fix(ir): the correction check counts what the stretch carried before judging (ENG-744) - #315
Conversation
The check was asked holistically — "did answering the finding break anything?" — and a reader answers that question well in one direction only. It confirms what a retelling says; it reads straight past the clause that quietly went, because an absence leaves nothing on the page to notice. In the room on 02/09 a stretch retold to answer one finding came back with the oath and without the burial, and the check returned a meaning_change and no loss at all. The loss surfaced two turns later, in a whole reading that could only guess which stretch it belonged to, and the same content ended up told in two of them. So the question is put the other way round. Before judging anything the reader now lists what the earlier telling of this stretch carried, element by element, and says of each whether the new telling still states it. Counting is what turns "is anything missing?" into a question with an answer. The stored prompt row is what a running room actually sends, so this file changes nothing by itself: it is the text to review, and the row is updated separately. Finding R4 of the 02/09 afternoon round on the open flow. No issue.
The count only helps if the room reads it. An entry marked as no longer told is a loss here, derived from the count rather than waited for: leaving it to the reader to also volunteer the same loss under findings is the holistic question again, and that question is the one that missed the burial. Reported twice, it still costs the team one mend. A written-out loss covers a counted one when its note carries every meaning-carrying word of the element — a strict rule on purpose, because the two mistakes are not equal: asking twice wastes a re-recording, suppressing wrongly means they are never asked at all. An element with no such word covers nothing, or the empty set would match every note and the room would drop every loss it counted. A reply with no count is read exactly as it always was. The room upgrades prompts by editing a row, so both shapes are in the air at once, and the older one must not start reading as a clean stretch. Findings the reader could not have addressed elsewhere still land on the corrected stretch, so nothing about where a finding goes changes here. Finding R4 of the 02/09 afternoon round on the open flow. No issue.
…olds A reply with no count is still read exactly as it always was — what goes is the second case saying so. `test_ir_a_correction_is_verified_on_its_own` proves it already, and not incidentally: its double never emits a count, so every one of its verifications is a reply in the older shape. Made the room require a count and ten of its cases fail, which is what that coverage looks like when you go to find it. A case that cannot fail alone is worth keeping only when it is the thing keeping the behaviour honest. This one was the same guard written twice, and the copy that carries it is the one a reader of the older shape would look in. Finding R4 of the 02/09 afternoon round on the open flow. No issue.
verify_correction's own 'carried' count only tracked what the earlier telling had, so an element the map gives that a correction brought back — precisely what answering a missing-element finding looks like — had nowhere to go but 'addition' once the reader compared the new telling to the map. Measured on P06 today: a corrected stretch that restored a clause the earlier telling had genuinely dropped came back with a false addition on it, three times running. The correction prompt now also enumerates the other direction (brought_back): elements the map gives that only the new telling states, named as the correction arriving rather than new content. The parser accepts the field, generates no finding from it, and — for a reader that still reports one as an addition despite the prompt's own rule — suppresses an addition finding that names the same element, mirroring the dedupe verify_correction already runs for a loss the reader both counted and wrote out. 🤖 Generated with [Nori](https://noriagentic.com) Co-Authored-By: Nori <contact@tilework.tech>
| note_words = _content_words(finding.note) | ||
| for element in brought_back: | ||
| words = _content_words(element) | ||
| if words and words <= note_words: |
There was a problem hiding this comment.
"The same subset-of-content-words test
_already_reporteduses for a derived loss, mirrored for the opposite mistake"
I am not too sure the mirror is exact here. On _already_reported a false match only stops the room repeating a loss the reader already wrote — the finding still reaches the team. Here a false match deletes the addition and nothing takes its place, which is the cost your own docstring below calls the worse one ("suppressing a real one means they are never asked at all"). And a real addition on the same clause is easily a superset: brought_back = "Orfa é citada pelo nome" against a note like "a equipe diz que Orfa é citada pelo nome do marido, que o mapa não conta" — every content word is there and the real addition goes silent. Could you check if the subset test is tight enough on this side? @henokteixeira
| verification over a field nothing here requires. | ||
| """ | ||
| if not isinstance(raw, list): | ||
| return [] |
There was a problem hiding this comment.
Lenient is fine, but silent too? Everything else in this parser leaves a trace when a reply comes back in a shape it cannot read, and here the backstop just does not apply — the exact case the addendum describes dropping the room into fail-safe. Maybe a logger.warning like the one in _elements_the_count_lost.
Linear: ENG-744
Summary
R4 finding from the 09/02 afternoon round of the open flow. No issue.
The verification of a correction asked, all at once, "did answering the finding break
anything?" — and a reader answers that question well in one direction only. It confirms
what the retelling says; it walks straight past the clause that dropped, because an
absence leaves nothing on the page to notice.
Measured in the room (session
b0cceac5…, Ruth 1:15-18, 09/02 15:47-15:58): stretch 4(
34c05876) said "Onde quer que você morrer, morrerei eu e aí serei sepultada". Theteam retold the whole stretch to answer the oath finding; the new version
(
aab97a9d) gained the oath and lost the burial.verify_correctionranover that pair and returned a
meaning_change— and nomissing. The loss only surfacedtwo turns later, at the full reread, pointed at chunk 3. The same content ended up
said in both 3 and 4, and the user saw "redundancy".
The session's own history shows the sequence (the guide's utterances, in order):
The research that motivated the design (arXiv 2608.31016) says LLM judges detect addition and
change well (0.79–0.94) and omission close to chance (0.50–0.63), and that what reverses it is
restructuring the task: enumerating the source elements first and checking them one by one.
What changed
the stretch's earlier count carried, element by element, and marks each one as
still stated or not (
carried, withelementandstill_told). The output JSON carriescarriedbeforeresolvedandfindings.missingon thisstretch, whether or not it exists in the model's
findingslist. Expecting the model to alsoreport it would be the holistic question again — the one that let the burial through.
missingis suppressed when somemissingthe modelwrote already carries, in its note, every content word of the element (≥4 letters,
unaccented, lowercase). A deliberately strict rule: the two errors are not equal —
counting twice costs a re-recording; suppressing wrongly means never asking.
An element with no content word covers nothing (the empty set is contained in every
note, and the room would discard every loss it counted).
carriedis read exactly as it always was.findings_after_correctiondid not change: the dedupe happens earlier, in the parser, so the listthat reaches it is already the room's decision.
get_prompt_textreads their_promptsrow and only falls back to the.mdfile when there is no row —and
seed_room_promptsinserts one per key. Editing the file does not change the prompt inproduction. The text below is what goes to the product owner's review; the row is updated
separately. The tests use the file (empty SQLite), which is why the compatibility case
exists: both reply shapes stay live at the same time.
Prompt before/after (for the product owner's review)
Probe on the real pair (Testing Plan case 7)
verify_correctionrun for real inside thetripod_backendcontainer over the databasepair (
earlier = 34c05876…,corrected = aab97a9d…, the oath finding), with the promptbefore and after. Two rounds, same result. Raw model responses:
BEFORE (base prompt) — no
missing{ "resolved": true, "findings": [ { "kind": "meaning_change", "note": "O novo relato diz que Noemi continuou insistindo para Rute voltar, mas o mapa diz que ela parou de falar." } ] }AFTER (this PR's prompt) — the burial comes out as not stated
{ "carried": [ { "element": "onde você morrer, eu morrerei", "still_told": false }, { "element": "onde você for sepultada, eu serei sepultada", "still_told": false } ], "resolved": true, "findings": [ { "kind": "missing", "note": "onde você morrer, eu morrerei" }, { "kind": "missing", "note": "onde você for sepultada, eu serei sepultada" }, { "kind": "meaning_change", "note": "O relato diz que Noemi continuou a insistir, mas o mapa diz que ela parou de falar." } ] }The model, with the new prompt, enumerated both losses and also reported them in
findings—exactly case 3. Running that real reply through this PR's parser: 3 findings
(2
missing+ 1meaning_change), not 5. The dedupe is what makes the difference.Test plan
New file
tests/test_ir_a_mend_is_checked_against_what_it_carried.py, 7 scenarios, allthrough the
finishroute, black-box. The reader double returns whatever reply the scenariodictates — the scenarios are about what the room does with it.
RED, before the change —
4 failed, 4 passed(measured with the original 8 scenarios)The Testing Plan expected case 3 to fail too, before the change. It does not fail, and the plan's
own parenthetical says why: with no derivation there is no duplicate, so the result is already
Mutations — each scenario has a positive control
if False and "carried" in parsed)assert 2 == 1still_toldsense invertedMutations applied over a copy of the file and restored by copy. Mutation 1 was re-measured after
the removal below and still fells the same 4.
One scenario withdrawn: the compatibility case was already covered
The Testing Plan called for a case for "a loss reported without enumeration still holds".
It was written, it passed, and mutation 4 showed where its coverage actually lives: making the
room require the count fells 10 scenarios in
test_ir_a_correction_is_verified_on_its_own— that file's double never emitsa count, so every verification of it is already a reply in the old shape. The case here was
the same guard written twice, and the copy that carries it is the one a reader of the old shape
would look for. Withdrawn at the plan author's request; behavior did not change.
Suites
87 passedtests/test_ir_a_correction_is_verified_on_its_own.py+tests/test_internalization_room_back_translation.py—80 passedruff check—All checks passed!;ruff format --check—811 files already formattedmypy app—Success: no issues found in 597 source files2458 passed, 5 skipped, 1 xfailedin 33min09 (measured before the removal; onescenario fewer since then) (
env -u DATABASE_URL,with
JWT_SECRET_KEYandDATABASE_URLon the command — without them the plan measured 125environment failures, none of them code)
git merge-treeagainst the neighboring slicesBoth clean against this HEAD:
henok/the-checked-turn-asks-nothing(touchesclosing_block, which this PR does not touch)henok/eng-719-the-parser-keeps-what-it-can-read(touches the same_parse_correction)For the reviewer
eng-719replaces the parser's silentreturn Noneexits with anamed condition. This PR's new block returns
Nonewithlogger.warning. The merge isclean, but once both land someone needs to reconcile the two styles — not done here, because
it is that slice's own scope.
resolved: falsediscards what the verification found.findings_after_correctionalreadydid this before this PR ("what the correction broke does not land on top of a finding that is
still standing"), and a loss derived on a refused correction is lost the same way.
Existing, documented behavior; not touched, but worth knowing that the derivation inherits it.
reaches the Speaker, on a turn with a model
additionplus a counted loss, the team hears theadditionfirst. A least-change choice; if the loss should take priority, it's a one-line fix.Addendum (09/02, evening) — the count had a blind side: what the correction brought back
Finding from today's open round, in a real session (P06,
118e4c47…, ~21:12 UTC): a stretchcorrected to answer a
meaning_change— the new version brought back exactly themissing clause ("junto com as suas servas", P06's map Proposition 1) — came back from
this PR's
verify_correctionwith a falseaddition, three times in a row, and droppedthe room into fail-safe twice.
Cause:
carriedonly enumerates what the earlier count said. An element the map givesthat the earlier count never had does not appear in
carried— it has nowhere to go — and whencomparing the new version against the map at step 3, the reader reads that clause as content that
was "not tracked" and reports it as
addition, even with this PR's earlier version alreadysaying "an element the map does give... is never an addition". The sentence alone changes nothing:
the reader never revisits that rule for a clause
carriednever listed.Fix (same spirit as this PR — count before judging, now in both directions):
brought_back: the elements the map givesfor the scope that the new count states and the earlier one did not — named this way
because they are the correction arriving, never an addition.
{element}), does not generate afinding from it, and — a mechanical belt, in case the reader still slips and reports one of
them as
additionanyway — suppresses that finding, with the same content-word dedupe thisPR already uses for a loss counted and reported twice (
_already_reported).Prompt diff (on top of what the PR already brought):
Real probe, with the corrected prompt — both directions, 3× each
verify_correctionrun for real (real credits, insidetripod_backend) over tworeal pairs:
(a) Today's P06 —
earlier= the 21:10:14 stretch ("Boaz fala pra Rute colher espigas emoutros campos e não no dele"),
corrected= the 21:12:29 one ("Boaz fala pra Rute colherespigas somente no campo dele, junto com as suas servas"), finding = message 1's
meaning_change.(b) This PR's Ruth 1 —
earlier=34c05876…("Onde quer que você morrer, morrerei eu eaí serei sepultada"),
corrected=aab97a9d…("Que o Senhor me castigue se outra coisa quenão seja a morte me separar de você. Quando Noemi viu que Rute estava mesmo decidida a
acompanhá-la, continuou a insistir pra que ela voltasse"), finding = the oath one (
missing:"o juramento de Rute, onde ela chama o nome de Javé e pede que ele a castigue se ela quebrar a
promessa, não apareceu") — the same session b0cceac5 used in this PR's original probe, the same
finding cited there as "the oath finding".
(a) — 3 of 3 rounds, no
addition:{ "carried": [{ "element": "Boaz fala para Rute colher espigas", "still_told": true }], "brought_back": ["colher somente no campo dele", "junto com as suas servas"], "resolved": true, "findings": [] }{ "carried": [{ "element": "Boaz fala para Rute colher espigas", "still_told": true }], "brought_back": ["colher somente no campo dele", "junto com as suas servas"], "resolved": true, "findings": [] }{ "carried": [{ "element": "Boaz fala para Rute colher espigas", "still_told": true }], "brought_back": ["colher somente no campo dele", "ficar junto com as servas dele"], "resolved": true, "findings": [] }(b) — 3 of 3 rounds, the burial is still reported as lost:
{ "carried": [ { "element": "onde você morrer, eu morrerei", "still_told": false }, { "element": "onde você for sepultada, eu serei", "still_told": false } ], "brought_back": ["juramento com o nome do Senhor", "pedido de castigo/maldição", "condição de que apenas a morte as separe", "Noemi vê a determinação de Rute"], "resolved": true, "findings": [ { "kind": "meaning_change", "note": "O relato diz que Noemi continuou a insistir para Rute voltar, mas o mapa diz que ela parou de falar." } ] }{ "carried": [ { "element": "onde você morrer, eu morrerei", "still_told": false }, { "element": "onde você for sepultada, eu serei", "still_told": false } ], "brought_back": ["juramento com o nome do Senhor", "pedido de castigo/maldição", "condição de que apenas a morte as separe", "Noemi vê a determinação de Rute"], "resolved": true, "findings": [ { "kind": "meaning_change", "note": "O relato diz que Noemi continuou a insistir para Rute voltar, mas o mapa diz que ela parou de falar." } ] }{ "carried": [ { "element": "onde você morrer, eu morrerei", "still_told": false }, { "element": "onde você for sepultada, eu serei", "still_told": false } ], "brought_back": ["juramento de Rute invocando o Senhor", "fórmula de auto-maldição (que o Senhor me castigue)", "condição do juramento (se algo além da morte separar)", "Noemi vê a determinação de Rute"], "resolved": true, "findings": [ { "kind": "missing", "note": "onde você morrer, eu morrerei" }, { "kind": "missing", "note": "onde você for sepultada, eu serei" }, { "kind": "meaning_change", "note": "O relato diz que Noemi continuou a insistir, mas o mapa diz que ela parou de falar (v. 18)." } ] }Running all six real responses through this PR's parser (
_parse_correction, already with the fix):(a) 0 of 3 carry
additionin the final result; (b) 3 of 3 still carry the burialas
missing(the dedupe betweencarriedand the explicit finding in round 3 still collapsesto a single finding, as already proven by this PR's original probe).
One earlier attempt failed and is recorded here for transparency: the sentence alone in the
Addedrule (without thebrought_backlist) did not close it — 3 of 3 still came back asaddition, because the reader never gets to test that sentence for a clausecarriedneverlisted. That is what motivated the structural fix above instead of just reinforcing the existing
rule.
Verification
tests/test_ir_a_mend_is_checked_against_what_it_carried.py+tests/test_ir_a_correction_is_verified_on_its_own.py+tests/test_internalization_room_back_translation.py— 89 passedbrought_backwith emptyfindingsisa clean correction (did not fail before — the unknown field was already ignored); an
additionwith the same words as an element in
brought_backis suppressed (did fail before,assert 1 == 0; a mutation removing the filter reproduces the same failure, restored bycopy).
ruff check— All checks passed;ruff format --check— cleanmypy app/services/internalization_room/back_translation.py— Success: no issues found