15 KiB
Experiment 57J.86 — Smallest Recovery Contract for Null Semantic/Action with Good Mutation (Read-Only Design)
Branch: feature/semantic-action-contract-v0.23
Starting HEAD: a40a3e3 (experiment: diagnose null semantic mutation path)
Objective
Answer exactly:
What is the smallest recovery contract that lets the engine preserve a good mutation while recovering missing semantic/action declarations, without regenerating or discarding the mutation?
Read-only architecture design. No code changes. No Ollama calls. No live API calls.
Context Files Read
docs/current-handoff.md(handoff state through 57J.85)docs/experiment-57j85.md(transition compatibility diagnosis)lib/graph/schema.js(schema truth for answerMeaning, structuralActionRequired, graphUpdateSchema)lib/graph/utils.js(validator logic at line 868+)lib/graph/apply-proposal.js(validation/parsing section: lines 2937–3146; applyValidatedProposal entry at 3182)lib/graph/orchestrator.js(proposal rejection path and diagnostics snapshot construction)app/api/cases/update/route.js(production API boundary — no retry/repair logic)
1 — Separate the Two Missing Declarations
A. structuralActionRequired
Can deterministic code recover it from proposal structure alone?
PARTIAL — YES for the true direction only.
Test: hasMeaningfulChange=true → structuralActionRequired=true
From lib/graph/utils.js lines 878–883:
const hasMeaningfulChange =
update.addedNodes.length > 0 ||
statusChanged ||
valueChanged ||
update.addedEdges.length > 0 ||
update.removedEdgeIds.length > 0;
If hasMeaningfulChange=true, then at least one of these conditions holds:
- addedNodes.length > 0 (new nodes were added)
- statusChanged (at least one node's status was changed)
- valueChanged (at least one node's value was changed)
- addedEdges.length > 0 (new edges were added)
- removedEdgeIds.length > 0 (edges were removed)
Each of these is by definition a structural action. The model declared that it intended to act (via the mutation itself). Setting structuralActionRequired=true when hasMeaningfulChange=true is a deterministic mapping from "mutation present" → "action was required."
No semantic inference is needed. This is purely structural: if nodes/edges were added or changed, structural action occurred.
Would doing so preserve the original meaning of structuralActionRequired as a model declaration, or would it change the field into an engine-derived fact?
CHANGES FIELD SEMANTICS.
structuralActionRequired was designed as a model declaration — the model telling the engine "I know I must act structurally." Deriving it from mutation presence converts it to an engine-inferred fact. The semantic shift is:
- Before (declaration): "The model consciously chose to declare action is required"
- After (inference): "There was structural change, therefore action must have been needed"
The practical effect for this experiment's scope is identical (action flows forward either way). But the contract semantics shift from declaration → inference. The field no longer reflects model intent; it reflects engine observation.
This matters for future contract work because:
- A model declaring
structuralActionRequired=falsewith mutation would still be a contradiction (rule 3 checks structuralActionRequired===false) - An engine-derived
structuralActionRequired=truefrom mutation cannot be "wrong" — it is tautologically true by definition of the mutation
B. answerMeaning
Can existing structured mutation fields recover userSupportedMeaning, supportCategory, resolutionGuidance?
NOT RECOVERABLE.
Reasoning:
userSupportedMeaningis the model's semantic interpretation of the raw user answer — a natural language summary of what the user established. No graph field captures this.supportCategoryclassifies the reasoning pattern (relative_priority_only, conditional_tradeoff, uncertain, explicit_hard_constraint, other). This requires understanding the raw answer text, not just the structural result.resolutionGuidance(must_remain_unresolved, may_resolve, must_resolve) is a judgment about what downstream processing should do — a control signal, not derivable from mutation shape.
The mutation arrays (addedNodes, updatedNodes, addedEdges) capture WHAT was done to the graph but not WHY or WHAT THE USER ESTABLISHED. The same mutation shape (new reported_claim node) could result from radically different answer meanings (explicit fact vs. estimate vs. uncertainty). There is no deterministic mapping from mutation structure back to semantic intent.
2 — Existing Recovery Capabilities
Can validator mutate/repair proposal: NO
validateGraphUpdate (utils.js line 868) returns { valid, errors } only. It has no side effects on the input proposal and no repair logic.
Can validator preserve rejected proposal and continue: PARTIAL
The orchestrator captures a rejectedProposalSnapshot (orchestrator.js lines 693–725) for diagnostics when rejection occurs at proposal_compatibility. This is write-only diagnostic evidence — it does not feed back into any repair mechanism.
Can orchestrator issue a bounded repair call: NO
The orchestrator flow (orchestrator.js lines 620–800) is strictly linear:
- Build prompt → model call
- Parse proposal (Zod + normalisation)
- Apply validated proposal → direct graph mutation
- Return success or error
There is no retry, repair, or secondary call path. On rejection at proposal_compatibility, the orchestrator returns an error with diagnostic snapshot and terminates.
Can existing code call the model again with the original proposal attached: NO
No mechanism exists to re-invoke the model with any proposal content. The raw response is parsed once and never retained after parsing. No prompt-building path accepts a previous proposal as context.
Can a repaired proposal reuse the exact original mutation arrays: REQUIRES NEW PATH
Currently, rejected proposals are discarded. Only a diagnostic snapshot (subset of fields) survives. To preserve and reuse the exact mutation arrays through repair would require new plumbing: retention of parsed proposal past rejection, plus a repair call path that accepts mutation-arrays-as-immutable-context.
3 — Compare Four Recovery Designs
Option A — deterministic action-field fill only
When hasMeaningfulChange=true and structuralActionRequired=null, engine sets structuralActionRequired=true. Leaves answerMeaning unchanged (null).
| Criterion | Answer |
|---|---|
| preserves useful original mutation | YES |
| requires new LLM call | NO |
| can change graph mutation | NO — only fills one boolean field on the proposal; mutation arrays untouched |
| requires English keyword inference | NO |
| retains semantic accountability | PARTIAL — recovers structural accountability (action = required, inferred from mutation); leaves answerMeaning unaccountable (null) |
| new failure surface | LOW — deterministic fill cannot produce incorrect values. If hasMeaningfulChange=true, structuralActionRequired MUST be true by definition. No hallucination risk. |
Option B — one bounded declaration-only repair call
Preserve original mutation arrays exactly. Ask model to populate only:
- answerMeaning (userSupportedMeaning, supportCategory, resolutionGuidance)
- structuralActionRequired
Forbidden from changing addedNodes, updatedNodes, resolvedUnknownNodeIds, addedEdges, removedEdgeIds.
| Criterion | Answer |
|---|---|
| preserves useful original mutation | YES — mutation arrays are passed as immutable context to the repair call |
| requires new LLM call | YES — one additional bounded call |
| can change graph mutation | NO — forbidden by contract boundary of the repair call |
| requires English keyword inference | NO — repair receives raw answer text + original proposal; must produce structured semantics, not derive from keywords |
| retains semantic accountability | FULL — all three missing fields (answerMeaning + structuralActionRequired) are recovered through model declaration, not engine inference |
| new failure surface | MEDIUM — second LLM call introduces latency/cost variance; repair prompt must be carefully constrained to prevent mutation drift |
Option C — full proposal regeneration
Reject original proposal. Ask model to regenerate everything from raw answer + graph state.
| Criterion | Answer |
|---|---|
| preserves useful original mutation | NO — entirely discarded; new mutation may differ materially |
| requires new LLM call | YES |
| can change graph mutation | YES — full regeneration allows different nodes, edges, values |
| requires English keyword inference | NO — but introduces cold-start variance across two generations from same input |
| retains semantic accountability | FULL — regenerated proposal is fully accountable (model produces fresh declarations for everything) |
| new failure surface | HIGH — double the cost; double the variance; original good data is lost |
Option D — keep transition compatibility unchanged
No repair architecture. Accept null/null + mutation as-is.
| Criterion | Answer |
|---|---|
| preserves useful original mutation | YES — current path accepts it |
| requires new LLM call | NO |
| can change graph mutation | NO |
| requires English keyword inference | NO |
| retains semantic accountability | NONE — no semantic declarations, no action declaration. The graph records what happened but not why or what the user meant. |
| new failure surface | LOW — no new code; existing path already exercised |
4 — 57J.84 Walkthrough
Apply each option to the exact 57J.84 shape:
answerMeaning = null
structuralActionRequired = null
addedNodes = [n_lease_savings_claim (reported_claim, £2M/year)]
addedEdges = [supports edge → n_savings_realism]
existing uncertainty preserved (n_savings_realism)
Option A: PRESERVED
Engine sees hasMeaningfulChange=true (new node + new edge). Sets structuralActionRequired=true deterministically. Mutation arrays pass through unchanged. answerMeaning remains null but mutation is preserved.
Option B: PRESERVED
Repair call receives original mutation arrays as immutable context. Produces answerMeaning with userSupportedMeaning ("User claims £2M/year savings from lease elimination, remaining uncertain about realism"), supportCategory="other", resolutionGuidance="may_resolve". structuralActionRequired=true. Mutation preserved exactly.
Option C: REGENERATED
Original mutation discarded. New proposal generated — might produce different node IDs, slightly different label/description for the claim, potentially different edge relationships. £2m information survives only if model regenerates it faithfully.
Option D: ACCEPTED UNCHANGED
Proposal accepted as-is through transition compatibility path. Mutation applied. answerMeaning=null and structuralActionRequired=null persist on the graph with no recovery.
5 — Repair-Call Ownership
If option B is chosen, the narrowest possible contract:
Should repair be allowed to reconsider semantic meaning? YES
The entire purpose of the repair call is to recover meaning declarations. It must produce userSupportedMeaning, supportCategory, and resolutionGuidance.
Should repair be allowed to alter mutation arrays? NO
Mutation integrity is the core invariant. The repair call must treat addedNodes/updatedNodes/addedEdges as frozen input context. Only semantic/action fields may be populated or changed.
Should repair be allowed to alter selectedQuestion? NO
selectedQuestion is derived from the mutation (nodeId references an unresolved unknown created or preserved by the mutation). Altering it would create a mismatch with the frozen mutation. Keep as-is.
Should repair receive raw user answer? YES
answerMeaning fields require understanding of the raw answer text. The repair call cannot produce userSupportedMeaning without the source material.
Should repair receive original proposal? YES
The repair call needs to see the original mutation arrays (as frozen context) and any existing non-null fields (to avoid overwriting). It must know what was already produced.
6 — Call-Budget Consequence
Repair classification: SECOND-STAGE REPAIR
This is not a RETRY (retry implies failure + repetition of the same operation). This is not a NORMAL SECOND MODEL CALL (implies independent decision-making). This is a repair: it operates on an accepted-but-incomplete primary proposal, adding missing declarations without regenerating.
Can existing call accounting distinguish primary vs repair calls? NO
Current call accounting tracks startCalls and updateCalls. There is no distinction between primary proposals and repair sub-calls within those counts. A bounded repair would be invisible to current accounting unless classified as either start or update.
Requires tooling change: YES — for complete distinction, but minimal.
Existing accounting can approximate the distinction by noting that repairs only occur on proposal_compatibility rejections (as opposed to proposal_validation, provider, or application failures). No new counters needed if using stage-diagnosis as proxy. But clean separation would require a call type field.
7 — Chose the Smallest Next Boundary
Choice: B — DECLARATION-ONLY REPAIR CALL
Why: Option A (deterministic action fill) solves only half the problem (structuralActionRequired) and changes field semantics from declaration to inference. Option C (full regeneration) discards the entire point of this experiment (preserving good mutation). Option D (keep transition path) accepts the gap indefinitely without resolving it.
Option B is the smallest design that:
- Preserves the exact original mutation (no regeneration, no discard)
- Recovers ALL missing fields (not just structuralActionRequired)
- Retains declaration semantics (model still produces the declarations)
- Is bounded (one call, forbidden from changing mutations)
- Solves the 57J.84 case fully (both meaning and action recovered)
The trade-off: one additional LLM call per affected proposal vs. semantic accountability gap. This trade-off is justified by the volume of proposals flowing through the null/null/mutation path (confirmed in 57J.84 as the dominant pattern for "implicit meaning through structure" cases).
8 — Non-Negotiable Invariants
For option B (declaration-only repair call):
original useful mutation preserved: YES
no keyword/synonym logic: YES
no mutation regeneration: YES
bounded additional model calls: 1
provider-agnostic: YES
57J.84 information would survive: YES
Chosen Boundary Summary
The smallest recovery contract that preserves good mutation while recovering missing declarations is a bounded second-stage repair call that receives the raw answer and original proposal as context, produces only answerMeaning and structuralActionRequired fields, and is forbidden from touching any mutation arrays. This converts a null/null/mutation acceptance (57J.84) into a fully declared proposal with zero mutation change.