fix(reconstruction): keep complete contradictions beside incomplete entries (#6009)

The obligation ledger flattened every contradiction entry for an obligation
into one Evidence list, so one entry naming absent Evidence suppressed a
sibling entry whose Evidence was all present. Group whole manifest entries
and evaluate each one: any complete entry still reports `contradiction`,
while absent references from any entry stay visible as
`contradiction-evidence-missing`.

Share the ready packaged-process request fixture with the CLI boundary test,
which now covers both entry orders through the public command.

Fixes #5925

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 authored and GitHub committed 2026-10-21 07:54:37 -07:00
1 parent 63b43b69bb
commit 2319166c2e
5 files changed
+151 -80

No files matched your search

@@ -1,73 +1,17 @@
import { describe, expect, it } from "vitest";
import {
boundProofEvidence,
build,
originalCases,
processEvidence,
proofEvidence,
readyProcessLedgerRequest,
request,
reviewedObligation,
} from "./ReconstructionObligationLedger.fixture.js";
import { deriveReconstructionObligationCandidates } from "./ReconstructionObligationCandidates.js";
import { evaluateReconstructionObligationLedger } from "./ReconstructionObligationLedgerEvaluation.js";
/** Build a request whose packaged-process obligation closes as ready. */
const readyProcessLedgerRequest = () => {
const capture = processEvidence();
const initial = build(request([capture]));
const obligation = initial.obligations[0];
if (obligation === undefined) throw new Error("Expected process obligation");
const fixtureIds = obligation.required_case_kinds.map(
(caseKind) => `packaged.${caseKind}`,
);
const proof = boundProofEvidence("packaged", {
obligation_ids: [obligation.obligation_id],
fixture_ids: fixtureIds,
case_kinds: obligation.required_case_kinds,
verifier_ids: ["verifier.packaged"],
claim_ids: ["claim.process"],
});
const ledgerRequest = request([capture, proof], {
manifest: {
bindings: [
{
obligation_id: obligation.obligation_id,
owner: {
module_path: "src/processOwner.ts",
symbol: "runProcess",
owner_sha256: "2".repeat(64),
},
parser_type: null,
original_cases: originalCases(obligation, capture.evidence_id),
fixtures: obligation.required_case_kinds.map((caseKind) => ({
fixture_id: `packaged.${caseKind}`,
case_kind: caseKind,
authority: "packaged-process",
evidence_ids: [proof.evidence_id],
})),
verifier: {
verifier_id: "verifier.packaged",
claim_id: "claim.process",
command: "npm run verify:package -- process",
authority: "packaged-process",
status: "pass",
result_evidence_id: proof.evidence_id,
enumerated_obligation_ids: [obligation.obligation_id],
nondeterminism: {
mode: "partial-order",
specification:
"Startup precedes teardown; worker completion is unordered.",
},
},
},
],
contradictions: [],
},
});
return ledgerRequest;
};
describe("reconstruction obligation ledger", () => {
it("emits the complete deterministic ledger and closure identity", () => {
const evidence = proofEvidence("review");
@@ -120,6 +120,63 @@ export const originalCases = (
location: `/normalized_result/cases/${caseKind}`,
}));
/** Build a request whose packaged-process obligation closes as ready. */
export const readyProcessLedgerRequest =
(): ReconstructionObligationLedgerInput => {
const capture = processEvidence();
const initial = build(request([capture]));
const obligation = initial.obligations[0];
if (obligation === undefined)
throw new Error("Expected process obligation");
const fixtureIds = obligation.required_case_kinds.map(
(caseKind) => `packaged.${caseKind}`,
);
const proof = boundProofEvidence("packaged", {
obligation_ids: [obligation.obligation_id],
fixture_ids: fixtureIds,
case_kinds: obligation.required_case_kinds,
verifier_ids: ["verifier.packaged"],
claim_ids: ["claim.process"],
});
return request([capture, proof], {
manifest: {
bindings: [
{
obligation_id: obligation.obligation_id,
owner: {
module_path: "src/processOwner.ts",
symbol: "runProcess",
owner_sha256: "2".repeat(64),
},
parser_type: null,
original_cases: originalCases(obligation, capture.evidence_id),
fixtures: obligation.required_case_kinds.map((caseKind) => ({
fixture_id: `packaged.${caseKind}`,
case_kind: caseKind,
authority: "packaged-process",
evidence_ids: [proof.evidence_id],
})),
verifier: {
verifier_id: "verifier.packaged",
claim_id: "claim.process",
command: "npm run verify:package -- process",
authority: "packaged-process",
status: "pass",
result_evidence_id: proof.evidence_id,
enumerated_obligation_ids: [obligation.obligation_id],
nondeterminism: {
mode: "partial-order",
specification:
"Startup precedes teardown; worker completion is unordered.",
},
},
},
],
contradictions: [],
},
});
};
/** Create a minimal reviewed obligation for ledger closure scenarios. */
export const reviewedObligation = (
obligationId: string,
@@ -7,6 +7,7 @@ import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
type Diagnostic = ReconstructionObligation["diagnostics"][number];
type Binding = NonNullable<ReconstructionObligation["binding"]>;
type Contradiction = ReconstructionObligationManifest["contradictions"][number];
export const groupManifestBindings = (
manifest: ReconstructionObligationManifest,
@@ -22,12 +23,12 @@ export const groupManifestBindings = (
export const groupManifestContradictions = (
manifest: ReconstructionObligationManifest,
): ReadonlyMap<string, readonly string[]> => {
const grouped = new Map<string, string[]>();
): ReadonlyMap<string, readonly Contradiction[]> => {
const grouped = new Map<string, Contradiction[]>();
for (const contradiction of manifest.contradictions)
grouped.set(contradiction.obligation_id, [
...(grouped.get(contradiction.obligation_id) ?? []),
...contradiction.evidence_ids,
contradiction,
]);
return grouped;
};
@@ -59,26 +60,33 @@ export const uniqueObligationDiagnostics = (
export const contradictionDiagnostics = (
obligationId: string,
contradictions: ReadonlyMap<string, readonly string[]>,
contradictions: ReadonlyMap<string, readonly Contradiction[]>,
evidenceById: ReadonlyMap<string, Evidence>,
): Diagnostic[] => {
const evidenceIds = contradictions.get(obligationId) ?? [];
if (evidenceIds.length === 0) return [];
const missing = evidenceIds.filter(
(evidenceId) => !evidenceById.has(evidenceId),
// Each manifest entry is a separate claim. Absent Evidence leaves only its
// own entry unestablished; it must not hide a sibling whose Evidence exists.
const entries = contradictions.get(obligationId) ?? [];
const missing = new Set(
entries.flatMap(({ evidence_ids: ids }) =>
ids.filter((evidenceId) => !evidenceById.has(evidenceId)),
),
);
return missing.length > 0
? [
{
code: "contradiction-evidence-missing",
detail: `Contradiction references missing Evidence: ${missing.join(", ")}.`,
},
]
: [
{
code: "contradiction",
detail:
"Authenticated contradictory Evidence reopens this obligation.",
},
];
const diagnostics: Diagnostic[] = [];
if (missing.size > 0)
addObligationDiagnostic(
diagnostics,
"contradiction-evidence-missing",
`Contradiction references missing Evidence: ${[...missing].join(", ")}.`,
);
if (
entries.some(({ evidence_ids: ids }) =>
ids.every((evidenceId) => evidenceById.has(evidenceId)),
)
)
addObligationDiagnostic(
diagnostics,
"contradiction",
"Authenticated contradictory Evidence reopens this obligation.",
);
return diagnostics;
};
@@ -47,7 +47,7 @@ interface EvaluationContext {
readonly candidates: ReadonlyMap<string, ReconstructionObligationCandidate>;
readonly evidence: ReadonlyMap<string, Evidence>;
readonly bindings: ReadonlyMap<string, readonly Binding[]>;
readonly contradictions: ReadonlyMap<string, readonly string[]>;
readonly contradictions: ReturnType<typeof groupManifestContradictions>;
}
export const evaluateReconstructionObligationLedger = ({
@@ -8,6 +8,7 @@ import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
import {
MIXED_AUTHORITY_SUMMARY,
mixedAuthorityRequest,
readyProcessLedgerRequest,
} from "../../../src/application/ReconstructionObligationLedger.fixture.js";
const execute = promisify(execFile);
@@ -161,3 +162,64 @@ describe("reconstruction obligation ledger CLI parity", () => {
]);
}, 20_000);
});
describe("reconstruction obligation ledger CLI contradictions", () => {
it("keeps a complete contradiction beside an entry naming absent Evidence", async () => {
const input = readyProcessLedgerRequest();
const obligationId = input.manifest.bindings[0]?.obligation_id;
const [present, other] = input.evidence_bundle.records.map(
({ evidence_id: id }) => id,
);
if (
obligationId === undefined ||
present === undefined ||
other === undefined
)
throw new Error("Expected a bound obligation and two Evidence records");
const absent = `ev_${"f".repeat(64)}`;
const complete = {
obligation_id: obligationId,
evidence_ids: [present, other],
};
const incomplete = {
obligation_id: obligationId,
evidence_ids: [present, absent],
};
// Each entry is its own claim; entry order must not decide the outcome.
for (const contradictions of [
[complete, incomplete],
[incomplete, complete],
]) {
const { stdout } = await execute(
process.execPath,
[
"scripts/rea.mjs",
"build-reconstruction-obligation-ledger",
JSON.stringify({
...input,
manifest: { ...input.manifest, contradictions },
}),
"--json",
],
{ cwd: process.cwd(), env: process.env, maxBuffer: 16 * 1_024 * 1_024 },
);
const ledger = JSON.parse(stdout).normalized_result;
expect(ledger).toMatchObject({
status: "failed",
reports: { contradicted_obligation_ids: [obligationId] },
obligations: [
{
status: "contradicted",
diagnostics: [
{
code: "contradiction-evidence-missing",
detail: `Contradiction references missing Evidence: ${absent}.`,
},
{ code: "contradiction" },
],
},
],
});
}
}, 20_000);
});