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:
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);
|
||||
});
|
||||
Reference in new issue
Block a user