perf(evidence): validate ledger mutations incrementally

This commit is contained in:
morluto 2026-07-25 09:11:07 +00:00
parent adc4852f3a
commit 809dfbfe48
2 changed files with 134 additions and 19 deletions

View File

@ -3,6 +3,7 @@ import canonicalize from "canonicalize";
import {
createEvidenceBundle,
parseEvidenceBundle,
validateResidualUnknownAddition,
type EvidenceBundle,
} from "../domain/evidenceBundle.js";
import {
@ -216,8 +217,6 @@ export class EvidenceLedger {
} catch (cause: unknown) {
return err(new UnknownRegistryError("invalid-transition", { cause }));
}
const pendingRecords = new Map(this.#records);
pendingRecords.set(evidence.evidence_id, evidence);
const existingUnknown = this.#unknownHeads.get(unknown.unknown_id);
if (existingUnknown !== undefined) {
if (
@ -225,20 +224,12 @@ export class EvidenceLedger {
existingUnknown.revision_digest !== unknown.revision_digest
)
return err(new UnknownRegistryError("already-exists"));
const checked = this.#validateCandidate(
pendingRecords,
this.#unknownRevisions,
);
const checked = this.#appendIncremental([evidence]);
if (!checked.ok) return checked;
this.#commit(pendingRecords, this.#unknownRevisions, checked.value);
return ok(null);
}
pendingRecords.set(mutation.evidence_id, mutation);
const pendingUnknowns = new Map(this.#unknownRevisions);
pendingUnknowns.set(unknownRevisionKey(unknown), unknown);
const checked = this.#validateCandidate(pendingRecords, pendingUnknowns);
const checked = this.#appendIncremental([evidence, mutation], unknown);
if (!checked.ok) return checked;
this.#commit(pendingRecords, pendingUnknowns, checked.value);
return ok(structuredClone(unknown));
}
@ -332,16 +323,68 @@ export class EvidenceLedger {
}),
);
}
const pendingRecords = new Map(this.#records);
pendingRecords.set(mutationEvidence.evidence_id, mutationEvidence);
const pendingUnknowns = new Map(this.#unknownRevisions);
pendingUnknowns.set(unknownRevisionKey(unknown), unknown);
const checked = this.#validateCandidate(pendingRecords, pendingUnknowns);
const checked = this.#appendIncremental([mutationEvidence], unknown);
if (!checked.ok) return checked;
this.#commit(pendingRecords, pendingUnknowns, checked.value);
return ok(structuredClone(unknown));
}
#appendIncremental(
records: readonly Evidence[],
unknown?: ResidualUnknown,
): Result<
void,
EvidenceIntegrityError | EvidenceLimitError | UnknownRegistryError
> {
const additions = new Map<string, Evidence>();
let addedBytes = 0;
for (const evidence of records) {
const existing =
additions.get(evidence.evidence_id) ??
this.#records.get(evidence.evidence_id);
if (existing !== undefined) {
if (!recordsAgree(existing, evidence))
return err(new EvidenceIntegrityError("Conflicting evidence record"));
continue;
}
additions.set(evidence.evidence_id, evidence);
addedBytes += serializedBytes(evidence);
}
const unknownKey =
unknown === undefined ? undefined : unknownRevisionKey(unknown);
if (unknownKey !== undefined && this.#unknownRevisions.has(unknownKey))
return err(new UnknownRegistryError("integrity"));
const unknownBytes = unknown === undefined ? 0 : serializedBytes(unknown);
if (
this.#exceedsRecordLimit(
this.#records.size + additions.size,
this.#unknownRevisions.size + (unknown === undefined ? 0 : 1),
)
)
return err(new EvidenceLimitError("records", this.limits.maxRecords));
if (this.#bytes + addedBytes + unknownBytes > this.limits.maxBytes)
return err(new EvidenceLimitError("bytes", this.limits.maxBytes));
if (unknown !== undefined)
try {
validateResidualUnknownAddition(
unknown,
{
get: (id) => additions.get(id) ?? this.#records.get(id),
has: (id) => additions.has(id) || this.#records.has(id),
},
this.#unknownHeads,
);
} catch (cause: unknown) {
return err(new UnknownRegistryError("integrity", { cause }));
}
for (const [id, evidence] of additions) this.#records.set(id, evidence);
if (unknown !== undefined && unknownKey !== undefined) {
this.#unknownRevisions.set(unknownKey, unknown);
this.#unknownHeads.set(unknown.unknown_id, unknown);
}
this.#bytes += addedBytes + unknownBytes;
return ok(undefined);
}
#validateCandidate(
records: ReadonlyMap<string, Evidence>,
unknowns: ReadonlyMap<string, ResidualUnknown>,

View File

@ -239,7 +239,7 @@ const validateUnknownGraph = (
const validateVerifiedResolution = (
unknown: ResidualUnknown,
evidenceById: ReadonlyMap<string, Evidence>,
evidenceById: Pick<ReadonlyMap<string, Evidence>, "get">,
): void => {
if (unknown.contradicting_evidence_ids.length > 0)
throw new TypeError(
@ -260,6 +260,78 @@ const validateVerifiedResolution = (
);
};
/**
* Validate one new residual-unknown revision against an already valid ledger.
* This keeps ordinary mutations proportional to the affected revision while
* full bundle imports continue to validate the complete graph.
*/
export const validateResidualUnknownAddition = (
unknown: ResidualUnknown,
evidenceById: Pick<ReadonlyMap<string, Evidence>, "get" | "has">,
currentHeads: ReadonlyMap<string, ResidualUnknown>,
): void => {
const current = currentHeads.get(unknown.unknown_id);
if (
current === undefined
? unknown.revision !== 1 || unknown.previous_revision_digest !== null
: unknown.revision !== current.revision + 1 ||
unknown.previous_revision_digest !== current.revision_digest
)
throw new TypeError(
`Residual unknown ${unknown.unknown_id} revision chain is broken`,
);
for (const evidenceId of referencedEvidenceIds(unknown))
if (!evidenceById.has(evidenceId))
throw new TypeError(
`Residual unknown references missing evidence ${evidenceId}`,
);
for (const relationship of unknown.relationships)
if (
relationship.unknown_id !== unknown.unknown_id &&
!currentHeads.has(relationship.unknown_id)
)
throw new TypeError(
`Residual unknown references missing unknown ${relationship.unknown_id}`,
);
if (unknown.resolution?.disposition === "verified")
validateVerifiedResolution(unknown, evidenceById);
for (const relationship of unknown.relationships)
if (
relationship.type === "depends-on" &&
reachesUnknown(
relationship.unknown_id,
unknown.unknown_id,
currentHeads,
unknown,
)
)
throw new TypeError("Residual unknown dependency graph contains a cycle");
};
const reachesUnknown = (
start: string,
goal: string,
currentHeads: ReadonlyMap<string, ResidualUnknown>,
candidate: ResidualUnknown,
): boolean => {
const pending = [start];
const visited = new Set<string>();
while (pending.length > 0) {
const id = pending.pop();
if (id === undefined) break;
if (id === goal) return true;
if (visited.has(id)) continue;
visited.add(id);
const unknown =
id === candidate.unknown_id ? candidate : currentHeads.get(id);
if (unknown === undefined) continue;
for (const relationship of unknown.relationships)
if (relationship.type === "depends-on")
pending.push(relationship.unknown_id);
}
return false;
};
const evidenceQualifies = (
unknown: ResidualUnknown,
evidence: Evidence,