diff --git a/packages/lina-core/src/agents/behavior-types.ts b/packages/lina-core/src/agents/behavior-types.ts index 36190ee..e8e682c 100644 --- a/packages/lina-core/src/agents/behavior-types.ts +++ b/packages/lina-core/src/agents/behavior-types.ts @@ -172,3 +172,20 @@ export interface BehaviorPersistence { ): CurrentBehaviorProjection; status(agentId: string): BehaviorJob[]; } + +export const DIMENSION_SOURCES = ["reflection", "neural"] as const; +export type DimensionSource = (typeof DIMENSION_SOURCES)[number]; +export type PersonaDimensionRef = { + schemaRevision: number; + dimensionId: string; + source: DimensionSource; +}; +/** F2 fills this; F1 declares the shape only. */ +export type NeuralProjectionRef = { + schemaVersion: 1; + agentId: string; + scopeId: string; + schemaRevision: number; + observationRef: string; + projectionDigest: string; +}; diff --git a/packages/lina-core/src/agents/index.ts b/packages/lina-core/src/agents/index.ts index 4ae5a22..36285bd 100644 --- a/packages/lina-core/src/agents/index.ts +++ b/packages/lina-core/src/agents/index.ts @@ -38,3 +38,62 @@ export { parseVisualReference, visualIdentityDigest, } from "./visual-validation.ts"; + +// Judgment contracts. + +export * from "./judgment.ts"; +export { + assessmentInputDigest, + INTENTION_TRANSITIONS, + intentionDigest, + judgmentDigest, + parseAssessment, + parseAssessmentSet, + parseIntentionRecord, + parseIntentionTransition, + parseJudgmentSnapshotRef, + parseObjectiveProfile, + parseObjectiveProfileRef, + parseOptionAssessment, + parseResolutionRecord, + parseSelectionSpec, + snapshotDigest, + transitionIntention, +} from "./judgment-validation.ts"; + +// Persona schema contracts. + +export type { + DimensionSource, + NeuralProjectionRef, + PersonaDimensionRef, +} from "./behavior-types.ts"; +export { DIMENSION_SOURCES } from "./behavior-types.ts"; +export type { + PersonaDimension, + PersonaDimensionKind, + PersonaSchema, +} from "./persona-schema.ts"; +export { + parsePersonaSchema, + personaSchemaDigest, + personaSchemaFromLifeDefinition, +} from "./persona-schema.ts"; + +// Personal catalog and arbitration policy. + +export * from "./judgment-catalog.ts"; +export * from "./judgment-policy.ts"; + +// Judgment persistence. + +export { JUDGMENT_SCHEMA_VERSION } from "./judgment-schema.ts"; +export { JudgmentStore } from "./judgment-store.ts"; + +// Behavior identity (pre-existing pure function; exposed so persona contract +// tests can pin the BehaviorJobInput fingerprint through the public barrel). + +export { behaviorFingerprint } from "./behavior-validation.ts"; + +export * from "./judgment-candidates.ts"; +export * from "./judgment-dialogue.ts"; diff --git a/packages/lina-core/src/agents/judgment-candidates.ts b/packages/lina-core/src/agents/judgment-candidates.ts new file mode 100644 index 0000000..2f9ec39 --- /dev/null +++ b/packages/lina-core/src/agents/judgment-candidates.ts @@ -0,0 +1,192 @@ +import { INTENTION_STATUSES, type IntentionStatus } from "./judgment.ts"; +import { + type CanonicalOption, + parseCanonicalOption, +} from "./judgment-catalog.ts"; +import type { HostEligibility } from "./judgment-policy.ts"; +import { judgmentDigest } from "./judgment-validation.ts"; +import { boundedId, boundedText } from "./validation.ts"; + +/** Closed Host inputs, independent of assessment arrival order and session lifetime. */ +export type CandidateSet = { + schemaVersion: 1; + roundId: string; + snapshotDigest: string; + options: CanonicalOption[]; + eligibility: HostEligibility; + intentionRefs: Array<{ + intentionId: string; + revision: number; + status: IntentionStatus; + digest: string; + }>; + candidateDigest: string; +}; + +function fields( + value: unknown, + keys: string[], + label: string, +): Record { + if ( + value === null || + typeof value !== "object" || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) + throw Error(`invalid ${label}`); + const row = value as Record; + for (const key of Object.keys(row)) + if (!keys.includes(key)) throw Error(`unknown ${label} field ${key}`); + for (const key of keys) + if (!Object.hasOwn(row, key)) throw Error(`missing ${label} field ${key}`); + return row; +} +function list(value: unknown, parse: (value: unknown) => T): T[] { + if (!Array.isArray(value) || value.length > 256) + throw Error("invalid candidate list"); + return Array.from(value, parse); +} +function unique(items: T[], key: (item: T) => string): T[] { + if (new Set(items.map(key)).size !== items.length) + throw Error("duplicate candidate key"); + return items; +} +function sorted(items: T[], key: (item: T) => string): T[] { + return unique(items, key).sort((a, b) => + key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0, + ); +} + +function candidateBody(value: unknown): Omit { + const { + schemaVersion, + roundId, + snapshotDigest, + options: rawOptions, + eligibility: rawEligibility, + intentionRefs: rawRefs, + } = fields( + value, + [ + "schemaVersion", + "roundId", + "snapshotDigest", + "options", + "eligibility", + "intentionRefs", + ], + "candidate set", + ); + if (schemaVersion !== 1) + throw Error("Unsupported candidate set schema version"); + const options = sorted( + list(rawOptions, parseCanonicalOption), + (o) => o.optionKey, + ); + const actor = options[0]?.actor; + if ( + options.some( + (o) => + o.actor.agentId !== actor?.agentId || + o.actor.scopeId !== actor?.scopeId, + ) + ) + throw Error("candidate actor mismatch"); + const keys = new Set(options.map((o) => o.optionKey)); + // Eligibility is an ordered Host list, not a derived/defaulted set. Missing + // rows remain missing and the policy treats them as ineligible. + const eligibility = unique( + list(rawEligibility, (value) => { + const { + optionKey: rawKey, + eligible, + reason, + } = fields( + value, + ["optionKey", "eligible", "reason"], + "candidate eligibility", + ); + const optionKey = boundedId(rawKey, "option key"); + if (!keys.has(optionKey)) throw Error("unknown eligibility option key"); + if (typeof eligible !== "boolean") + throw Error("invalid candidate eligibility"); + return { + optionKey, + eligible, + reason: + reason === null ? null : boundedText(reason, "eligibility reason"), + }; + }), + (r) => r.optionKey, + ); + const intentionRefs = sorted( + list(rawRefs, (value) => { + const { + intentionId, + revision, + status: rawStatus, + digest, + } = fields( + value, + ["intentionId", "revision", "status", "digest"], + "candidate intention ref", + ); + const status = INTENTION_STATUSES.find((s) => s === rawStatus); + if ( + typeof revision !== "number" || + !Number.isSafeInteger(revision) || + revision < 0 || + status === undefined + ) + throw Error("invalid candidate intention ref"); + return { + intentionId: boundedId(intentionId, "intention id"), + revision, + status, + digest: boundedId(digest, "intention digest"), + }; + }), + (r) => r.intentionId, + ); + return { + schemaVersion: 1, + roundId: boundedId(roundId, "round id"), + snapshotDigest: boundedId(snapshotDigest, "snapshot digest"), + options, + eligibility, + intentionRefs, + }; +} + +export function parseCandidateSet(value: unknown): CandidateSet { + const row = fields( + value, + [ + "schemaVersion", + "roundId", + "snapshotDigest", + "options", + "eligibility", + "intentionRefs", + "candidateDigest", + ], + "candidate set", + ); + const { candidateDigest, ...rest } = row; + const result = candidateBody(rest); + if (candidateDigest !== judgmentDigest(result)) + throw Error("candidate set digest mismatch"); + return { ...result, candidateDigest }; +} + +export function buildCandidateSet(input: { + roundId: string; + snapshotDigest: string; + options: CanonicalOption[]; + eligibility: HostEligibility; + intentionRefs: CandidateSet["intentionRefs"]; +}): CandidateSet { + const result = candidateBody({ schemaVersion: 1, ...input }); + return { ...result, candidateDigest: judgmentDigest(result) }; +} diff --git a/packages/lina-core/src/agents/judgment-catalog.ts b/packages/lina-core/src/agents/judgment-catalog.ts new file mode 100644 index 0000000..acaad48 --- /dev/null +++ b/packages/lina-core/src/agents/judgment-catalog.ts @@ -0,0 +1,355 @@ +import type { OptionKey } from "./judgment.ts"; +import { judgmentDigest } from "./judgment-validation.ts"; +import { boundedId, boundedText, MAX_TEXT } from "./validation.ts"; + +export const PERSONAL_CATALOG_ID = "personal.v1" as const; +export const PERSONAL_OPTION_KINDS = [ + "intention.adopt", + "intention.activate", + "intention.suspend", + "intention.resume", + "intention.cancel", + "intention.complete", + "task.start", + "task.send", + "task.interrupt", + "task.handover", + "inquire", + "defer", + "noop", +] as const; +export type PersonalOptionKind = (typeof PERSONAL_OPTION_KINDS)[number]; +export const EFFECT_OWNERS = [ + "judgment_store", + "task_manager", + "host", + "none", +] as const; +export type EffectOwner = (typeof EFFECT_OWNERS)[number]; + +export type PersonalPrecondition = + | { + kind: "intention.adopt"; + intentionKind: "user_commitment" | "autonomous_goal"; + sourceRef: string; + } + | { kind: "intention.activate"; intentionId: string } + | { + kind: "intention.suspend" | "intention.resume"; + intentionId: string; + acceptanceSourceRef: string; + reason: string; + } + | { + kind: "intention.cancel"; + intentionId: string; + acceptanceSourceRef: string; + authorityRef: string; + userConfirmationRef: string | null; + } + | { kind: "intention.complete"; intentionId: string; outcomeRef: string } + | { + kind: "task.start"; + authorityRef: string; + taskText: string; + intentionId: string; + } + | { + kind: "task.send" | "task.interrupt" | "task.handover"; + taskId: string; + ownerId: string; + revision: number; + } + | { kind: "inquire"; authorityRef: string } + | { kind: "defer"; resumeCondition: string } + | { kind: "noop"; reason: string }; + +export type CanonicalOption = { + schemaVersion: 1; + catalogId: typeof PERSONAL_CATALOG_ID; + kind: PersonalOptionKind; + actor: { agentId: string; scopeId: string }; + targetId: string | null; + args: Record; + preconditions: PersonalPrecondition; + effect: { owner: EffectOwner; scope: string }; + optionKey: OptionKey; +}; + +const owners: Record = { + "intention.adopt": "judgment_store", + "intention.activate": "judgment_store", + "intention.suspend": "judgment_store", + "intention.resume": "judgment_store", + "intention.cancel": "judgment_store", + "intention.complete": "judgment_store", + "task.start": "task_manager", + "task.send": "task_manager", + "task.interrupt": "task_manager", + "task.handover": "task_manager", + inquire: "host", + defer: "none", + noop: "none", +}; + +function object(value: unknown, label: string): Record { + if ( + value === null || + typeof value !== "object" || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) + throw Error(`invalid ${label}`); + return value as Record; +} +function fields( + row: Record, + keys: readonly string[], + label: string, +): void { + for (const key of Object.keys(row)) + if (!keys.includes(key)) throw Error(`unknown ${label} field ${key}`); + for (const key of keys) + if (!Object.hasOwn(row, key)) throw Error(`missing ${label} field ${key}`); +} +function optionKind(value: unknown): PersonalOptionKind { + const text = boundedId(value, "option kind").toLowerCase(); + const kind = PERSONAL_OPTION_KINDS.find((kind) => kind === text); + if (kind === undefined) throw Error("unknown personal.v1 option kind"); + return kind; +} +function target(value: unknown): string | null { + const id = value === null ? null : boundedId(value, "target id").trim(); + if (id === "-") throw Error("reserved target id"); + return id; +} + +export function normalizeOptionArgs( + args: Record, +): Record { + const row = object(args, "option args"); + return Object.fromEntries( + Object.keys(row) + .sort() + .flatMap((key) => { + boundedId(key, "option argument key"); + const value = row[key]; + if (typeof value !== "string" || value.length > MAX_TEXT) + throw Error("invalid option argument"); + const normalized = value.normalize("NFC").trim().replace(/\s+/g, " "); + if (normalized === "") return []; + boundedText(value, "option argument"); + return [[key, boundedText(normalized, "option argument")]]; + }), + ); +} + +export function canonicalOptionKey( + option: Omit, +): OptionKey { + const kind = optionKind(option.kind); + const targetId = target(option.targetId); + // Bind execution semantics, not just display arguments, in sorted JSON. + const hash = judgmentDigest({ + schemaVersion: option.schemaVersion, + catalogId: option.catalogId, + kind, + actor: { + agentId: boundedId(option.actor.agentId, "agent id"), + scopeId: boundedId(option.actor.scopeId, "scope id"), + }, + targetId, + args: normalizeOptionArgs(option.args), + preconditions: parsePrecondition(option.preconditions, kind), + effect: { + owner: option.effect.owner, + scope: boundedId(option.effect.scope, "effect scope"), + }, + }); + // The digest binds the target without exceeding the shared ID bound. + return `${option.catalogId}:${kind}:${hash}`; +} + +function parsePrecondition( + value: unknown, + kind: PersonalOptionKind, +): PersonalPrecondition { + const row: Record & { + kind?: unknown; + intentionKind?: unknown; + userConfirmationRef?: unknown; + revision?: unknown; + } = object(value, "personal precondition"); + if (row.kind !== kind) throw Error("precondition kind mismatch"); + const check = (...keys: string[]) => + fields(row, ["kind", ...keys], "personal precondition"); + const id = (key: string) => boundedId(row[key], key); + const text = (key: string) => boundedText(row[key], key); + switch (kind) { + case "intention.adopt": { + check("intentionKind", "sourceRef"); + const intentionKind = row.intentionKind; + if ( + intentionKind !== "user_commitment" && + intentionKind !== "autonomous_goal" + ) + throw Error("invalid adoption intention kind"); + return { kind, intentionKind, sourceRef: id("sourceRef") }; + } + case "intention.activate": + check("intentionId"); + return { kind, intentionId: id("intentionId") }; + case "intention.suspend": + case "intention.resume": + check("intentionId", "acceptanceSourceRef", "reason"); + return { + kind, + intentionId: id("intentionId"), + acceptanceSourceRef: id("acceptanceSourceRef"), + reason: text("reason"), + }; + case "intention.cancel": + check( + "intentionId", + "acceptanceSourceRef", + "authorityRef", + "userConfirmationRef", + ); + return { + kind, + intentionId: id("intentionId"), + acceptanceSourceRef: id("acceptanceSourceRef"), + authorityRef: id("authorityRef"), + userConfirmationRef: + row.userConfirmationRef === null ? null : id("userConfirmationRef"), + }; + case "intention.complete": + check("intentionId", "outcomeRef"); + return { + kind, + intentionId: id("intentionId"), + outcomeRef: id("outcomeRef"), + }; + case "task.start": + check("authorityRef", "taskText", "intentionId"); + return { + kind, + authorityRef: id("authorityRef"), + taskText: text("taskText"), + intentionId: id("intentionId"), + }; + case "task.send": + case "task.interrupt": + case "task.handover": { + check("taskId", "ownerId", "revision"); + const revision = row.revision; + if ( + typeof revision !== "number" || + !Number.isSafeInteger(revision) || + revision < 0 + ) + throw Error("invalid task revision"); + return { kind, taskId: id("taskId"), ownerId: id("ownerId"), revision }; + } + case "inquire": + check("authorityRef"); + return { kind, authorityRef: id("authorityRef") }; + case "defer": + check("resumeCondition"); + return { kind, resumeCondition: text("resumeCondition") }; + case "noop": + check("reason"); + return { kind, reason: text("reason") }; + } +} + +export function parseCanonicalOption(value: unknown): CanonicalOption { + const row: Record & + Partial> = object( + value, + "canonical option", + ); + if (row.schemaVersion !== 1) + throw Error("Unsupported canonical option schema version"); + fields( + row, + [ + "schemaVersion", + "catalogId", + "kind", + "actor", + "targetId", + "args", + "preconditions", + "effect", + "optionKey", + ], + "canonical option", + ); + if (row.catalogId !== PERSONAL_CATALOG_ID) + throw Error("unsupported option catalog"); + const kind = optionKind(row.kind); + const preconditions = parsePrecondition(row.preconditions, kind); + const actor: Record & + Partial> = object( + row.actor, + "option actor", + ); + fields(actor, ["agentId", "scopeId"], "option actor"); + const effect: Record & + Partial> = object( + row.effect, + "option effect", + ); + fields(effect, ["owner", "scope"], "option effect"); + if (effect.owner !== owners[kind]) + throw Error("option effect owner mismatch"); + const args = object(row.args, "option args"); + // normalizeOptionArgs validates each value at this unknown-data boundary. + const result: CanonicalOption = { + schemaVersion: 1, + catalogId: PERSONAL_CATALOG_ID, + kind, + actor: { + agentId: boundedId(actor.agentId, "agent id"), + scopeId: boundedId(actor.scopeId, "scope id"), + }, + targetId: target(row.targetId), + args: normalizeOptionArgs(args as Record), + preconditions, + effect: { + owner: owners[kind], + scope: boundedId(effect.scope, "effect scope"), + }, + optionKey: boundedId(row.optionKey, "option key"), + }; + if (result.effect.scope !== result.actor.scopeId) + throw Error("option effect scope mismatch"); + if (result.optionKey !== canonicalOptionKey(result)) + throw Error("canonical option key mismatch"); + return result; +} + +export function buildCanonicalOption( + input: Omit< + CanonicalOption, + "schemaVersion" | "catalogId" | "optionKey" | "effect" + >, +): CanonicalOption { + const kind = optionKind(input.kind); + const option = { + schemaVersion: 1 as const, + catalogId: PERSONAL_CATALOG_ID, + kind, + actor: input.actor, + targetId: input.targetId, + args: input.args, + preconditions: input.preconditions, + // The Host supplies the actor's scope; the catalog selects its effect owner. + effect: { owner: owners[kind], scope: input.actor.scopeId }, + }; + return parseCanonicalOption({ + ...option, + optionKey: canonicalOptionKey(option), + }); +} diff --git a/packages/lina-core/src/agents/judgment-dialogue.ts b/packages/lina-core/src/agents/judgment-dialogue.ts new file mode 100644 index 0000000..50e1a03 --- /dev/null +++ b/packages/lina-core/src/agents/judgment-dialogue.ts @@ -0,0 +1,402 @@ +import { validId } from "../context/validation.ts"; +import { + type Assessment, + type DialogueSourceRef, + type JudgmentSnapshotRef, + MODULE_KINDS, + type ModuleKind, + type ObjectiveProfileRef, + type ResolutionRecord, + type RoundStatus, + SITUATIONS, + type Situation, +} from "./judgment.ts"; +import { + canonicalJson, + judgmentDigest, + parseAssessment, + parseJudgmentSnapshotRef, + parseObjectiveProfileRef, + parseResolutionRecord, + snapshotDigest, +} from "./judgment-validation.ts"; +import { boundedId, boundedText } from "./validation.ts"; + +type DialogueProvenance = DialogueSourceRef & { + roundId: string; + snapshotDigest: string; + objectiveProfileRefs: Record; + assessmentDigests: Record; + policyId: string; + policyRevision: number; +}; +/** JSON capability v2, stored in the unchanged v1 ledger DDL. Old readers reject + * this version; legacy action v1 bodies/digests are never rewritten or decorated. + * This is synthesis provenance, not response acceptance or action selection. */ +export type DialogueResolutionRecord = DialogueProvenance & { + schemaVersion: 2; + mode: "dialogue"; + situation: Situation; + recommendations: Record; + alignment: "aligned" | "conflicted" | "incomplete"; + conflicts: Array<{ moduleKind: ModuleKind; reason: string }>; + concessions: Array<{ moduleKind: ModuleKind; reason: string }>; + synthesis: string; + rationale: string; + status: Exclude; + holdReason: string | null; +}; +export type StoredResolutionRecord = + | ResolutionRecord + | DialogueResolutionRecord; +export type DialogueJudgmentRef = Omit< + DialogueProvenance, + "assessmentDigests" +> & { + schemaVersion: 1; + agentId: string; + scopeId: string; + assessmentDigests: Record; + resolutionDigest: string; + refDigest: string; +}; + +function fields( + value: unknown, + keys: readonly string[], + label: string, + version?: number, +): Record { + if ( + value === null || + typeof value !== "object" || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) + throw Error(`invalid ${label}`); + const row = value as Record; + if (version !== undefined && row["schemaVersion"] !== version) + throw Error(`Unsupported ${label} schema version`); + for (const key of Object.keys(row)) + if (!keys.includes(key)) throw Error(`unknown ${label} field ${key}`); + for (const key of keys) + if (!Object.hasOwn(row, key)) throw Error(`missing ${label} field ${key}`); + return row; +} +function member( + value: unknown, + values: readonly T[], + label: string, +): T { + const found = values.find((item) => item === value); + if (found === undefined) throw Error(`invalid ${label}`); + return found; +} +function digest(value: unknown): string { + const parsed = boundedId(value, "dialogue digest"); + if (!/^[a-f0-9]{64}$/.test(parsed)) throw Error("invalid dialogue digest"); + return parsed; +} +function modules( + value: unknown, + parse: (value: unknown) => T, +): Record { + const row = fields(value, MODULE_KINDS, "dialogue modules"); + return { + clotho: parse(row["clotho"]), + lachesis: parse(row["lachesis"]), + atropos: parse(row["atropos"]), + }; +} +function policyRevision(value: unknown): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) + throw Error("invalid dialogue policy revision"); + return value; +} +function reasons(value: unknown): DialogueResolutionRecord["conflicts"] { + if (!Array.isArray(value) || value.length > 256) + throw Error("invalid dialogue reasons"); + return Array.from(value, (item) => { + const row = fields(item, ["moduleKind", "reason"], "dialogue reason"); + return { + moduleKind: member(row["moduleKind"], MODULE_KINDS, "module kind"), + reason: boundedText(row["reason"], "dialogue reason"), + }; + }); +} +const PROVENANCE_KEYS = [ + "roundId", + "snapshotDigest", + "objectiveProfileRefs", + "assessmentDigests", + "policyId", + "policyRevision", + "requestId", + "requestDigest", + "sourceDigest", +] as const; +function provenance(row: Record): DialogueProvenance { + return { + roundId: boundedId(row["roundId"], "round id"), + snapshotDigest: digest(row["snapshotDigest"]), + objectiveProfileRefs: modules( + row["objectiveProfileRefs"], + parseObjectiveProfileRef, + ), + assessmentDigests: modules(row["assessmentDigests"], (value) => + value === null ? null : digest(value), + ), + policyId: boundedId(row["policyId"], "policy id"), + policyRevision: policyRevision(row["policyRevision"]), + requestId: validId(row["requestId"], "request id"), + requestDigest: digest(row["requestDigest"]), + sourceDigest: digest(row["sourceDigest"]), + }; +} +export function parseDialogueResolutionRecord( + value: unknown, +): DialogueResolutionRecord { + const row = fields( + value, + [ + "schemaVersion", + "mode", + ...PROVENANCE_KEYS, + "situation", + "recommendations", + "alignment", + "conflicts", + "concessions", + "synthesis", + "rationale", + "status", + "holdReason", + ], + "dialogue resolution", + 2, + ); + const result: DialogueResolutionRecord = { + schemaVersion: 2, + mode: member(row["mode"], ["dialogue"], "dialogue mode"), + ...provenance(row), + situation: member(row["situation"], SITUATIONS, "situation"), + recommendations: modules(row["recommendations"], (value) => + value === null ? null : boundedText(value, "dialogue recommendation"), + ), + alignment: member( + row["alignment"], + ["aligned", "conflicted", "incomplete"], + "dialogue alignment", + ), + conflicts: reasons(row["conflicts"]), + concessions: reasons(row["concessions"]), + synthesis: boundedText(row["synthesis"], "dialogue synthesis"), + rationale: boundedText(row["rationale"], "dialogue rationale"), + status: member( + row["status"], + ["resolved", "held", "deferred"], + "resolution status", + ), + holdReason: + row["holdReason"] === null + ? null + : boundedText(row["holdReason"], "hold reason"), + }; + if ((result.status !== "resolved") !== (result.holdReason !== null)) + throw Error("unresolved status requires hold reason"); + const incomplete = MODULE_KINDS.some( + (m) => result.assessmentDigests[m] === null, + ); + if ( + incomplete !== (result.alignment === "incomplete") || + (incomplete && result.status === "resolved") + ) + throw Error("invalid incomplete dialogue status"); + for (const m of MODULE_KINDS) + if ( + (result.assessmentDigests[m] === null) !== + (result.recommendations[m] === null) + ) + throw Error("dialogue recommendation assessment mismatch"); + if ( + result.alignment === "aligned" && + (result.conflicts.length || result.concessions.length) + ) + throw Error("aligned dialogue has no conflicts or concessions"); + if (result.alignment === "conflicted" && result.conflicts.length === 0) + throw Error("conflicted dialogue requires reasons"); + return result; +} +/** Explicit union parser; parseResolutionRecord remains the exact action-v1 parser. */ +export function parseStoredResolutionRecord( + value: unknown, +): StoredResolutionRecord { + return value !== null && + typeof value === "object" && + "schemaVersion" in value && + value.schemaVersion === 2 + ? parseDialogueResolutionRecord(value) + : parseResolutionRecord(value); +} + +/** Pure historical binding. Current objective/intention checks belong only at + * the owner write boundary, never here or in reference restoration. */ +export function validateDialogueResolution( + record: DialogueResolutionRecord, + snapshot: JudgmentSnapshotRef, + assessments: Assessment[], +): void { + if ( + record.roundId !== snapshot.roundId || + record.snapshotDigest !== snapshotDigest(snapshot) || + record.policyId !== snapshot.policyId || + record.policyRevision !== snapshot.policyRevision || + record.situation !== snapshot.situation || + canonicalJson(record.objectiveProfileRefs) !== + canonicalJson(snapshot.objectiveProfileRefs) + ) + throw Error("dialogue snapshot mismatch"); + if ( + !snapshot.sourceRefs.some( + (source) => source.kind === "request" && source.id === record.requestId, + ) + ) + throw Error("dialogue request source mismatch"); + const expected = snapshot.dialogueSource; + if (!expected) throw Error("dialogue source provenance missing"); + if ( + record.requestId !== expected.requestId || + record.requestDigest !== expected.requestDigest || + record.sourceDigest !== expected.sourceDigest + ) + throw Error("dialogue source digest mismatch"); + const seen = new Set(); + for (const assessment of assessments) { + const m = assessment.moduleKind; + if (seen.has(m)) throw Error("duplicate dialogue assessment"); + seen.add(m); + if ( + assessment.proposedOptionKeys.length || + assessment.objectiveAssessments.length || + assessment.recommendedOptionKeys.length + ) + throw Error("dialogue forbids action assessments"); + if ( + assessment.snapshotId !== record.roundId || + assessment.snapshotDigest !== record.snapshotDigest || + canonicalJson(assessment.objectiveRef) !== + canonicalJson(record.objectiveProfileRefs[m]) + ) + throw Error("dialogue assessment snapshot mismatch"); + if (record.assessmentDigests[m] !== judgmentDigest(assessment)) + throw Error("dialogue assessment digest mismatch"); + } + for (const m of MODULE_KINDS) + if (seen.has(m) !== (record.assessmentDigests[m] !== null)) + throw Error("missing dialogue assessment"); +} +export function buildDialogueResolution( + input: Omit< + DialogueResolutionRecord, + | "schemaVersion" + | "mode" + | "roundId" + | "snapshotDigest" + | "objectiveProfileRefs" + | "assessmentDigests" + | "policyId" + | "policyRevision" + | "situation" + > & { snapshot: JudgmentSnapshotRef; assessments: Assessment[] }, +): DialogueResolutionRecord { + const { + snapshot: rawSnapshot, + assessments: rawAssessments, + ...synthesis + } = input; + const snapshot = parseJudgmentSnapshotRef(rawSnapshot); + const assessments = rawAssessments.map(parseAssessment); + const assessmentDigest = (module: ModuleKind) => { + const found = assessments.find((a) => a.moduleKind === module); + return found ? judgmentDigest(found) : null; + }; + const result = parseDialogueResolutionRecord({ + ...synthesis, + schemaVersion: 2, + mode: "dialogue", + roundId: snapshot.roundId, + snapshotDigest: snapshotDigest(snapshot), + objectiveProfileRefs: snapshot.objectiveProfileRefs, + assessmentDigests: { + clotho: assessmentDigest("clotho"), + lachesis: assessmentDigest("lachesis"), + atropos: assessmentDigest("atropos"), + }, + policyId: snapshot.policyId, + policyRevision: snapshot.policyRevision, + situation: snapshot.situation, + }); + validateDialogueResolution(result, snapshot, assessments); + return result; +} +export function parseDialogueJudgmentRef(value: unknown): DialogueJudgmentRef { + const row = fields( + value, + [ + "schemaVersion", + ...PROVENANCE_KEYS, + "agentId", + "scopeId", + "resolutionDigest", + "refDigest", + ], + "dialogue judgment ref", + 1, + ); + const result: DialogueJudgmentRef = { + schemaVersion: 1, + ...provenance(row), + agentId: boundedId(row["agentId"], "agent id"), + scopeId: boundedId(row["scopeId"], "scope id"), + assessmentDigests: modules(row["assessmentDigests"], digest), + resolutionDigest: digest(row["resolutionDigest"]), + refDigest: digest(row["refDigest"]), + }; + if (result.refDigest !== judgmentDigest({ ...result, refDigest: undefined })) + throw Error("dialogue ref digest mismatch"); + return result; +} +export function buildDialogueJudgmentRef(input: { + snapshot: JudgmentSnapshotRef; + assessments: Assessment[]; + resolution: DialogueResolutionRecord; +}): DialogueJudgmentRef { + const snapshot = parseJudgmentSnapshotRef(input.snapshot); + const record = parseDialogueResolutionRecord(input.resolution); + validateDialogueResolution( + record, + snapshot, + input.assessments.map(parseAssessment), + ); + if (record.status !== "resolved") + throw Error("dialogue ref requires resolved synthesis"); + const value = { + schemaVersion: 1, + roundId: record.roundId, + snapshotDigest: record.snapshotDigest, + objectiveProfileRefs: record.objectiveProfileRefs, + assessmentDigests: record.assessmentDigests, + policyId: record.policyId, + policyRevision: record.policyRevision, + requestId: record.requestId, + requestDigest: record.requestDigest, + sourceDigest: record.sourceDigest, + agentId: snapshot.agentId, + scopeId: snapshot.scopeId, + resolutionDigest: judgmentDigest(record), + }; + return parseDialogueJudgmentRef({ + ...value, + refDigest: judgmentDigest(value), + }); +} diff --git a/packages/lina-core/src/agents/judgment-evidence.ts b/packages/lina-core/src/agents/judgment-evidence.ts new file mode 100644 index 0000000..df34516 --- /dev/null +++ b/packages/lina-core/src/agents/judgment-evidence.ts @@ -0,0 +1,118 @@ +import type { + Assessment, + IntentionRecord, + JudgmentSnapshotRef, +} from "./judgment.ts"; +import type { CandidateSet } from "./judgment-candidates.ts"; +import { + INTENTION_TRANSITIONS, + intentionDigest, + parseIntentionRecord, + snapshotDigest, +} from "./judgment-validation.ts"; + +/** Owner lookup is trusted Host code, never a model-provided evidence claim. + * The same frozen evidence rules apply to closure, arbitration and store replay. + */ +export function validateCandidateEvidence( + set: CandidateSet, + snapshot: JudgmentSnapshotRef, + lookup: (id: string) => IntentionRecord | null, + assessments: Assessment[], + current: boolean, +): void { + if ( + set.roundId !== snapshot.roundId || + set.snapshotDigest !== snapshotDigest(snapshot) + ) + throw Error("candidate snapshot mismatch"); + const referenced = new Map(); + for (const ref of set.intentionRefs) { + const record = lookup(ref.intentionId); + if ( + !record || + record.agentId !== snapshot.agentId || + record.scopeId !== snapshot.scopeId + ) + throw Error("candidate intention scope or reference mismatch"); + if ( + record.revision < ref.revision || + (current && record.revision !== ref.revision) + ) + throw Error("stale candidate intention revision"); + // This owner changes only status/revision/history. Preserve every original + // field while reconstructing the frozen revision from validated history. + const historical = parseIntentionRecord({ + ...record, + revision: ref.revision, + status: ref.status, + history: record.history.slice(0, ref.revision), + }); + if (intentionDigest(historical) !== ref.digest) + throw Error("candidate intention digest mismatch"); + referenced.set(ref.intentionId, historical); + } + const eligible = new Set( + set.eligibility.filter((r) => r.eligible).map((r) => r.optionKey), + ); + for (const option of set.options) { + if ( + option.actor.agentId !== snapshot.agentId || + option.actor.scopeId !== snapshot.scopeId + ) + throw Error("candidate actor snapshot mismatch"); + const precondition = option.preconditions; + if (!eligible.has(option.optionKey) || !("intentionId" in precondition)) + continue; + const record = referenced.get(precondition.intentionId); + if (!record) throw Error("missing candidate intention ref"); + if ( + "acceptanceSourceRef" in precondition && + precondition.acceptanceSourceRef !== record.acceptance.sourceRef + ) + throw Error("candidate original acceptance mismatch"); + if (precondition.kind === "task.start") continue; + if (option.targetId !== record.intentionId) + throw Error("candidate intention target mismatch"); + const to = ( + { + "intention.activate": "active", + "intention.resume": "active", + "intention.suspend": "suspended", + "intention.cancel": "cancelled", + "intention.complete": "completed", + } as const + )[precondition.kind]; + if ( + !INTENTION_TRANSITIONS[record.status].includes(to) || + (precondition.kind === "intention.activate" && + record.status !== "adopted") || + (precondition.kind === "intention.resume" && + record.status !== "suspended") + ) + throw Error("illegal candidate intention transition"); + } + const keys = new Set(set.options.map((o) => o.optionKey)); + for (const assessment of assessments) { + if ( + [ + ...assessment.proposedOptionKeys, + ...assessment.recommendedOptionKeys, + ...assessment.objectiveAssessments.map((o) => o.optionKey), + ].some((key) => !keys.has(key)) + ) + throw Error("unknown assessment option key"); + if (assessment.moduleKind !== "atropos") continue; + for (const opinion of assessment.objectiveAssessments) { + for (const id of opinion.breachedIntentionIds ?? []) { + const record = referenced.get(id); + if ( + record?.kind !== "user_commitment" || + record.acceptance.acceptedBy !== "user" || + !["adopted", "active", "suspended"].includes(record.status) + ) + throw Error("missing protected commitment evidence"); + } + } + } +} diff --git a/packages/lina-core/src/agents/judgment-policy.ts b/packages/lina-core/src/agents/judgment-policy.ts new file mode 100644 index 0000000..b565001 --- /dev/null +++ b/packages/lina-core/src/agents/judgment-policy.ts @@ -0,0 +1,408 @@ +import { isDeepStrictEqual } from "node:util"; +import { + ASSESSMENT_UNAVAILABLE_REASONS, + type AssessmentSet, + type AssessmentUnavailableReason, + type IntentionRecord, + type JudgmentSnapshotRef, + MODULE_KINDS, + type ModuleKind, + type OptionAssessment, + type OptionKey, + type ResolutionRecord, + type SelectionSpec, + type Situation, + type Stance, +} from "./judgment.ts"; +import { type CandidateSet, parseCandidateSet } from "./judgment-candidates.ts"; +import { + type CanonicalOption, + parseCanonicalOption, +} from "./judgment-catalog.ts"; +import { validateCandidateEvidence } from "./judgment-evidence.ts"; +import { + judgmentDigest, + parseAssessmentSet, + parseJudgmentSnapshotRef, + parseResolutionRecord, + parseSelectionSpec, + snapshotDigest, +} from "./judgment-validation.ts"; + +export type ArbitrationPolicy = Readonly<{ + policyId: string; + revision: number; + ratio: number; + orders: Readonly>; + lambda: Readonly>; + stanceOrder: readonly Stance[]; + unavailableReasons: readonly AssessmentUnavailableReason[]; +}>; +export const PERSONAL_POLICY_V1: ArbitrationPolicy = Object.freeze({ + policyId: "personal.v1", + revision: 1, + ratio: 0.5, + orders: Object.freeze({ + user_request: Object.freeze(["atropos", "clotho", "lachesis"] as const), + autonomous: Object.freeze(["lachesis", "clotho", "atropos"] as const), + transition: Object.freeze(["clotho", "atropos", "lachesis"] as const), + }), + lambda: Object.freeze({ user_request: 0, autonomous: 1, transition: 1 }), + stanceOrder: Object.freeze(["prefer", "accept", "oppose"] as const), + unavailableReasons: Object.freeze([...ASSESSMENT_UNAVAILABLE_REASONS]), +}); +export type HostEligibility = Array<{ + optionKey: OptionKey; + eligible: boolean; + reason: string | null; +}>; + +/** Each rank owns one geometric mass, regardless of how many candidates tie. */ +export function rankMass(ranks: number[], ratio: number): number[] { + if (!Number.isFinite(ratio) || ratio <= 0) throw Error("invalid rank ratio"); + const counts = new Map(); + let anchor = ranks[0] ?? 1; + for (const rank of ranks) { + if (!Number.isSafeInteger(rank) || rank < 1) throw Error("invalid rank"); + counts.set(rank, (counts.get(rank) ?? 0) + 1); + anchor = ratio <= 1 ? Math.min(anchor, rank) : Math.max(anchor, rank); + } + // Divide all weights by the largest geometric weight before summing. + const total = [...counts.keys()].reduce( + (sum, rank) => sum + ratio ** (rank - anchor), + 0, + ); + return ranks.map( + (rank) => ratio ** (rank - anchor) / total / (counts.get(rank) ?? 1), + ); +} + +type Candidate = { + option: CanonicalOption; + opinions: Record; +}; + +export function resolvePersonalRound(input: { + policy: ArbitrationPolicy; + snapshot: JudgmentSnapshotRef; + options: CanonicalOption[]; + set: AssessmentSet; + eligibility: HostEligibility; + bias: Record; + /** Only Host code supplies the owner lookup; assessment IDs alone are claims. */ + evidence?: { + candidates: CandidateSet; + lookupIntention: (id: string) => IntentionRecord | null; + }; +}): { resolution: ResolutionRecord; spec: SelectionSpec | null } { + const { policy } = input; + const snapshot = parseJudgmentSnapshotRef(input.snapshot); + const options = input.options + .map(parseCanonicalOption) + .sort((a, b) => + a.optionKey < b.optionKey ? -1 : a.optionKey > b.optionKey ? 1 : 0, + ); + const keys = new Set(options.map((o) => o.optionKey)); + if (keys.size !== options.length) throw Error("duplicate option key"); + for (const option of options) { + if ( + option.actor.agentId !== snapshot.agentId || + option.actor.scopeId !== snapshot.scopeId + ) + throw Error("option actor snapshot mismatch"); + } + const eligibility = new Map(); + for (const row of input.eligibility) { + if (!keys.has(row.optionKey)) throw Error("unknown eligibility option key"); + if (eligibility.has(row.optionKey)) + throw Error("duplicate eligibility option key"); + eligibility.set(row.optionKey, row); + } + const set = parseAssessmentSet(input.set); + const digest = snapshotDigest(snapshot); + if (set.roundId !== snapshot.roundId || set.snapshotDigest !== digest) + throw Error("assessment set snapshot mismatch"); + if ( + policy.policyId !== snapshot.policyId || + policy.revision !== snapshot.policyRevision + ) + throw Error("policy snapshot mismatch"); + if (!isDeepStrictEqual(policy, PERSONAL_POLICY_V1)) + throw Error("unsupported personal policy declaration"); + for (const assessment of set.assessments) { + const objective = snapshot.objectiveProfileRefs[assessment.moduleKind]; + if ( + assessment.objectiveRef.objectiveId !== objective.objectiveId || + assessment.objectiveRef.revision !== objective.revision || + assessment.objectiveRef.digest !== objective.digest + ) + throw Error("assessment objective mismatch"); + if ( + assessment.proposedOptionKeys.some((key) => !keys.has(key)) || + assessment.objectiveAssessments.some((o) => !keys.has(o.optionKey)) || + assessment.recommendedOptionKeys.some((key) => !keys.has(key)) + ) + throw Error("unknown assessment option key"); + } + if (input.evidence) { + const candidates = parseCandidateSet(input.evidence.candidates); + if ( + !isDeepStrictEqual(candidates.options, options) || + !isDeepStrictEqual(candidates.eligibility, input.eligibility) + ) + throw Error("candidate evidence input mismatch"); + validateCandidateEvidence( + candidates, + snapshot, + input.evidence.lookupIntention, + set.assessments, + false, + ); + } + const order = [...policy.orders[snapshot.situation]]; + const record: ResolutionRecord = { + schemaVersion: 1, + roundId: snapshot.roundId, + policyId: policy.policyId, + policyRevision: policy.revision, + situation: snapshot.situation, + order, + recommendations: { clotho: [], lachesis: [], atropos: [] }, + conflicts: [], + excluded: [], + abstentions: [], + ranking: [], + conceded: [], + status: "resolved", + holdReason: null, + }; + for (const assessment of set.assessments) + record.recommendations[assessment.moduleKind] = [ + ...assessment.recommendedOptionKeys, + ]; + const finishWithoutSpec = ( + status: "held" | "deferred", + holdReason: string, + ) => ({ + resolution: parseResolutionRecord({ ...record, status, holdReason }), + spec: null, + }); + + // 1-2: Host facts first; completeness is required only for eligible options. + const eligible: Candidate[] = []; + let missing: string | null = null; + for (const option of options) { + const row = eligibility.get(option.optionKey); + const hostEligible = row?.eligible === true; + if (!hostEligible) + record.excluded.push({ + optionKey: option.optionKey, + stage: "host_eligibility", + byModule: null, + reason: row + ? (row.reason ?? "host ineligible") + : "missing host eligibility", + }); + const opinions: Partial> = {}; + for (const assessment of set.assessments) { + const moduleKind = assessment.moduleKind; + const opinion = assessment.objectiveAssessments.find( + (o) => o.optionKey === option.optionKey, + ); + if (opinion === undefined) { + if (hostEligible && missing === null) + missing = `missing assessment ${moduleKind} for ${option.optionKey}`; + continue; + } + opinions[moduleKind] = opinion; + if (opinion.unavailableReason !== null) + record.abstentions.push({ + optionKey: option.optionKey, + moduleKind, + reason: opinion.unavailableReason, + }); + } + const { clotho, lachesis, atropos } = opinions; + if (clotho && lachesis && atropos) { + const complete = { clotho, lachesis, atropos }; + const availableStances = MODULE_KINDS.map( + (m) => complete[m].stance, + ).filter((stance) => stance !== "unavailable"); + if (new Set(availableStances).size > 1) + record.conflicts.push({ + optionKey: option.optionKey, + stances: { + clotho: clotho.stance, + lachesis: lachesis.stance, + atropos: atropos.stance, + }, + }); + if (hostEligible) eligible.push({ option, opinions: complete }); + } + } + if (missing !== null) return finishWithoutSpec("held", missing); + + // 3-4: Protected commitments need owner-verified attribution. Factual + // prerequisite failures are Host eligibility; Clotho's label alone is not proof. + const remaining = eligible.filter(({ option, opinions }) => { + const precondition = option.preconditions; + const breached = opinions.atropos.breachedIntentionIds; + const changesAcceptance = + (precondition.kind === "intention.suspend" || + precondition.kind === "intention.cancel") && + breached?.length === 1 && + breached[0] === precondition.intentionId; + if ( + opinions.atropos.stance === "oppose" && + opinions.atropos.severity === "commitment_breach" && + input.evidence && + breached !== undefined && + breached.length > 0 && + !changesAcceptance + ) { + record.excluded.push({ + optionKey: option.optionKey, + stage: "commitment_protection", + byModule: "atropos", + reason: "accepted commitment breach", + }); + return false; + } + return true; + }); + // 9: No candidate means deferred, not an empty ordering or uniform fallback. + if (remaining.length === 0) + return finishWithoutSpec("deferred", "no eligible candidate"); + + // 5: Drop an unavailable module for the ENTIRE round, never per comparison. + const orderingModules = order.filter((m) => + remaining.every((c) => c.opinions[m].stance !== "unavailable"), + ); + if (orderingModules.length === 0) + return finishWithoutSpec("held", "all modules unavailable for ordering"); + const compare = (a: Candidate, b: Candidate): number => { + for (const moduleKind of orderingModules) { + const difference = + policy.stanceOrder.indexOf(a.opinions[moduleKind].stance) - + policy.stanceOrder.indexOf(b.opinions[moduleKind].stance); + if (difference !== 0) return difference; + } + return 0; + }; + remaining.sort(compare); + let rank = 0; + let previous: Candidate | undefined; + for (const candidate of remaining) { + if (previous === undefined || compare(previous, candidate) !== 0) rank += 1; + record.ranking.push({ optionKey: candidate.option.optionKey, rank }); + previous = candidate; + } + const winners = new Set( + record.ranking.filter((r) => r.rank === 1).map((r) => r.optionKey), + ); + const ranked = new Set(record.ranking.map((r) => r.optionKey)); + for (const assessment of set.assessments) { + if (!orderingModules.includes(assessment.moduleKind)) continue; + for (const opinion of assessment.objectiveAssessments) { + if ( + ranked.has(opinion.optionKey) && + opinion.stance === "prefer" && + !winners.has(opinion.optionKey) + ) + record.conceded.push({ + moduleKind: assessment.moduleKind, + optionKey: opinion.optionKey, + }); + } + } + + // 6-8: Bias is copied only AFTER p0 has been produced and is never ranked. + const masses = rankMass( + record.ranking.map((r) => r.rank), + policy.ratio, + ); + const baseline = new Map( + record.ranking.map((r, i) => [r.optionKey, masses[i] ?? 0]), + ); + const candidates = options.map((o) => ({ + optionKey: o.optionKey, + p0: baseline.get(o.optionKey) ?? 0, + b: input.bias[o.optionKey] ?? null, + })); + if (!candidates.some((c) => c.p0 > 0)) + return finishWithoutSpec("deferred", "no eligible candidate"); + const resolution = parseResolutionRecord(record); + const spec = { + schemaVersion: 1 as const, + roundId: snapshot.roundId, + snapshotDigest: digest, + assessmentSetDigest: judgmentDigest(set), + objectiveProfileRefs: snapshot.objectiveProfileRefs, + resolutionDigest: judgmentDigest(resolution), + policyId: policy.policyId, + policyRevision: policy.revision, + situation: snapshot.situation, + lambda: policy.lambda[snapshot.situation], + candidates, + eligibleDigest: judgmentDigest( + candidates.filter((c) => c.p0 > 0).map((c) => c.optionKey), + ), + }; + return { + resolution, + spec: parseSelectionSpec({ ...spec, specDigest: judgmentDigest(spec) }), + }; +} + +export function sampleSelection( + spec: SelectionSpec, + u: number, +): { + optionKey: OptionKey; + probabilities: Array<{ optionKey: OptionKey; p: number }>; + consumedDraw: boolean; +} { + const parsed = parseSelectionSpec(spec); + const positive = parsed.candidates.filter((c) => c.p0 > 0); + const single = positive.length === 1 ? positive.at(0) : undefined; + if (single) + return { + optionKey: single.optionKey, + probabilities: parsed.candidates.map((c) => ({ + optionKey: c.optionKey, + p: c.p0 > 0 ? 1 : 0, + })), + consumedDraw: false, + }; + if (!Number.isFinite(u) || u < 0 || u >= 1) + throw Error("invalid selection draw"); + // Preserve lookup evidence in the spec; a partial failure disables the + // decision's entire modulation, not just the unavailable candidate's bias. + const lambda = positive.some((c) => c.b === null) ? 0 : parsed.lambda; + const logs = positive.map((c) => Math.log(c.p0) + lambda * (c.b ?? 0)); + const max = Math.max(...logs); + const total = logs.reduce((sum, log) => sum + Math.exp(log - max), 0); + const probabilities = parsed.candidates.map((c) => ({ + optionKey: c.optionKey, + p: + c.p0 === 0 + ? 0 + : Math.exp(Math.log(c.p0) + lambda * (c.b ?? 0) - max) / total, + })); + let cumulative = 0; + let lastPositive: OptionKey | undefined; + for (const candidate of probabilities) { + cumulative += candidate.p; + if (candidate.p > 0) lastPositive = candidate.optionKey; + if (cumulative > u) + return { + optionKey: candidate.optionKey, + probabilities, + consumedDraw: true, + }; + } + // Floating-point summation may leave a sub-tolerance tail below one. + if (lastPositive !== undefined && Math.abs(cumulative - 1) <= 1e-12) + return { optionKey: lastPositive, probabilities, consumedDraw: true }; + throw Error("invalid selection weights"); +} diff --git a/packages/lina-core/src/agents/judgment-schema.ts b/packages/lina-core/src/agents/judgment-schema.ts new file mode 100644 index 0000000..ec6f910 --- /dev/null +++ b/packages/lina-core/src/agents/judgment-schema.ts @@ -0,0 +1,63 @@ +import type { DatabaseSync } from "node:sqlite"; + +export const JUDGMENT_SCHEMA_VERSION = 1; + +const SCHEMA = ` +CREATE TABLE judgment_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT; +CREATE TABLE objective_profiles (objective_id TEXT NOT NULL, revision INTEGER NOT NULL, module_kind TEXT NOT NULL, digest TEXT NOT NULL, body TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(objective_id, revision)) STRICT; +CREATE TABLE objective_profile_active (agent_id TEXT NOT NULL, scope_id TEXT NOT NULL, module_kind TEXT NOT NULL, objective_id TEXT NOT NULL, revision INTEGER NOT NULL, activation_revision INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY(agent_id, scope_id, module_kind), FOREIGN KEY(objective_id, revision) REFERENCES objective_profiles(objective_id, revision)) STRICT; +CREATE TABLE rounds (round_id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, scope_id TEXT NOT NULL, situation TEXT NOT NULL, sequence INTEGER NOT NULL, snapshot TEXT NOT NULL, snapshot_digest TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('open','resolved','deferred','held')), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, UNIQUE(agent_id, scope_id, sequence)) STRICT; +CREATE TABLE candidate_sets (round_id TEXT PRIMARY KEY REFERENCES rounds(round_id), snapshot_digest TEXT NOT NULL, candidate_digest TEXT NOT NULL, body TEXT NOT NULL, created_at INTEGER NOT NULL) STRICT; +CREATE TABLE assessments (round_id TEXT NOT NULL REFERENCES rounds(round_id), module_kind TEXT NOT NULL, snapshot_digest TEXT NOT NULL, input_digest TEXT NOT NULL, digest TEXT NOT NULL, body TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(round_id, module_kind)) STRICT; +CREATE TABLE resolution_records (round_id TEXT PRIMARY KEY REFERENCES rounds(round_id), digest TEXT NOT NULL, body TEXT NOT NULL, created_at INTEGER NOT NULL) STRICT; +CREATE TABLE selection_specs (round_id TEXT PRIMARY KEY REFERENCES rounds(round_id), spec_digest TEXT NOT NULL, body TEXT NOT NULL, created_at INTEGER NOT NULL) STRICT; +CREATE TABLE intention_records (intention_id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, scope_id TEXT NOT NULL, revision INTEGER NOT NULL, status TEXT NOT NULL, digest TEXT NOT NULL, body TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL) STRICT; +CREATE TABLE intention_transitions (intention_id TEXT NOT NULL REFERENCES intention_records(intention_id), revision INTEGER NOT NULL, from_status TEXT NOT NULL, to_status TEXT NOT NULL, reason TEXT NOT NULL, evidence_ref TEXT, at TEXT NOT NULL, PRIMARY KEY(intention_id, revision)) STRICT; +`; + +function verify(db: DatabaseSync): void { + const expected = SCHEMA.split(";") + .map((sql) => sql.trim()) + .filter(Boolean) + .sort(); + const actual = db + .prepare("SELECT sql FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*'") + .all() + .map(({ sql }) => sql) + .sort(); + if ( + actual.length !== expected.length || + actual.some((sql, index) => sql !== expected[index]) + ) + throw Error("unknown judgment store schema"); +} + +export function initializeJudgmentSchema( + db: DatabaseSync, + fresh: boolean, +): void { + const { user_version: version } = + db.prepare("PRAGMA user_version").get() ?? {}; + const tables = db + .prepare("SELECT name FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*'") + .all(); + if (version === 0 && tables.length === 0) { + if (!fresh) throw Error("unknown judgment store schema"); + db.exec(SCHEMA); + const insert = db.prepare( + "INSERT INTO judgment_meta(key, value) VALUES (?, ?)", + ); + insert.run("schema_version", String(JUDGMENT_SCHEMA_VERSION)); + insert.run("store", "judgment"); + db.exec(`PRAGMA user_version = ${JUDGMENT_SCHEMA_VERSION}`); + return; + } + if (version !== JUDGMENT_SCHEMA_VERSION) + throw Error("unknown judgment store schema"); + verify(db); + const meta = db.prepare("SELECT value FROM judgment_meta WHERE key = ?"); + const { value: store } = meta.get("store") ?? {}; + const { value: schemaVersion } = meta.get("schema_version") ?? {}; + if (store !== "judgment" || schemaVersion !== "1") + throw Error("unknown judgment store schema"); +} diff --git a/packages/lina-core/src/agents/judgment-store.ts b/packages/lina-core/src/agents/judgment-store.ts new file mode 100644 index 0000000..5fad73f --- /dev/null +++ b/packages/lina-core/src/agents/judgment-store.ts @@ -0,0 +1,1119 @@ +import type { DatabaseSync } from "node:sqlite"; +import { openCheckedDatabase } from "../session-binding.ts"; +import { + type Assessment, + type AssessmentSet, + INTENTION_STATUSES, + type IntentionRecord, + type IntentionStatus, + type IntentionTransition, + type JudgmentSnapshotRef, + MODULE_KINDS, + type ModuleKind, + type ObjectiveProfile, + type ObjectiveProfileRef, + type ResolutionRecord, + type RoundStatus, + type SelectionSpec, +} from "./judgment.ts"; +import { type CandidateSet, parseCandidateSet } from "./judgment-candidates.ts"; +import { + buildDialogueJudgmentRef, + type DialogueJudgmentRef, + type DialogueResolutionRecord, + parseDialogueJudgmentRef, + parseStoredResolutionRecord, + type StoredResolutionRecord, + validateDialogueResolution, +} from "./judgment-dialogue.ts"; +import { validateCandidateEvidence } from "./judgment-evidence.ts"; +import { PERSONAL_POLICY_V1, resolvePersonalRound } from "./judgment-policy.ts"; +import { initializeJudgmentSchema } from "./judgment-schema.ts"; +import { + canonicalJson, + intentionDigest, + judgmentDigest, + parseAssessment, + parseAssessmentSet, + parseIntentionRecord, + parseIntentionTransition, + parseJudgmentSnapshotRef, + parseObjectiveProfile, + parseObjectiveProfileRef, + parseSelectionSpec, + snapshotDigest, + transitionIntention, +} from "./judgment-validation.ts"; +import { boundedId } from "./validation.ts"; + +function body(value: unknown): string { + return canonicalJson(value); +} +function revision(value: number, minimum = 0): number { + if (!Number.isSafeInteger(value) || value < minimum) + throw Error("invalid judgment revision"); + return value; +} + +function validateResolutionBinding( + resolution: StoredResolutionRecord, + selection: SelectionSpec | null, + set: AssessmentSet | null, + snapshot: JudgmentSnapshotRef, + candidates: CandidateSet | null, + lookupIntention: (id: string) => IntentionRecord | null, + assessments: Assessment[], +): void { + if (resolution.schemaVersion === 2) { + if (selection !== null || candidates !== null) + throw Error("dialogue forbids action candidates and selection"); + validateDialogueResolution(resolution, snapshot, assessments); + return; + } + // Without complete evidence only a non-executable failure receipt is trusted. + // Its descriptive fields are not policy-replayed; deferred is not a fallback. + if (!candidates || !set) { + if (resolution.status !== "held" || selection !== null) + throw Error(!set ? "incomplete assessment set" : "missing candidate set"); + return; + } + const replayed = resolvePersonalRound({ + policy: PERSONAL_POLICY_V1, + snapshot, + options: candidates.options, + eligibility: candidates.eligibility, + set, + evidence: { candidates, lookupIntention }, + bias: Object.fromEntries( + selection?.candidates.map((c) => [c.optionKey, c.b]) ?? [], + ), + }); + if (body(resolution) !== body(replayed.resolution)) + throw Error("resolution policy replay mismatch"); + if (selection === null || replayed.spec === null) { + if (selection !== replayed.spec) + throw Error("selection policy replay mismatch"); + return; + } + if (selection.assessmentSetDigest !== judgmentDigest(set)) + throw Error("selection assessment set mismatch"); + if (selection.resolutionDigest !== judgmentDigest(resolution)) + throw Error("selection resolution digest mismatch"); + if ( + selection.policyId !== resolution.policyId || + selection.policyRevision !== resolution.policyRevision || + selection.situation !== resolution.situation + ) + throw Error("selection policy mismatch"); + if ( + body(selection.objectiveProfileRefs) !== body(snapshot.objectiveProfileRefs) + ) + throw Error("selection objective refs mismatch"); + if (body(selection) !== body(replayed.spec)) + throw Error("selection policy replay mismatch"); +} + +/** The Host owns the single writer; this ledger is independent of session lifetimes. */ +export class JudgmentStore { + private readonly db: DatabaseSync; + private readonly now: () => number; + private closed = false; + private validatedDataVersion: number | undefined; + + constructor(path: string, options: { now?: () => number } = {}) { + const opened = openCheckedDatabase(path); + this.db = opened.db; + this.now = options.now ?? Date.now; + try { + this.db.exec("PRAGMA foreign_keys = ON; BEGIN IMMEDIATE"); + initializeJudgmentSchema(this.db, opened.fresh); + this.db.exec( + "COMMIT; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL", + ); + } catch (error) { + if (this.db.isTransaction) this.db.exec("ROLLBACK"); + this.db.close(); + throw error; + } + } + + putObjectiveProfile(profile: ObjectiveProfile): ObjectiveProfileRef { + this.assertOpen(); + const parsed = parseObjectiveProfile(profile); + const ref = { + objectiveId: parsed.objectiveId, + revision: parsed.revision, + digest: judgmentDigest(parsed), + }; + return this.transaction(() => { + const existing = this.getObjectiveProfile(ref.objectiveId, ref.revision); + if (existing) { + if (judgmentDigest(existing) !== ref.digest) + throw Error("objective profile revision already exists"); + return ref; + } + this.db + .prepare( + "INSERT INTO objective_profiles(objective_id, revision, module_kind, digest, body, created_at) VALUES (?, ?, ?, ?, ?, ?)", + ) + .run( + ref.objectiveId, + ref.revision, + parsed.moduleKind, + ref.digest, + body(parsed), + this.now(), + ); + return ref; + }); + } + + getObjectiveProfile( + objectiveId: string, + profileRevision: number, + ): ObjectiveProfile | null { + return this.transaction(() => { + const row = this.db + .prepare( + "SELECT body, digest FROM objective_profiles WHERE objective_id = ? AND revision = ?", + ) + .get( + boundedId(objectiveId, "objective id"), + revision(profileRevision, 1), + ); + if (!row) return null; + const { body: json, digest } = row; + const parsed = parseObjectiveProfile(JSON.parse(String(json))); + if (judgmentDigest(parsed) !== digest) + throw Error("objective profile digest mismatch"); + return parsed; + }, false); + } + + activateObjectiveProfile( + agentId: string, + scopeId: string, + ref: ObjectiveProfileRef, + ): { activationRevision: number } { + this.assertOpen(); + const agent = boundedId(agentId, "agent id"); + const scope = boundedId(scopeId, "scope id"); + const parsed = parseObjectiveProfileRef(ref); + return this.transaction(() => { + const profile = this.getObjectiveProfile( + parsed.objectiveId, + parsed.revision, + ); + if (!profile || judgmentDigest(profile) !== parsed.digest) + throw Error("objective profile ref mismatch"); + const existing = this.db + .prepare( + "SELECT objective_id, revision, activation_revision FROM objective_profile_active WHERE agent_id = ? AND scope_id = ? AND module_kind = ?", + ) + .get(agent, scope, profile.moduleKind); + const { + objective_id: objectiveId, + revision: activeRevision, + activation_revision: previous, + } = existing ?? {}; + if ( + objectiveId === parsed.objectiveId && + activeRevision === parsed.revision + ) + return { activationRevision: Number(previous) }; + const nextRefs = body({ + ...this.activeObjectiveProfiles(agent, scope), + [profile.moduleKind]: parsed, + }); + // An open snapshot may become stale, but activation must not revive it. + for (const row of this.db + .prepare( + "SELECT snapshot FROM rounds WHERE agent_id = ? AND scope_id = ? AND status = 'open'", + ) + .all(agent, scope)) { + const snapshot = parseJudgmentSnapshotRef( + JSON.parse(String(row["snapshot"])), + ); + if (body(snapshot.objectiveProfileRefs) === nextRefs) + throw Error("objective reactivation would revive stale round"); + } + const activationRevision = revision(Number(previous ?? 0) + 1, 1); + this.db + .prepare(`INSERT INTO objective_profile_active(agent_id, scope_id, module_kind, objective_id, revision, activation_revision, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(agent_id, scope_id, module_kind) DO UPDATE SET + objective_id = excluded.objective_id, revision = excluded.revision, activation_revision = excluded.activation_revision, updated_at = excluded.updated_at`) + .run( + agent, + scope, + profile.moduleKind, + parsed.objectiveId, + parsed.revision, + activationRevision, + this.now(), + ); + return { activationRevision }; + }); + } + + activeObjectiveProfiles( + agentId: string, + scopeId: string, + ): Record | null { + return this.transaction(() => { + const rows = this.db + .prepare(`SELECT p.body, p.digest FROM objective_profile_active a JOIN objective_profiles p + ON p.objective_id = a.objective_id AND p.revision = a.revision WHERE a.agent_id = ? AND a.scope_id = ?`) + .all(boundedId(agentId, "agent id"), boundedId(scopeId, "scope id")); + const refs: Partial> = {}; + for (const { body: json, digest } of rows) { + const profile = parseObjectiveProfile(JSON.parse(String(json))); + if (judgmentDigest(profile) !== digest) + throw Error("objective profile digest mismatch"); + refs[profile.moduleKind] = parseObjectiveProfileRef({ + objectiveId: profile.objectiveId, + revision: profile.revision, + digest: judgmentDigest(profile), + }); + } + if (!refs.clotho || !refs.lachesis || !refs.atropos) return null; + return { + clotho: refs.clotho, + lachesis: refs.lachesis, + atropos: refs.atropos, + }; + }, false); + } + + openRound(snapshot: JudgmentSnapshotRef): { + roundId: string; + snapshotDigest: string; + } { + this.assertOpen(); + const parsed = parseJudgmentSnapshotRef(snapshot); + return this.transaction(() => { + const active = this.activeObjectiveProfiles( + parsed.agentId, + parsed.scopeId, + ); + if (body(active) !== body(parsed.objectiveProfileRefs)) + throw Error("stale objective profile refs"); + if ( + parsed.intentionRevision !== + this.intentionRevision(parsed.agentId, parsed.scopeId) + ) + throw Error("stale intention revision"); + const highWater = this.db + .prepare( + "SELECT MAX(sequence) AS sequence FROM rounds WHERE agent_id = ? AND scope_id = ?", + ) + .get(parsed.agentId, parsed.scopeId); + if ( + highWater?.["sequence"] !== null && + parsed.sequence <= Number(highWater?.["sequence"]) + ) + throw Error("stale round sequence"); + const digest = snapshotDigest(parsed); + const now = this.now(); + this.db + .prepare(`INSERT INTO rounds(round_id, agent_id, scope_id, situation, sequence, snapshot, snapshot_digest, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`) + .run( + parsed.roundId, + parsed.agentId, + parsed.scopeId, + parsed.situation, + parsed.sequence, + body(parsed), + digest, + now, + now, + ); + return { roundId: parsed.roundId, snapshotDigest: digest }; + }); + } + + getRound(roundId: string): { + snapshot: JudgmentSnapshotRef; + status: RoundStatus; + snapshotDigest: string; + } | null { + return this.transaction(() => { + const row = this.db + .prepare( + "SELECT snapshot, status, snapshot_digest FROM rounds WHERE round_id = ?", + ) + .get(boundedId(roundId, "round id")); + if (!row) return null; + const { snapshot, status, snapshot_digest: digest } = row; + const parsed = parseJudgmentSnapshotRef(JSON.parse(String(snapshot))); + if (snapshotDigest(parsed) !== digest) + throw Error("snapshot digest mismatch"); + return { + snapshot: parsed, + status: status as RoundStatus, + snapshotDigest: String(digest), + }; + }, false); + } + + /** Freeze the exact Host universe and provenance once, while its round is open. */ + closeCandidateSet(set: CandidateSet): void { + this.assertOpen(); + const parsed = parseCandidateSet(set); + this.transaction(() => { + const round = this.requireOpenRound(parsed.roundId); + if (this.candidateSet(parsed.roundId)) + throw Error("candidate set already closed"); + validateCandidateEvidence( + parsed, + round.snapshot, + (id) => this.getIntention(id), + this.storedAssessments(parsed.roundId), + true, + ); + this.db + .prepare( + "INSERT INTO candidate_sets(round_id, snapshot_digest, candidate_digest, body, created_at) VALUES (?, ?, ?, ?, ?)", + ) + .run( + parsed.roundId, + parsed.snapshotDigest, + parsed.candidateDigest, + body(parsed), + this.now(), + ); + }); + } + + candidateSet(roundId: string): CandidateSet | null { + return this.transaction(() => { + const row = this.db + .prepare("SELECT body FROM candidate_sets WHERE round_id = ?") + .get(boundedId(roundId, "round id")); + if (!row) return null; + const { body: json } = row; + return parseCandidateSet(JSON.parse(String(json))); + }, false); + } + + private storedAssessments(roundId: string): Assessment[] { + return this.db + .prepare("SELECT body FROM assessments WHERE round_id = ?") + .all(roundId) + .map(({ body: json }) => parseAssessment(JSON.parse(String(json)))); + } + + putAssessment(assessment: Assessment): void { + this.assertOpen(); + const parsed = parseAssessment(assessment); + this.transaction(() => { + const round = this.requireOpenRound(parsed.snapshotId); + if ( + parsed.snapshotId !== round.snapshot.roundId || + parsed.snapshotDigest !== round.snapshotDigest + ) + throw Error("assessment snapshot mismatch"); + const objective = round.snapshot.objectiveProfileRefs[parsed.moduleKind]; + if ( + parsed.objectiveRef.objectiveId !== objective.objectiveId || + parsed.objectiveRef.revision !== objective.revision || + parsed.objectiveRef.digest !== objective.digest + ) + throw Error("assessment objective mismatch"); + const candidates = this.candidateSet(parsed.snapshotId); + if (candidates) + validateCandidateEvidence( + candidates, + round.snapshot, + (id) => this.getIntention(id), + [parsed], + false, + ); + if ( + this.db + .prepare( + "SELECT 1 FROM assessments WHERE round_id = ? AND module_kind = ?", + ) + .get(parsed.snapshotId, parsed.moduleKind) + ) + throw Error("duplicate assessment"); + this.db + .prepare( + "INSERT INTO assessments(round_id, module_kind, snapshot_digest, input_digest, digest, body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .run( + parsed.snapshotId, + parsed.moduleKind, + parsed.snapshotDigest, + parsed.inputDigest, + judgmentDigest(parsed), + body(parsed), + this.now(), + ); + }); + } + + assessmentSet(roundId: string): AssessmentSet { + return this.transaction(() => { + const round = this.getRound(roundId); + const rows = this.db + .prepare( + "SELECT body, digest, input_digest, snapshot_digest FROM assessments WHERE round_id = ?", + ) + .all(boundedId(roundId, "round id")); + if (!round || rows.length !== MODULE_KINDS.length) + throw Error("incomplete assessment set"); + const assessments = rows.map( + ({ + body: json, + digest, + input_digest: inputDigest, + snapshot_digest: snapshot, + }) => { + const parsed = parseAssessment(JSON.parse(String(json))); + if (judgmentDigest(parsed) !== digest) + throw Error("assessment digest mismatch"); + if (parsed.inputDigest !== inputDigest) + throw Error("assessment input digest mismatch"); + if (parsed.snapshotDigest !== snapshot) + throw Error("assessment snapshot digest mismatch"); + return parsed; + }, + ); + return parseAssessmentSet({ + schemaVersion: 1, + roundId, + snapshotDigest: round.snapshotDigest, + assessments: MODULE_KINDS.map((module) => + assessments.find((item) => item.moduleKind === module), + ), + }); + }, false); + } + + recordResolution( + roundId: string, + resolution: StoredResolutionRecord, + spec: SelectionSpec | null, + ): void { + this.assertOpen(); + const id = boundedId(roundId, "round id"); + const parsed = parseStoredResolutionRecord(resolution); + const selection = spec === null ? null : parseSelectionSpec(spec); + this.transaction(() => { + const round = this.requireOpenRound(id); + if ( + body( + this.activeObjectiveProfiles( + round.snapshot.agentId, + round.snapshot.scopeId, + ), + ) !== body(round.snapshot.objectiveProfileRefs) + ) + throw Error("stale objective profile refs"); + if ( + round.snapshot.intentionRevision !== + this.intentionRevision(round.snapshot.agentId, round.snapshot.scopeId) + ) + throw Error("stale intention revision"); + if (parsed.roundId !== id) throw Error("resolution round mismatch"); + if ( + parsed.situation !== round.snapshot.situation || + parsed.policyId !== round.snapshot.policyId || + parsed.policyRevision !== round.snapshot.policyRevision + ) + throw Error("resolution snapshot mismatch"); + const needsSelection = + parsed.schemaVersion === 1 && parsed.status === "resolved"; + if ( + needsSelection !== (selection !== null) || + (selection && + (selection.roundId !== id || + selection.snapshotDigest !== round.snapshotDigest)) + ) + throw Error("selection spec mismatch"); + const candidates = this.candidateSet(id); + const assessments = this.storedAssessments(id); + if (candidates) + validateCandidateEvidence( + candidates, + round.snapshot, + (id) => this.getIntention(id), + assessments, + false, + ); + const set = + assessments.length === MODULE_KINDS.length + ? this.assessmentSet(id) + : null; + validateResolutionBinding( + parsed, + selection, + set, + round.snapshot, + candidates, + (id) => this.getIntention(id), + assessments, + ); + const now = this.now(); + this.db + .prepare( + "INSERT INTO resolution_records(round_id, digest, body, created_at) VALUES (?, ?, ?, ?)", + ) + .run(id, judgmentDigest(parsed), body(parsed), now); + if (selection) + this.db + .prepare( + "INSERT INTO selection_specs(round_id, spec_digest, body, created_at) VALUES (?, ?, ?, ?)", + ) + .run(id, selection.specDigest, body(selection), now); + this.db + .prepare( + "UPDATE rounds SET status = ?, updated_at = ? WHERE round_id = ?", + ) + .run(parsed.status, now, id); + }); + } + + getResolution(roundId: string): StoredResolutionRecord | null; + getResolution(roundId: string, mode: "action"): ResolutionRecord | null; + getResolution( + roundId: string, + mode: "dialogue", + ): DialogueResolutionRecord | null; + getResolution( + roundId: string, + mode?: "action" | "dialogue", + ): StoredResolutionRecord | null { + return this.transaction(() => { + const row = this.db + .prepare( + "SELECT body, digest FROM resolution_records WHERE round_id = ?", + ) + .get(boundedId(roundId, "round id")); + if (!row) return null; + const { body: json, digest } = row; + const parsed = parseStoredResolutionRecord(JSON.parse(String(json))); + if (judgmentDigest(parsed) !== digest) + throw Error("resolution digest mismatch"); + if (mode !== undefined && mode !== "action" && mode !== "dialogue") + throw Error("invalid resolution mode"); + if ( + mode !== undefined && + mode !== (parsed.schemaVersion === 1 ? "action" : "dialogue") + ) + return null; + return parsed; + }, false); + } + + /** Reconstructible immutable reference, not a response-acceptance receipt. */ + dialogueJudgmentRef(roundId: string): DialogueJudgmentRef | null { + return this.transaction(() => { + const resolution = this.getResolution(roundId, "dialogue"); + if (resolution?.status !== "resolved") return null; + const round = this.getRound(roundId); + if (!round) throw Error("dialogue round missing"); + return buildDialogueJudgmentRef({ + snapshot: round.snapshot, + assessments: this.storedAssessments(roundId), + resolution, + }); + }, false); + } + + validateDialogueJudgmentRef(value: DialogueJudgmentRef): void { + const ref = parseDialogueJudgmentRef(value); + this.transaction(() => { + if (body(ref) !== body(this.dialogueJudgmentRef(ref.roundId))) + throw Error("dialogue judgment ref mismatch"); + }, false); + } + + getSelectionSpec(roundId: string): SelectionSpec | null { + return this.transaction(() => { + const row = this.db + .prepare( + "SELECT body, spec_digest FROM selection_specs WHERE round_id = ?", + ) + .get(boundedId(roundId, "round id")); + if (!row) return null; + const { body: json, spec_digest: digest } = row; + const parsed = parseSelectionSpec(JSON.parse(String(json))); + if (parsed.specDigest !== digest) + throw Error("selection spec digest mismatch"); + return parsed; + }, false); + } + + /** Scoped create/+1-transition mutation counter; no delete or scope-move API. + * Keep this invariant if future mutation APIs are introduced. */ + intentionRevision(agentId: string, scopeId: string): number { + return this.transaction(() => { + const row = this.db + .prepare( + "SELECT COALESCE(SUM(1 + revision), 0) AS revision FROM intention_records WHERE agent_id = ? AND scope_id = ?", + ) + .get(boundedId(agentId, "agent id"), boundedId(scopeId, "scope id")); + return revision(Number(row?.["revision"])); + }, false); + } + + putIntention(record: IntentionRecord): void { + this.assertOpen(); + const parsed = parseIntentionRecord(record); + if ( + parsed.status !== "proposed" || + parsed.revision !== 0 || + parsed.history.length !== 0 + ) + throw Error("intention must be proposed"); + this.transaction(() => { + if (this.getIntention(parsed.intentionId)) + throw Error("duplicate intention"); + revision(this.intentionRevision(parsed.agentId, parsed.scopeId) + 1); + const now = this.now(); + this.db + .prepare( + "INSERT INTO intention_records(intention_id, agent_id, scope_id, revision, status, digest, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .run( + parsed.intentionId, + parsed.agentId, + parsed.scopeId, + parsed.revision, + parsed.status, + intentionDigest(parsed), + body(parsed), + now, + now, + ); + }); + } + + transitionIntention( + intentionId: string, + transition: Omit, + expectedRevision: number, + ): IntentionRecord { + this.assertOpen(); + const id = boundedId(intentionId, "intention id"); + revision(expectedRevision); + return this.transaction(() => { + const record = this.getIntention(id); + if (!record) throw Error("unknown intention"); + if (record.revision !== expectedRevision) + throw Error("stale intention revision"); + if (Object.hasOwn(transition, "from")) + throw Error("unknown intention transition field from"); + const entry = parseIntentionTransition({ + from: record.status, + ...transition, + }); + const updated = parseIntentionRecord( + transitionIntention(record, transition), + ); + revision(this.intentionRevision(record.agentId, record.scopeId) + 1); + this.db + .prepare( + "UPDATE intention_records SET revision = ?, status = ?, digest = ?, body = ?, updated_at = ? WHERE intention_id = ?", + ) + .run( + updated.revision, + updated.status, + intentionDigest(updated), + body(updated), + this.now(), + id, + ); + this.db + .prepare( + "INSERT INTO intention_transitions(intention_id, revision, from_status, to_status, reason, evidence_ref, at) VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .run( + id, + updated.revision, + entry.from, + entry.to, + entry.reason, + entry.evidenceRef, + entry.at, + ); + return updated; + }); + } + + getIntention(intentionId: string): IntentionRecord | null { + return this.transaction(() => { + const row = this.db + .prepare( + "SELECT body, digest FROM intention_records WHERE intention_id = ?", + ) + .get(boundedId(intentionId, "intention id")); + if (!row) return null; + const { body: json, digest } = row; + const parsed = parseIntentionRecord(JSON.parse(String(json))); + if (intentionDigest(parsed) !== digest) + throw Error("intention digest mismatch"); + return parsed; + }, false); + } + + listIntentions( + agentId: string, + scopeId: string, + status?: IntentionStatus, + ): IntentionRecord[] { + return this.transaction(() => { + const agent = boundedId(agentId, "agent id"); + const scope = boundedId(scopeId, "scope id"); + if (status !== undefined && !INTENTION_STATUSES.includes(status)) + throw Error("invalid intention status"); + const statement = this.db.prepare( + status === undefined + ? "SELECT body, digest FROM intention_records WHERE agent_id = ? AND scope_id = ? ORDER BY intention_id" + : "SELECT body, digest FROM intention_records WHERE agent_id = ? AND scope_id = ? AND status = ? ORDER BY intention_id", + ); + const rows = + status === undefined + ? statement.all(agent, scope) + : statement.all(agent, scope, status); + return rows.map(({ body: json, digest }) => { + const parsed = parseIntentionRecord(JSON.parse(String(json))); + if (intentionDigest(parsed) !== digest) + throw Error("intention digest mismatch"); + return parsed; + }); + }, false); + } + + close(): void { + this.assertOpen(); + this.db.close(); + this.closed = true; + } + + private assertOpen(): void { + if (this.closed) throw Error("judgment store is closed"); + } + + private requireOpenRound(roundId: string) { + this.assertOpen(); + const round = this.getRound(roundId); + if (!round) throw Error("unknown judgment round"); + if (round.status !== "open") throw Error("judgment round is not open"); + return round; + } + + /** Scan before any indexed filter, on the same SQLite snapshot as the call. + * Own writes preserve these invariants; only an external commit (or reopen) + * requires another scan. Never recurse through public getters here. + */ + private validateLedger(): void { + const rows = ( + table: string, + label: string, + parse: (value: unknown) => T, + metadata: (value: T) => Record, + digest: (value: T) => string = judgmentDigest, + jsonColumn = "body", + digestColumn = "digest", + ) => + this.db + .prepare(`SELECT * FROM ${table}`) + .all() + .map((row) => { + const value = parse(JSON.parse(String(row[jsonColumn]))); + if (digest(value) !== row[digestColumn]) + throw Error(`${label} digest mismatch`); + for (const [column, expected] of Object.entries(metadata(value))) { + if (row[column] !== expected) { + if (table === "assessments" && column === "input_digest") + throw Error("assessment input digest mismatch"); + if (table === "assessments" && column === "snapshot_digest") + throw Error("assessment snapshot digest mismatch"); + throw Error(`${label} metadata mismatch`); + } + } + return { row, value }; + }); + const profiles = rows( + "objective_profiles", + "objective profile", + parseObjectiveProfile, + (p) => ({ + objective_id: p.objectiveId, + revision: p.revision, + module_kind: p.moduleKind, + }), + ); + const profilesByRef = new Map( + profiles.map((entry) => [ + body([entry.value.objectiveId, entry.value.revision]), + entry, + ]), + ); + for (const row of this.db + .prepare("SELECT * FROM objective_profile_active") + .all()) { + const { + objective_id, + revision: profileRevision, + module_kind, + agent_id, + scope_id, + activation_revision, + } = row; + if ( + profilesByRef.get(body([objective_id, profileRevision]))?.value + .moduleKind !== module_kind + ) + throw Error("objective activation metadata mismatch"); + boundedId(agent_id, "agent id"); + boundedId(scope_id, "scope id"); + revision(Number(activation_revision), 1); + } + const rounds = rows( + "rounds", + "snapshot", + parseJudgmentSnapshotRef, + (p) => ({ + round_id: p.roundId, + agent_id: p.agentId, + scope_id: p.scopeId, + situation: p.situation, + sequence: p.sequence, + }), + snapshotDigest, + "snapshot", + "snapshot_digest", + ); + const candidates = rows( + "candidate_sets", + "candidate set", + parseCandidateSet, + (p) => ({ round_id: p.roundId, snapshot_digest: p.snapshotDigest }), + (p) => p.candidateDigest, + "body", + "candidate_digest", + ); + const assessments = rows( + "assessments", + "assessment", + parseAssessment, + (p) => ({ + round_id: p.snapshotId, + module_kind: p.moduleKind, + input_digest: p.inputDigest, + snapshot_digest: p.snapshotDigest, + }), + ); + const resolutions = rows( + "resolution_records", + "resolution", + parseStoredResolutionRecord, + (p) => ({ round_id: p.roundId }), + ); + const statuses = new Map( + resolutions.map(({ value: p }) => [p.roundId, p.status]), + ); + for (const { + row: { status }, + value, + } of rounds) { + if (status !== (statuses.get(value.roundId) ?? "open")) + throw Error("round status metadata mismatch"); + for (const module of MODULE_KINDS) { + const ref = value.objectiveProfileRefs[module]; + const profile = profilesByRef.get( + body([ref.objectiveId, ref.revision]), + ); + if ( + !profile || + profile.value.moduleKind !== module || + profile.row["digest"] !== ref.digest + ) + throw Error("snapshot objective profile mismatch"); + } + } + const selections = rows( + "selection_specs", + "selection spec", + parseSelectionSpec, + (p) => ({ round_id: p.roundId }), + (p) => p.specDigest, + "body", + "spec_digest", + ); + const selectedRounds = new Set( + selections.map(({ value }) => value.roundId), + ); + for (const { value } of resolutions) { + if ( + (value.schemaVersion === 1 && value.status === "resolved") !== + selectedRounds.has(value.roundId) + ) + throw Error("selection spec metadata mismatch"); + } + const snapshots = new Map( + rounds.map(({ value }) => [value.roundId, value]), + ); + const records = new Map( + resolutions.map(({ value }) => [value.roundId, value]), + ); + for (const { value } of resolutions) { + const snapshot = snapshots.get(value.roundId); + if ( + !snapshot || + value.policyId !== snapshot.policyId || + value.policyRevision !== snapshot.policyRevision || + value.situation !== snapshot.situation + ) + throw Error("resolution snapshot mismatch"); + } + const sets = new Map(); + for (const { value } of assessments) { + const snapshot = snapshots.get(value.snapshotId); + if (!snapshot) throw Error("judgment foreign key mismatch"); + if (value.snapshotDigest !== snapshotDigest(snapshot)) + throw Error("assessment snapshot mismatch"); + if ( + body(value.objectiveRef) !== + body(snapshot.objectiveProfileRefs[value.moduleKind]) + ) + throw Error("assessment objective mismatch"); + const set = sets.get(value.snapshotId) ?? []; + set.push(value); + sets.set(value.snapshotId, set); + } + for (const { value: selection } of selections) { + const snapshot = snapshots.get(selection.roundId); + const record = records.get(selection.roundId); + if ( + !snapshot || + !record || + record.status !== "resolved" || + selection.snapshotDigest !== snapshotDigest(snapshot) + ) + throw Error("selection spec metadata mismatch"); + } + const intentions = rows( + "intention_records", + "intention", + parseIntentionRecord, + (p) => ({ + intention_id: p.intentionId, + agent_id: p.agentId, + scope_id: p.scopeId, + revision: p.revision, + status: p.status, + }), + intentionDigest, + ); + const histories = new Map( + intentions.map(({ value: p }) => [p.intentionId, p.history]), + ); + const transitions = this.db + .prepare("SELECT * FROM intention_transitions") + .all(); + if ( + transitions.length !== + intentions.reduce((n, { value }) => n + value.history.length, 0) + ) + throw Error("intention transition metadata mismatch"); + for (const row of transitions) { + const { + intention_id, + revision: transitionRevision, + from_status, + to_status, + reason, + evidence_ref, + at, + } = row; + const history = histories.get(String(intention_id)); + const entry = history?.[Number(transitionRevision) - 1]; + if ( + !entry || + entry.from !== from_status || + entry.to !== to_status || + entry.reason !== reason || + entry.evidenceRef !== evidence_ref || + entry.at !== at + ) + throw Error("intention transition metadata mismatch"); + } + const intentionsById = new Map( + intentions.map(({ value }) => [value.intentionId, value]), + ); + const candidatesByRound = new Map( + candidates.map(({ value }) => [value.roundId, value]), + ); + for (const { value } of candidates) { + const snapshot = snapshots.get(value.roundId); + if (!snapshot) throw Error("judgment foreign key mismatch"); + validateCandidateEvidence( + value, + snapshot, + (id) => intentionsById.get(id) ?? null, + sets.get(value.roundId) ?? [], + false, + ); + } + const selectionsByRound = new Map( + selections.map(({ value }) => [value.roundId, value]), + ); + for (const { value: record } of resolutions) { + const snapshot = snapshots.get(record.roundId); + if (!snapshot) throw Error("resolution snapshot mismatch"); + const assessments = sets.get(record.roundId) ?? []; + let set: AssessmentSet | null = null; + if (assessments.length === MODULE_KINDS.length) { + set = parseAssessmentSet({ + schemaVersion: 1, + roundId: record.roundId, + snapshotDigest: snapshotDigest(snapshot), + assessments: MODULE_KINDS.map((module) => + assessments.find((a) => a.moduleKind === module), + ), + }); + } + validateResolutionBinding( + record, + selectionsByRound.get(record.roundId) ?? null, + set, + snapshot, + candidatesByRound.get(record.roundId) ?? null, + (id) => intentionsById.get(id) ?? null, + assessments, + ); + } + if (this.db.prepare("PRAGMA foreign_key_check").all().length > 0) + throw Error("judgment foreign key mismatch"); + } + + private transaction(fn: () => T, write = true): T { + this.assertOpen(); + if (this.db.isTransaction) return fn(); + this.db.exec(write ? "BEGIN IMMEDIATE" : "BEGIN"); + try { + // Establish the read snapshot before observing data_version. Otherwise an + // external commit could be scanned under one version and cached as another. + this.db + .prepare("SELECT value FROM judgment_meta WHERE key = 'store'") + .get(); + const { data_version: dataVersion } = + this.db.prepare("PRAGMA data_version").get() ?? {}; + if (dataVersion !== this.validatedDataVersion) { + this.validateLedger(); + this.validatedDataVersion = Number(dataVersion); + } + const result = fn(); + this.db.exec("COMMIT"); + return result; + } catch (error) { + if (this.db.isTransaction) this.db.exec("ROLLBACK"); + throw error; + } + } +} diff --git a/packages/lina-core/src/agents/judgment-validation.ts b/packages/lina-core/src/agents/judgment-validation.ts new file mode 100644 index 0000000..bb81407 --- /dev/null +++ b/packages/lina-core/src/agents/judgment-validation.ts @@ -0,0 +1,1038 @@ +import { createHash } from "node:crypto"; +import { validId } from "../context/validation.ts"; +import { + ACCEPTED_BY, + ASSESSMENT_UNAVAILABLE_REASONS, + type Assessment, + type AssessmentSet, + type DialogueSourceRef, + EXCLUSION_STAGES, + INTENTION_KINDS, + INTENTION_RELATIONS, + INTENTION_STATUSES, + type IntentionRecord, + type IntentionStatus, + type IntentionTransition, + type JsonObject, + type JsonValue, + type JudgmentSnapshotRef, + MODULE_KINDS, + type ModuleKind, + type ObjectiveProfile, + type ObjectiveProfileRef, + type OptionAssessment, + type ResolutionRecord, + SEVERITIES, + type SelectionSpec, + SITUATIONS, + STANCES, +} from "./judgment.ts"; +import { boundedId, boundedText } from "./validation.ts"; + +const MAX_LIST = 256; + +function object(value: unknown, label: string): Record { + if ( + value === null || + typeof value !== "object" || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) + throw Error(`invalid ${label}`); + return value as Record; +} + +function fields( + value: unknown, + keys: readonly string[], + label: string, + versioned = false, +): Record { + const row = object(value, label); + if (versioned && row["schemaVersion"] !== 1) + throw Error(`Unsupported ${label} schema version`); + for (const key of Object.keys(row)) + if (!keys.includes(key)) throw Error(`unknown ${label} field ${key}`); + for (const key of keys) + if (!Object.hasOwn(row, key)) throw Error(`missing ${label} field ${key}`); + return row; +} + +function enumeration( + value: unknown, + values: readonly T[], + label: string, +): T { + const text = boundedId(value, label); + const member = values.find((item) => item === text); + if (member === undefined) throw Error(`invalid ${label}`); + return member; +} + +function revision(value: unknown, label: string, min = 0): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min) + throw Error(`invalid ${label}`); + return value; +} + +function finite(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) + throw Error(`invalid ${label}`); + return value; +} + +function nullableId(value: unknown, label: string): string | null { + return value === null ? null : boundedId(value, label); +} + +function nullableText(value: unknown, label: string): string | null { + return value === null ? null : boundedText(value, label); +} + +function timestamp(value: unknown, label: string): string { + const text = boundedText(value, label); + const time = Date.parse(text); + if (!Number.isFinite(time) || new Date(time).toISOString() !== text) + throw Error(`invalid ${label}`); + return text; +} + +function list( + value: unknown, + parse: (item: unknown) => T, + label: string, +): T[] { + if (!Array.isArray(value) || value.length > MAX_LIST) + throw Error(`invalid ${label}`); + return Array.from(value, parse); +} + +/** Set-like lists canonicalize keys; semantic order (policy/ranking) is preserved. */ +function uniqueSorted( + items: T[], + key: (item: T) => string, + label: string, + sort = true, +): T[] { + if (new Set(items.map(key)).size !== items.length) + throw Error(`duplicate ${label}`); + return sort + ? [...items].sort((a, b) => + key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0, + ) + : items; +} + +function strings(value: unknown, label: string, parse = boundedId): string[] { + return uniqueSorted( + list(value, (item) => parse(item, label), label), + (item) => item, + label, + ); +} + +function moduleRecord( + value: unknown, + parse: (item: unknown) => T, + label: string, +): Record { + const row = fields(value, MODULE_KINDS, label); + return { + clotho: parse(row["clotho"]), + lachesis: parse(row["lachesis"]), + atropos: parse(row["atropos"]), + }; +} + +function jsonValue(value: unknown): JsonValue { + if (value === null || typeof value === "boolean") return value; + if (typeof value === "string") return boundedText(value, "JSON text"); + if (typeof value === "number") return finite(value, "JSON number"); + if (Array.isArray(value)) return list(value, jsonValue, "JSON array"); + return jsonObject(value); +} + +function jsonObject(value: unknown): JsonObject { + const row = object(value, "JSON object"); + return Object.fromEntries( + Object.entries(row).map(([key, item]) => [ + boundedId(key, "JSON key"), + jsonValue(item), + ]), + ); +} + +function canonical(item: unknown, depth = 0): unknown { + if (depth > 64) throw Error("canonical value too deep"); + return Array.isArray(item) + ? item.map((value) => canonical(value, depth + 1)) + : item !== null && typeof item === "object" + ? Object.fromEntries( + Object.keys(item) + .sort() + .map((key) => [ + key, + canonical((item as Record)[key], depth + 1), + ]), + ) + : item; +} + +export function canonicalJson(value: unknown): string { + if ( + value === undefined || + typeof value === "function" || + typeof value === "symbol" || + typeof value === "bigint" + ) + throw Error("unsupported canonical value"); + return JSON.stringify(canonical(value)); +} + +export function judgmentDigest(value: unknown): string { + return createHash("sha256").update(canonicalJson(value)).digest("hex"); +} + +export function assessmentInputDigest(input: { + snapshotDigest: string; + objectiveRef: ObjectiveProfileRef; + mechanismRevision: number; +}): string { + return judgmentDigest({ + snapshotDigest: input.snapshotDigest, + objectiveRef: input.objectiveRef, + mechanismRevision: input.mechanismRevision, + }); +} +export function snapshotDigest(ref: JudgmentSnapshotRef): string { + return judgmentDigest(ref); +} +export function intentionDigest(record: IntentionRecord): string { + return judgmentDigest(record); +} + +function objectiveProfileRef(value: unknown): ObjectiveProfileRef { + const row = fields( + value, + ["objectiveId", "revision", "digest"], + "objective profile ref", + ); + return { + objectiveId: boundedId(row["objectiveId"], "objective id"), + revision: revision(row["revision"], "objective revision", 1), + digest: boundedId(row["digest"], "objective digest"), + }; +} + +export function parseObjectiveProfileRef(value: unknown): ObjectiveProfileRef { + return objectiveProfileRef(value); +} + +function objectiveProfileRefs( + value: unknown, +): Record { + const refs = moduleRecord( + value, + objectiveProfileRef, + "objective profile refs", + ); + // One immutable profile revision has exactly one module owner. + uniqueSorted( + Object.values(refs), + (ref) => JSON.stringify([ref.objectiveId, ref.revision]), + "objective profile refs", + false, + ); + return refs; +} + +export function parseObjectiveProfile(value: unknown): ObjectiveProfile { + const row = fields( + value, + [ + "schemaVersion", + "objectiveId", + "moduleKind", + "revision", + "objective", + "comparisonCriteria", + "reconsiderationConditions", + ], + "objective profile", + true, + ); + return { + schemaVersion: 1, + objectiveId: boundedId(row["objectiveId"], "objective id"), + moduleKind: enumeration(row["moduleKind"], MODULE_KINDS, "module kind"), + revision: revision(row["revision"], "objective revision", 1), + objective: boundedText(row["objective"], "objective"), + comparisonCriteria: strings( + row["comparisonCriteria"], + "comparison criteria", + boundedText, + ), + reconsiderationConditions: strings( + row["reconsiderationConditions"], + "reconsideration conditions", + boundedText, + ), + }; +} + +export function parseJudgmentSnapshotRef(value: unknown): JudgmentSnapshotRef { + const hasDialogueSource = + typeof value === "object" && + value !== null && + Object.hasOwn(value, "dialogueSource"); + const row = fields( + value, + [ + "schemaVersion", + "roundId", + "agentId", + "scopeId", + "sourceRefs", + "workingRevision", + "instructionRevision", + "policyId", + "policyRevision", + "identityRevision", + "domainRevisions", + "intentionRevision", + "objectiveProfileRefs", + "observationRef", + "frozenNeuralRef", + "situation", + "clockId", + "sequence", + "bindingGeneration", + ...(hasDialogueSource ? ["dialogueSource"] : []), + ], + "judgment snapshot ref", + true, + ); + const sourceRefs = uniqueSorted( + list( + row["sourceRefs"], + (value) => { + const source = fields(value, ["kind", "id", "revision"], "source ref"); + const kind = boundedId(source["kind"], "source kind"); + // Context-owned refs keep their owner's bound; unknown kinds stay opaque. + const parseId = + kind === "request" || kind === "entry" || kind === "summary" + ? validId + : boundedId; + return { + kind, + id: parseId(source["id"], "source id"), + revision: revision(source["revision"], "source revision"), + }; + }, + "source refs", + ), + (source) => JSON.stringify([source.kind, source.id]), + "source refs", + ); + const domains = object(row["domainRevisions"], "domain revisions"); + let dialogueSource: DialogueSourceRef | undefined; + if (hasDialogueSource) { + const source = fields( + row["dialogueSource"], + ["requestId", "requestDigest", "sourceDigest"], + "dialogue source", + ); + dialogueSource = { + requestId: validId(source["requestId"], "request id"), + requestDigest: boundedId(source["requestDigest"], "request digest"), + sourceDigest: boundedId(source["sourceDigest"], "source digest"), + }; + if ( + ![dialogueSource.requestDigest, dialogueSource.sourceDigest].every( + (value) => /^[a-f0-9]{64}$/.test(value), + ) + ) + throw Error("invalid dialogue source digest"); + const requestId = dialogueSource.requestId; + if ( + !sourceRefs.some((ref) => ref.kind === "request" && ref.id === requestId) + ) + throw Error("dialogue request source mismatch"); + } + return { + schemaVersion: 1, + roundId: boundedId(row["roundId"], "round id"), + agentId: boundedId(row["agentId"], "agent id"), + scopeId: boundedId(row["scopeId"], "scope id"), + sourceRefs, + ...(dialogueSource ? { dialogueSource } : {}), + workingRevision: revision(row["workingRevision"], "working revision"), + instructionRevision: revision( + row["instructionRevision"], + "instruction revision", + ), + policyId: boundedId(row["policyId"], "policy id"), + policyRevision: revision(row["policyRevision"], "policy revision"), + identityRevision: revision(row["identityRevision"], "identity revision"), + domainRevisions: Object.fromEntries( + Object.keys(domains) + .sort() + .map((key) => [ + boundedId(key, "domain id"), + revision(domains[key], "domain revision"), + ]), + ), + intentionRevision: revision(row["intentionRevision"], "intention revision"), + objectiveProfileRefs: objectiveProfileRefs(row["objectiveProfileRefs"]), + observationRef: nullableId(row["observationRef"], "observation ref"), + frozenNeuralRef: nullableId(row["frozenNeuralRef"], "frozen neural ref"), + situation: enumeration(row["situation"], SITUATIONS, "situation"), + clockId: boundedId(row["clockId"], "clock id"), + sequence: revision(row["sequence"], "sequence"), + bindingGeneration: revision(row["bindingGeneration"], "binding generation"), + }; +} + +export function parseOptionAssessment(value: unknown): OptionAssessment { + const hasBreaches = + typeof value === "object" && + value !== null && + Object.hasOwn(value, "breachedIntentionIds"); + const row = fields( + value, + [ + "optionKey", + "stance", + "severity", + "unavailableReason", + "gain", + "loss", + "uncertainty", + "evidenceRefs", + ...(hasBreaches ? ["breachedIntentionIds"] : []), + ], + "option assessment", + ); + const stance = enumeration(row["stance"], STANCES, "stance"); + const severity = + row["severity"] === null + ? null + : enumeration(row["severity"], SEVERITIES, "severity"); + const unavailableReason = + row["unavailableReason"] === null + ? null + : enumeration( + row["unavailableReason"], + ASSESSMENT_UNAVAILABLE_REASONS, + "unavailable reason", + ); + if ((stance === "oppose") !== (severity !== null)) + throw Error("severity requires oppose stance"); + if ((stance === "unavailable") !== (unavailableReason !== null)) + throw Error("unavailable stance requires reason"); + const result: OptionAssessment = { + optionKey: boundedId(row["optionKey"], "option key"), + stance, + severity, + unavailableReason, + gain: boundedText(row["gain"], "gain"), + loss: boundedText(row["loss"], "loss"), + uncertainty: boundedText(row["uncertainty"], "uncertainty"), + evidenceRefs: strings(row["evidenceRefs"], "evidence refs"), + }; + if (hasBreaches) { + if (severity !== "commitment_breach") + throw Error("breach attribution requires commitment opposition"); + result.breachedIntentionIds = strings( + row["breachedIntentionIds"], + "breached intention ids", + ); + } + return result; +} + +export function parseAssessment(value: unknown): Assessment { + const row = fields( + value, + [ + "schemaVersion", + "moduleKind", + "snapshotId", + "snapshotDigest", + "inputDigest", + "objectiveRef", + "mechanismRevision", + "completeText", + "evidenceRefs", + "proposedOptionKeys", + "objectiveAssessments", + "recommendedOptionKeys", + "detail", + "diagnostics", + ], + "assessment", + true, + ); + const moduleKind = enumeration( + row["moduleKind"], + MODULE_KINDS, + "module kind", + ); + const detail = fields(row["detail"], ["kind", "body"], "assessment detail"); + const kind = enumeration( + detail["kind"], + ["forecasts", "values", "continuity"] as const, + "assessment detail kind", + ); + const expected = { + clotho: "forecasts", + lachesis: "values", + atropos: "continuity", + }; + if (kind !== expected[moduleKind]) + throw Error("assessment detail kind mismatch"); + const body = jsonObject(detail["body"]); + const objectiveAssessments = uniqueSorted( + list( + row["objectiveAssessments"], + parseOptionAssessment, + "objective assessments", + ), + (item) => item.optionKey, + "assessment option keys", + ); + const recommendedOptionKeys = strings( + row["recommendedOptionKeys"], + "recommended option keys", + ); + const assessed = new Set(objectiveAssessments.map((item) => item.optionKey)); + if (recommendedOptionKeys.some((key) => !assessed.has(key))) + throw Error("recommended option is not assessed"); + const result = { + schemaVersion: 1 as const, + moduleKind, + snapshotId: boundedId(row["snapshotId"], "snapshot id"), + snapshotDigest: boundedId(row["snapshotDigest"], "snapshot digest"), + inputDigest: boundedId(row["inputDigest"], "input digest"), + objectiveRef: objectiveProfileRef(row["objectiveRef"]), + mechanismRevision: revision(row["mechanismRevision"], "mechanism revision"), + completeText: boundedText(row["completeText"], "complete text"), + evidenceRefs: strings(row["evidenceRefs"], "evidence refs"), + proposedOptionKeys: strings( + row["proposedOptionKeys"], + "proposed option keys", + ), + objectiveAssessments, + recommendedOptionKeys, + detail: { kind, body }, + diagnostics: jsonObject(row["diagnostics"]), + }; + if (result.inputDigest !== assessmentInputDigest(result)) + throw Error("assessment input digest mismatch"); + switch (moduleKind) { + case "clotho": + return { ...result, moduleKind, detail: { kind: "forecasts", body } }; + case "lachesis": + return { ...result, moduleKind, detail: { kind: "values", body } }; + case "atropos": + return { ...result, moduleKind, detail: { kind: "continuity", body } }; + } +} + +export function parseAssessmentSet(value: unknown): AssessmentSet { + const row = fields( + value, + ["schemaVersion", "roundId", "snapshotDigest", "assessments"], + "assessment set", + true, + ); + const roundId = boundedId(row["roundId"], "round id"); + const digest = boundedId(row["snapshotDigest"], "snapshot digest"); + const assessments = list(row["assessments"], parseAssessment, "assessments"); + if ( + assessments.length !== MODULE_KINDS.length || + assessments.some((item, index) => item.moduleKind !== MODULE_KINDS[index]) + ) + throw Error("assessment set requires each module in declared order"); + if ( + assessments.some( + (item) => item.snapshotId !== roundId || item.snapshotDigest !== digest, + ) + ) + throw Error("assessment set snapshot mismatch"); + return { schemaVersion: 1, roundId, snapshotDigest: digest, assessments }; +} + +export function parseResolutionRecord(value: unknown): ResolutionRecord { + const row = fields( + value, + [ + "schemaVersion", + "roundId", + "policyId", + "policyRevision", + "situation", + "order", + "recommendations", + "conflicts", + "excluded", + "abstentions", + "ranking", + "conceded", + "status", + "holdReason", + ], + "resolution record", + true, + ); + const status = enumeration( + row["status"], + ["resolved", "deferred", "held"] as const, + "resolution status", + ); + const holdReason = nullableText(row["holdReason"], "hold reason"); + if ((status !== "resolved") !== (holdReason !== null)) + throw Error("unresolved status requires hold reason"); + const order = uniqueSorted( + list( + row["order"], + (item) => enumeration(item, MODULE_KINDS, "module order"), + "module order", + ), + (item) => item, + "module order", + false, + ); + if (order.length !== MODULE_KINDS.length) + throw Error("resolution order requires each module"); + return { + schemaVersion: 1, + roundId: boundedId(row["roundId"], "round id"), + policyId: boundedId(row["policyId"], "policy id"), + policyRevision: revision(row["policyRevision"], "policy revision"), + situation: enumeration(row["situation"], SITUATIONS, "situation"), + order, + recommendations: moduleRecord( + row["recommendations"], + (item) => strings(item, "recommendation keys"), + "recommendations", + ), + conflicts: uniqueSorted( + list( + row["conflicts"], + (value) => { + const item = fields(value, ["optionKey", "stances"], "conflict"); + return { + optionKey: boundedId(item["optionKey"], "option key"), + stances: moduleRecord( + item["stances"], + (stance) => enumeration(stance, STANCES, "stance"), + "conflict stances", + ), + }; + }, + "conflicts", + ), + (item) => item.optionKey, + "conflicts", + ), + excluded: uniqueSorted( + list( + row["excluded"], + (value) => { + const item = fields( + value, + ["optionKey", "stage", "byModule", "reason"], + "exclusion", + ); + return { + optionKey: boundedId(item["optionKey"], "option key"), + stage: enumeration( + item["stage"], + EXCLUSION_STAGES, + "exclusion stage", + ), + byModule: + item["byModule"] === null + ? null + : enumeration( + item["byModule"], + MODULE_KINDS, + "exclusion module", + ), + reason: boundedText(item["reason"], "exclusion reason"), + }; + }, + "exclusions", + ), + (item) => item.optionKey, + "exclusions", + ), + abstentions: uniqueSorted( + list( + row["abstentions"], + (value) => { + const item = fields( + value, + ["optionKey", "moduleKind", "reason"], + "abstention", + ); + return { + optionKey: boundedId(item["optionKey"], "option key"), + moduleKind: enumeration( + item["moduleKind"], + MODULE_KINDS, + "module kind", + ), + reason: boundedText(item["reason"], "abstention reason"), + }; + }, + "abstentions", + ), + (item) => JSON.stringify([item.optionKey, item.moduleKind]), + "abstentions", + ), + ranking: uniqueSorted( + list( + row["ranking"], + (value) => { + const item = fields(value, ["optionKey", "rank"], "ranking"); + return { + optionKey: boundedId(item["optionKey"], "option key"), + rank: revision(item["rank"], "rank", 1), + }; + }, + "ranking", + ), + (item) => item.optionKey, + "ranking", + false, + ), + conceded: uniqueSorted( + list( + row["conceded"], + (value) => { + const item = fields(value, ["moduleKind", "optionKey"], "concession"); + return { + moduleKind: enumeration( + item["moduleKind"], + MODULE_KINDS, + "module kind", + ), + optionKey: boundedId(item["optionKey"], "option key"), + }; + }, + "concessions", + ), + (item) => JSON.stringify([item.moduleKind, item.optionKey]), + "concessions", + ), + status, + holdReason, + }; +} + +export function parseSelectionSpec(value: unknown): SelectionSpec { + const row = fields( + value, + [ + "schemaVersion", + "roundId", + "snapshotDigest", + "assessmentSetDigest", + "objectiveProfileRefs", + "resolutionDigest", + "policyId", + "policyRevision", + "situation", + "lambda", + "candidates", + "eligibleDigest", + "specDigest", + ], + "selection spec", + true, + ); + const candidates = uniqueSorted( + list( + row["candidates"], + (value) => { + const item = fields( + value, + ["optionKey", "p0", "b"], + "selection candidate", + ); + const p0 = finite(item["p0"], "p0"); + if (p0 < 0) throw Error("invalid p0"); + const b = item["b"] === null ? null : finite(item["b"], "bias"); + if (b !== null && (b < -1 || b > 1)) throw Error("invalid bias"); + return { + optionKey: boundedId(item["optionKey"], "option key"), + p0, + b, + }; + }, + "selection candidates", + ), + (item) => item.optionKey, + "selection candidates", + false, + ); + for (const [index, candidate] of candidates.entries()) { + const previous = candidates[index - 1]; + if (previous && previous.optionKey > candidate.optionKey) + throw Error("unsorted selection candidates"); + } + if ( + !candidates.some((item) => item.p0 > 0) || + Math.abs(candidates.reduce((sum, item) => sum + item.p0, 0) - 1) > 1e-12 + ) + throw Error("invalid selection mass"); + const lambda = finite(row["lambda"], "lambda"); + if (lambda < 0) throw Error("invalid lambda"); + const result: SelectionSpec = { + schemaVersion: 1, + roundId: boundedId(row["roundId"], "round id"), + snapshotDigest: boundedId(row["snapshotDigest"], "snapshot digest"), + assessmentSetDigest: boundedId( + row["assessmentSetDigest"], + "assessment set digest", + ), + objectiveProfileRefs: objectiveProfileRefs(row["objectiveProfileRefs"]), + resolutionDigest: boundedId(row["resolutionDigest"], "resolution digest"), + policyId: boundedId(row["policyId"], "policy id"), + policyRevision: revision(row["policyRevision"], "policy revision"), + situation: enumeration(row["situation"], SITUATIONS, "situation"), + lambda, + candidates, + eligibleDigest: boundedId(row["eligibleDigest"], "eligible digest"), + specDigest: boundedId(row["specDigest"], "spec digest"), + }; + if ( + result.eligibleDigest !== + judgmentDigest( + candidates.filter((item) => item.p0 > 0).map((item) => item.optionKey), + ) + ) + throw Error("eligible digest mismatch"); + if ( + result.specDigest !== judgmentDigest({ ...result, specDigest: undefined }) + ) + throw Error("selection spec digest mismatch"); + return result; +} + +export const INTENTION_TRANSITIONS: Readonly< + Record +> = Object.freeze({ + proposed: Object.freeze(["adopted", "cancelled"] as const), + adopted: Object.freeze(["active", "suspended", "cancelled"] as const), + active: Object.freeze(["suspended", "completed", "cancelled"] as const), + suspended: Object.freeze(["active", "cancelled"] as const), + completed: Object.freeze([]), + cancelled: Object.freeze([]), +}); + +export function parseIntentionTransition(value: unknown): IntentionTransition { + const row = fields( + value, + ["from", "to", "reason", "evidenceRef", "at"], + "intention transition", + ); + return { + from: enumeration(row["from"], INTENTION_STATUSES, "transition from"), + to: enumeration(row["to"], INTENTION_STATUSES, "transition to"), + reason: boundedText(row["reason"], "transition reason"), + evidenceRef: nullableId(row["evidenceRef"], "transition evidence ref"), + at: timestamp(row["at"], "transition at"), + }; +} + +function validateTransition( + transition: IntentionTransition, + acceptanceSourceRef: string, +): void { + const { from, to, evidenceRef } = transition; + if (!INTENTION_TRANSITIONS[from].includes(to)) + throw Error(`invalid intention transition: ${from} -> ${to}`); + if (to === "completed" && evidenceRef === null) + throw Error("completed intention requires outcome ref"); + if ( + (to === "cancelled" || + to === "suspended" || + (from === "suspended" && to === "active")) && + evidenceRef !== acceptanceSourceRef + ) + throw Error("intention change requires original acceptance ref"); +} + +export function parseIntentionRecord(value: unknown): IntentionRecord { + const row = fields( + value, + [ + "schemaVersion", + "intentionId", + "agentId", + "scopeId", + "revision", + "kind", + "purposeRef", + "text", + "acceptance", + "priority", + "deadline", + "completionCondition", + "abortConditions", + "relatedIntentions", + "status", + "history", + ], + "intention record", + true, + ); + const intentionId = boundedId(row["intentionId"], "intention id"); + const acceptance = fields( + row["acceptance"], + ["sourceRef", "acceptedBy", "policyRevision", "acceptedAt"], + "intention acceptance", + ); + const kind = enumeration(row["kind"], INTENTION_KINDS, "intention kind"); + const acceptedBy = enumeration( + acceptance["acceptedBy"], + ACCEPTED_BY, + "accepted by", + ); + if (kind === "user_commitment" && acceptedBy !== "user") + throw Error("user commitment requires user acceptance"); + const sourceRef = boundedId(acceptance["sourceRef"], "acceptance source ref"); + const acceptedAt = timestamp(acceptance["acceptedAt"], "accepted at"); + const history = list( + row["history"], + parseIntentionTransition, + "intention history", + ); + const recordRevision = revision(row["revision"], "intention revision"); + const status = enumeration( + row["status"], + INTENTION_STATUSES, + "intention status", + ); + if (history.length !== recordRevision) + throw Error("intention history revision mismatch"); + let previous: IntentionStatus = "proposed"; + let previousTime = Date.parse(acceptedAt); + for (const transition of history) { + if (transition.from !== previous) + throw Error("noncontiguous intention history"); + const time = Date.parse(transition.at); + if (time < previousTime) throw Error("nonchronological intention history"); + validateTransition(transition, sourceRef); + previous = transition.to; + previousTime = time; + } + if (previous !== status) throw Error("intention history status mismatch"); + return { + schemaVersion: 1, + intentionId, + agentId: boundedId(row["agentId"], "agent id"), + scopeId: boundedId(row["scopeId"], "scope id"), + revision: recordRevision, + kind, + purposeRef: boundedId(row["purposeRef"], "purpose ref"), + text: boundedText(row["text"], "intention text"), + acceptance: { + sourceRef, + acceptedBy, + policyRevision: revision( + acceptance["policyRevision"], + "acceptance policy revision", + ), + acceptedAt, + }, + priority: revision(row["priority"], "intention priority"), + deadline: + row["deadline"] === null + ? null + : timestamp(row["deadline"], "intention deadline"), + completionCondition: boundedText( + row["completionCondition"], + "completion condition", + ), + abortConditions: strings( + row["abortConditions"], + "abort conditions", + boundedText, + ), + relatedIntentions: uniqueSorted( + list( + row["relatedIntentions"], + (value) => { + const item = fields( + value, + ["intentionId", "relation"], + "related intention", + ); + const relatedId = boundedId( + item["intentionId"], + "related intention id", + ); + if (relatedId === intentionId) + throw Error("self-referential intention relation"); + return { + intentionId: relatedId, + relation: enumeration( + item["relation"], + INTENTION_RELATIONS, + "intention relation", + ), + }; + }, + "related intentions", + ), + (item) => JSON.stringify([item.intentionId, item.relation]), + "related intentions", + ), + status, + history, + }; +} + +export function transitionIntention( + record: IntentionRecord, + transition: Omit, +): IntentionRecord { + const source = parseIntentionRecord(record); + for (const key of Reflect.ownKeys(transition)) + if ( + typeof key !== "string" || + !["to", "reason", "evidenceRef", "at"].includes(key) + ) + throw Error("invalid intention transition"); + const entry = parseIntentionTransition({ + from: source.status, + to: transition.to, + reason: transition.reason, + evidenceRef: transition.evidenceRef, + at: transition.at, + }); + const previousTime = + source.history.at(-1)?.at ?? source.acceptance.acceptedAt; + if (Date.parse(entry.at) < Date.parse(previousTime)) + throw Error("nonchronological intention history"); + validateTransition(entry, source.acceptance.sourceRef); + // F1 restores history, but has no owner-verified user cancellation receipt. + if ( + source.kind === "user_commitment" && + source.status !== "proposed" && + entry.to === "cancelled" + ) + throw Error("user commitment cancellation authority is unavailable"); + if (source.history.length >= MAX_LIST) + throw Error("invalid intention history"); + return { + ...source, + revision: source.revision + 1, + status: entry.to, + history: [...source.history, entry], + }; +} diff --git a/packages/lina-core/src/agents/judgment.ts b/packages/lina-core/src/agents/judgment.ts new file mode 100644 index 0000000..39ccb9c --- /dev/null +++ b/packages/lina-core/src/agents/judgment.ts @@ -0,0 +1,223 @@ +export const MODULE_KINDS = ["clotho", "lachesis", "atropos"] as const; +export type ModuleKind = (typeof MODULE_KINDS)[number]; +export const STANCES = ["prefer", "accept", "oppose", "unavailable"] as const; +export type Stance = (typeof STANCES)[number]; +/** Initial personal.v1 vocabulary; detailed diagnosis belongs in evidence/text fields. */ +export const ASSESSMENT_UNAVAILABLE_REASONS = [ + "insufficient_evidence", +] as const; +export type AssessmentUnavailableReason = + (typeof ASSESSMENT_UNAVAILABLE_REASONS)[number]; +export const SEVERITIES = [ + "commitment_breach", + "infeasible", + "preference", +] as const; +export type Severity = (typeof SEVERITIES)[number]; +export const SITUATIONS = ["user_request", "autonomous", "transition"] as const; +export type Situation = (typeof SITUATIONS)[number]; +export const INTENTION_KINDS = [ + "user_commitment", + "autonomous_goal", + "task_binding", +] as const; +export type IntentionKind = (typeof INTENTION_KINDS)[number]; +export const INTENTION_STATUSES = [ + "proposed", + "adopted", + "active", + "suspended", + "completed", + "cancelled", +] as const; +export type IntentionStatus = (typeof INTENTION_STATUSES)[number]; +export const INTENTION_RELATIONS = [ + "depends", + "conflicts", + "supersedes", +] as const; +export type IntentionRelation = (typeof INTENTION_RELATIONS)[number]; +export const ACCEPTED_BY = ["user", "host_autonomy"] as const; +export type AcceptedBy = (typeof ACCEPTED_BY)[number]; +export const ROUND_STATUSES = ["open", "resolved", "deferred", "held"] as const; +export type RoundStatus = (typeof ROUND_STATUSES)[number]; +export const EXCLUSION_STAGES = [ + "host_eligibility", + "commitment_protection", + // Legacy receipt stage; current personal.v1 prerequisites use Host eligibility. + "infeasible", +] as const; +export type ExclusionStage = (typeof EXCLUSION_STAGES)[number]; + +export type ObjectiveProfile = { + schemaVersion: 1; + objectiveId: string; + moduleKind: ModuleKind; + revision: number; + objective: string; + comparisonCriteria: string[]; + reconsiderationConditions: string[]; +}; +export type ObjectiveProfileRef = { + objectiveId: string; + revision: number; + digest: string; +}; +export type SourceRef = { kind: string; id: string; revision: number }; +export type DialogueSourceRef = { + requestId: string; + requestDigest: string; + sourceDigest: string; +}; +export type JudgmentSnapshotRef = { + schemaVersion: 1; + roundId: string; + agentId: string; + scopeId: string; + sourceRefs: SourceRef[]; + /** Host source-owner digests frozen before judgment; required for dialogue. */ + dialogueSource?: DialogueSourceRef; + workingRevision: number; + instructionRevision: number; + policyId: string; + policyRevision: number; + identityRevision: number; + domainRevisions: Record; + intentionRevision: number; + objectiveProfileRefs: Record; + observationRef: string | null; + frozenNeuralRef: string | null; + situation: Situation; + clockId: string; + sequence: number; + bindingGeneration: number; +}; + +/** Opaque until produced by a versioned option catalog. */ +export type OptionKey = string; +/** Nested shape, versioned by Assessment. */ +export type OptionAssessment = { + optionKey: OptionKey; + stance: Stance; + severity: Severity | null; + unavailableReason: AssessmentUnavailableReason | null; + gain: string; + loss: string; + uncertainty: string; + evidenceRefs: string[]; + /** Absent/empty attribution cannot justify a hard veto or a targeted waiver. */ + breachedIntentionIds?: string[]; +}; +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | JsonObject; +export type JsonObject = { [key: string]: JsonValue }; +export type AssessmentDetail = { + kind: "forecasts" | "values" | "continuity"; + body: JsonObject; +}; +export type Assessment = { + schemaVersion: 1; + snapshotId: string; + snapshotDigest: string; + inputDigest: string; + objectiveRef: ObjectiveProfileRef; + mechanismRevision: number; + completeText: string; + evidenceRefs: string[]; + proposedOptionKeys: OptionKey[]; + objectiveAssessments: OptionAssessment[]; + recommendedOptionKeys: OptionKey[]; + diagnostics: JsonObject; +} & ( + | { moduleKind: "clotho"; detail: { kind: "forecasts"; body: JsonObject } } + | { moduleKind: "lachesis"; detail: { kind: "values"; body: JsonObject } } + | { moduleKind: "atropos"; detail: { kind: "continuity"; body: JsonObject } } +); +export type AssessmentSet = { + schemaVersion: 1; + roundId: string; + snapshotDigest: string; + assessments: Assessment[]; +}; +export type ResolutionRecord = { + schemaVersion: 1; + roundId: string; + policyId: string; + policyRevision: number; + situation: Situation; + order: ModuleKind[]; + recommendations: Record; + conflicts: Array<{ + optionKey: OptionKey; + stances: Record; + }>; + excluded: Array<{ + optionKey: OptionKey; + stage: ExclusionStage; + byModule: ModuleKind | null; + reason: string; + }>; + abstentions: Array<{ + optionKey: OptionKey; + moduleKind: ModuleKind; + reason: string; + }>; + ranking: Array<{ optionKey: OptionKey; rank: number }>; + conceded: Array<{ moduleKind: ModuleKind; optionKey: OptionKey }>; + status: Exclude; + holdReason: string | null; +}; +export type SelectionSpec = { + schemaVersion: 1; + roundId: string; + snapshotDigest: string; + assessmentSetDigest: string; + objectiveProfileRefs: Record; + resolutionDigest: string; + policyId: string; + policyRevision: number; + situation: Situation; + lambda: number; + candidates: Array<{ optionKey: OptionKey; p0: number; b: number | null }>; + eligibleDigest: string; + specDigest: string; +}; +/** Nested shape, versioned by IntentionRecord. */ +export type IntentionTransition = { + from: IntentionStatus; + to: IntentionStatus; + reason: string; + evidenceRef: string | null; + at: string; +}; +export type IntentionRecord = { + schemaVersion: 1; + intentionId: string; + agentId: string; + scopeId: string; + revision: number; + kind: IntentionKind; + purposeRef: string; + text: string; + acceptance: { + sourceRef: string; + acceptedBy: AcceptedBy; + policyRevision: number; + acceptedAt: string; + }; + priority: number; + deadline: string | null; + completionCondition: string; + abortConditions: string[]; + relatedIntentions: Array<{ + intentionId: string; + relation: IntentionRelation; + }>; + status: IntentionStatus; + history: IntentionTransition[]; +}; diff --git a/packages/lina-core/src/agents/persona-schema.ts b/packages/lina-core/src/agents/persona-schema.ts new file mode 100644 index 0000000..df9bcec --- /dev/null +++ b/packages/lina-core/src/agents/persona-schema.ts @@ -0,0 +1,377 @@ +import { createHash } from "node:crypto"; +import { lifeDigest } from "../world/life-json.ts"; +import type { + IdentityPolicySnapshot, + IdentityProfilePolicy, + LifeDefinition, +} from "../world/life-types.ts"; +import { + parseIdentityPolicy, + parseLifeDefinition, +} from "../world/life-validation.ts"; +import { knownAgents } from "../world/validation.ts"; +import { DIMENSION_SOURCES, type DimensionSource } from "./behavior-types.ts"; +import { boundedId } from "./validation.ts"; + +const SCHEMA_KEYS = [ + "schemaVersion", + "agentId", + "revision", + "sourceDefinition", + "sourceIdentity", + "dimensions", + "digest", +] as const; +const DIMENSION_KEYS = [ + "id", + "kind", + "label", + "source", + "range", + "initial", + "locked", + "originAxisId", +] as const; +const KIND_RANK = { trait: 0, habit: 1, attitude: 2 } as const; +const HASH = /^[a-f0-9]{64}$/; +// Labels are copied verbatim from LIFE axes, so the persona bound must equal +// the LIFE text ceiling (world/validation.ts MAX_TEXT), not the agent-text one. +const MAX_LABEL = 32_768; + +export type PersonaDimensionKind = "trait" | "habit" | "attitude"; + +export type PersonaDimension = { + id: string; + kind: PersonaDimensionKind; + label: string; + source: DimensionSource; + range: { min: number; max: number } | null; + initial: number | boolean; + locked: boolean; + originAxisId: string | null; +}; + +export type PersonaSchema = { + schemaVersion: 1; + agentId: string; + revision: number; + sourceDefinition: { + worldId: string; + definitionRevision: number; + definitionDigest: string; + } | null; + sourceIdentity: { profileRevision: number } | null; + dimensions: PersonaDimension[]; + digest: string; +}; + +function object(value: unknown, label: string): Record { + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) + throw Error("invalid " + label); + return value as Record; +} + +function exact( + value: Record, + keys: readonly string[], + label: string, +): void { + for (const key of Object.keys(value)) + if (!keys.includes(key)) throw Error(`unknown ${label} field ${key}`); + for (const key of keys) + if (!Object.hasOwn(value, key)) throw Error("invalid " + label); +} + +function revision(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) + throw Error("invalid " + label); + return value; +} + +function digestValue(value: unknown, label: string): string { + if (typeof value !== "string" || !HASH.test(value)) + throw Error("invalid " + label); + return value; +} + +function canonical(item: unknown): unknown { + if (Array.isArray(item)) return item.map(canonical); + if (item !== null && typeof item === "object") + return Object.fromEntries( + Object.keys(item) + .sort() + .map((key) => [key, canonical((item as Record)[key])]), + ); + return item; +} + +export function personaSchemaDigest( + schema: Omit, +): string { + return createHash("sha256") + .update(JSON.stringify(canonical(schema))) + .digest("hex"); +} + +function parseLabel(value: unknown): string { + if ( + typeof value !== "string" || + value.trim().length === 0 || + value.length > MAX_LABEL + ) + throw Error("invalid persona dimension label"); + return value; +} + +function parseKind(value: unknown): PersonaDimensionKind { + if (value === "trait" || value === "habit" || value === "attitude") + return value; + throw Error("invalid persona dimension kind"); +} + +function parseSource(value: unknown): DimensionSource { + for (const allowed of DIMENSION_SOURCES) + if (allowed === value) return allowed; + throw Error("invalid persona dimension source"); +} + +function parseRange(value: unknown): { min: number; max: number } { + const row = object(value, "persona dimension range"); + exact(row, ["min", "max"], "persona dimension range"); + const min = row["min"]; + const max = row["max"]; + if ( + typeof min !== "number" || + typeof max !== "number" || + !Number.isFinite(min) || + !Number.isFinite(max) || + min > max + ) + throw Error("invalid persona dimension range"); + return { min, max }; +} + +function parseDimension(value: unknown): PersonaDimension { + const row = object(value, "persona dimension"); + exact(row, DIMENSION_KEYS, "persona dimension"); + const kind = parseKind(row["kind"]); + const rangeValue = row["range"]; + const initial = row["initial"]; + if (kind === "habit") { + if (rangeValue !== null) throw Error("invalid persona dimension range"); + if (typeof initial !== "boolean") + throw Error("invalid persona dimension initial"); + if (typeof row["locked"] !== "boolean") + throw Error("invalid persona dimension locked"); + return { + id: boundedId(row["id"], "persona dimension id"), + kind, + label: parseLabel(row["label"]), + source: parseSource(row["source"]), + range: null, + initial, + locked: row["locked"], + originAxisId: + row["originAxisId"] === null + ? null + : boundedId(row["originAxisId"], "origin axis id"), + }; + } + if (rangeValue === null) throw Error("invalid persona dimension range"); + const range = parseRange(rangeValue); + if ( + typeof initial !== "number" || + !Number.isFinite(initial) || + initial < range.min || + initial > range.max + ) + throw Error("invalid persona dimension initial"); + if (typeof row["locked"] !== "boolean") + throw Error("invalid persona dimension locked"); + return { + id: boundedId(row["id"], "persona dimension id"), + kind, + label: parseLabel(row["label"]), + source: parseSource(row["source"]), + range, + initial: initial === 0 ? 0 : initial, + locked: row["locked"], + originAxisId: + row["originAxisId"] === null + ? null + : boundedId(row["originAxisId"], "origin axis id"), + }; +} + +function parseSourceDefinition( + value: unknown, +): PersonaSchema["sourceDefinition"] { + if (value === null) return null; + const row = object(value, "persona schema source definition"); + exact( + row, + ["worldId", "definitionRevision", "definitionDigest"], + "persona schema source definition", + ); + return { + worldId: boundedId(row["worldId"], "world id"), + definitionRevision: revision( + row["definitionRevision"], + "definition revision", + ), + definitionDigest: digestValue(row["definitionDigest"], "definition digest"), + }; +} + +function parseSourceIdentity(value: unknown): PersonaSchema["sourceIdentity"] { + if (value === null) return null; + const row = object(value, "persona schema source identity"); + exact(row, ["profileRevision"], "persona schema source identity"); + return { + profileRevision: revision(row["profileRevision"], "profile revision"), + }; +} + +export function parsePersonaSchema(value: unknown): PersonaSchema { + const row = object(value, "persona schema"); + if (row["schemaVersion"] !== 1) + throw Error("Unsupported persona schema version"); + exact(row, SCHEMA_KEYS, "persona schema"); + if (!Array.isArray(row["dimensions"])) + throw Error("invalid persona schema dimensions"); + const dimensions = row["dimensions"].map(parseDimension); + const ids = new Set(); + let previous: PersonaDimension | undefined; + for (const dimension of dimensions) { + if (ids.has(dimension.id)) throw Error("duplicate persona dimension id"); + ids.add(dimension.id); + if (previous !== undefined && compareDimensions(previous, dimension) >= 0) + throw Error("unsorted persona dimensions"); + previous = dimension; + } + const parsed: Omit = { + schemaVersion: 1, + agentId: boundedId(row["agentId"], "agent id"), + revision: revision(row["revision"], "persona schema revision"), + sourceDefinition: parseSourceDefinition(row["sourceDefinition"]), + sourceIdentity: parseSourceIdentity(row["sourceIdentity"]), + dimensions, + }; + const digest = digestValue(row["digest"], "persona schema digest"); + if (digest !== personaSchemaDigest(parsed)) + throw Error("persona schema digest mismatch"); + return { ...parsed, digest }; +} + +function compareDimensions(a: PersonaDimension, b: PersonaDimension): number { + const delta = KIND_RANK[a.kind] - KIND_RANK[b.kind]; + if (delta !== 0) return delta; + if (a.id < b.id) return -1; + if (a.id > b.id) return 1; + return 0; +} + +function profileFor( + identity: IdentityPolicySnapshot | null, + agentId: string, +): IdentityProfilePolicy | null { + if (identity === null) return null; + for (const profile of parseIdentityPolicy(identity).profiles) + if (profile.agentId === agentId) return profile; + return null; +} + +function lockedFor( + policy: IdentityProfilePolicy | null, + kind: PersonaDimensionKind, + id: string, +): boolean { + if (policy === null) return false; + if (policy.evolution === "manual") return true; + if (kind === "trait") return policy.lockedTraitIds.includes(id); + if (kind === "habit") return policy.lockedHabitIds.includes(id); + return policy.lockedAttitudeIds.includes(id); +} + +export function personaSchemaFromLifeDefinition(input: { + agentId: string; + revision: number; + definition: LifeDefinition; + identity: IdentityPolicySnapshot | null; +}): PersonaSchema { + const agentId = boundedId(input.agentId, "agent id"); + const schemaRevision = revision(input.revision, "persona schema revision"); + const definition = parseLifeDefinition(input.definition); + knownAgents([agentId], definition.participants); + const policy = profileFor(input.identity, agentId); + const ids = new Set(); + for (const dimension of [ + ...definition.traits, + ...definition.habits, + ...definition.attitudes, + ]) { + if (ids.has(dimension.id)) throw Error("duplicate persona dimension id"); + ids.add(dimension.id); + } + const dimensions: PersonaDimension[] = [ + ...definition.traits + .filter((axis) => definition.projection.sharedTraitIds.includes(axis.id)) + .map((axis) => ({ + id: axis.id, + kind: "trait" as const, + label: axis.label, + source: "reflection" as const, + range: { min: axis.min, max: axis.max }, + initial: axis.initial, + locked: lockedFor(policy, "trait", axis.id), + originAxisId: axis.id, + })), + ...definition.habits + .filter((habit) => + definition.projection.sharedHabitIds.includes(habit.id), + ) + .map((habit) => ({ + id: habit.id, + kind: "habit" as const, + label: habit.label, + source: "reflection" as const, + range: null, + initial: habit.initial, + locked: lockedFor(policy, "habit", habit.id), + originAxisId: habit.id, + })), + ...definition.attitudes + .filter((axis) => + definition.projection.sharedAttitudeIds.includes(axis.id), + ) + .map((axis) => ({ + id: axis.id, + kind: "attitude" as const, + label: axis.label, + source: "reflection" as const, + range: { min: axis.min, max: axis.max }, + initial: axis.initial, + locked: lockedFor(policy, "attitude", axis.id), + originAxisId: axis.id, + })), + ].sort(compareDimensions); + const parsed: Omit = { + schemaVersion: 1, + agentId, + revision: schemaRevision, + sourceDefinition: { + worldId: definition.worldId, + definitionRevision: definition.revision, + definitionDigest: lifeDigest(definition), + }, + sourceIdentity: + policy === null ? null : { profileRevision: policy.profileRevision }, + dimensions, + }; + return { ...parsed, digest: personaSchemaDigest(parsed) }; +} diff --git a/packages/lina-core/src/context/index.ts b/packages/lina-core/src/context/index.ts index b2e1f35..2fdef65 100644 --- a/packages/lina-core/src/context/index.ts +++ b/packages/lina-core/src/context/index.ts @@ -2,6 +2,18 @@ export { archiveText } from "./archive.ts"; export type { SummaryGeneration } from "./generation.ts"; export { CONTEXT_SCHEMA_VERSION } from "./schema.ts"; export { ContextStore } from "./store.ts"; + +// Context read projection (F1-E1). + +export type { + ContextReadProjection, + InstructionRef, +} from "./read-projection.ts"; +export { + buildContextReadProjection, + parseContextReadProjection, +} from "./read-projection.ts"; + export type { ActivateInput, ActiveSummary, diff --git a/packages/lina-core/src/context/read-projection.ts b/packages/lina-core/src/context/read-projection.ts new file mode 100644 index 0000000..d839471 --- /dev/null +++ b/packages/lina-core/src/context/read-projection.ts @@ -0,0 +1,302 @@ +/** + * Deviation note: 017 L65 says "MODIFY core `context/types.ts`의 읽기 투영 계약". + * This plan satisfies that contract with a NEW sibling module re-exported from + * `context/index.ts` because `context-wire.ts:118-162` re-projects `WorkingState` + * field-by-field and `context.test.ts` asserts exact shapes — adding fields to + * `WorkingState` would silently drop on the wire and churn the row codec. + * Same public contract, different file; movable later without changing callers. + */ + +import { createHash } from "node:crypto"; +import { + WORKING_GOAL_MAX_CHARS, + WORKING_ITEM_MAX_CHARS, + WORKING_LIST_MAX_ITEMS, + WORKING_SOURCES_MAX, + type WorkingState, +} from "./types.ts"; +import { validId } from "./validation.ts"; + +/** Identifies the instruction whose text was last projected. */ +export interface InstructionRef { + requestId: string; + entryId: string; + /** sha256 hex of the instruction text. */ + textDigest: string; +} + +/** Read-only view separating "what the user asked" from "what the agent is working on". */ +export interface ContextReadProjection { + schemaVersion: 1; + workingRevision: number; + instructionRevision: number; + instruction: InstructionRef | null; + working: WorkingState; + projectedAt: string; +} + +const ALLOWED_KEYS = new Set([ + "schemaVersion", + "workingRevision", + "instructionRevision", + "instruction", + "working", + "projectedAt", +]); + +const WORKING_KEYS = new Set([ + "revision", + "goal", + "decisions", + "openItems", + "nextSteps", + "sourceEntryIds", +]); + +function sha256Hex(text: string): string { + return createHash("sha256").update(text, "utf8").digest("hex"); +} + +const ISO_8601_PATTERN = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,3})?(?:Z|[+-](\d{2}):(\d{2}))$/; + +function daysInMonth(year: number, month: number): number { + const isLeap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + if (month === 2) return isLeap ? 29 : 28; + if (month === 4 || month === 6 || month === 9 || month === 11) return 30; + return 31; +} + +function isIso8601(value: string): boolean { + const match = ISO_8601_PATTERN.exec(value); + if (!match) return false; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + const offsetHour = match[7] ? Number(match[7]) : 0; + const offsetMinute = match[8] ? Number(match[8]) : 0; + + if (month < 1 || month > 12) return false; + if (day < 1 || day > daysInMonth(year, month)) return false; + if (hour > 23) return false; + if (minute > 59) return false; + if (second > 59) return false; + if (offsetHour > 23) return false; + if (offsetMinute > 59) return false; + + return Number.isFinite(Date.parse(value)); +} + +function reject(message: string): never { + throw new Error(message); +} + +function parseBoundedStringList(value: unknown, field: string): string[] { + if (!Array.isArray(value)) reject(`invalid working ${field}`); + if (value.length > WORKING_LIST_MAX_ITEMS) reject(`invalid working ${field}`); + for (const item of value) { + if (typeof item !== "string" || item.trim().length === 0) + reject(`invalid working ${field}`); + if (item.length > WORKING_ITEM_MAX_CHARS) + reject(`invalid working ${field}`); + } + return [...(value as string[])]; +} + +function parseWorking(value: unknown): WorkingState { + if (typeof value !== "object" || value === null || Array.isArray(value)) + reject("invalid context read projection working"); + const obj = value as Partial>; + for (const key of Object.keys(obj)) { + if (!WORKING_KEYS.has(key)) + reject(`unknown context read projection working field ${key}`); + } + const revision = obj.revision; + if (!Number.isSafeInteger(revision) || (revision as number) < 0) + reject("invalid context read projection working revision"); + const goal = obj.goal; + if (typeof goal !== "string" || goal.length > WORKING_GOAL_MAX_CHARS) + reject("invalid context read projection working goal"); + return { + revision: revision as number, + goal, + decisions: parseBoundedStringList(obj.decisions, "decisions"), + openItems: parseBoundedStringList(obj.openItems, "openItems"), + nextSteps: parseBoundedStringList(obj.nextSteps, "nextSteps"), + sourceEntryIds: parseSourceEntryIds(obj.sourceEntryIds), + }; +} + +function parseSourceEntryIds(value: unknown): string[] { + if (!Array.isArray(value)) reject("invalid working sourceEntryIds"); + if (value.length > WORKING_SOURCES_MAX) + reject("invalid working sourceEntryIds"); + const ids = value.map((item) => validId(item, "working sourceEntryIds")); + if (new Set(ids).size !== ids.length) + reject("working sourceEntryIds must be unique"); + return ids; +} + +function parseInstructionRef(value: unknown): InstructionRef { + if (typeof value !== "object" || value === null || Array.isArray(value)) + reject("invalid context read projection instruction"); + const obj = value as Partial>; + for (const key of Object.keys(obj)) { + if (!["requestId", "entryId", "textDigest"].includes(key)) + reject(`unknown context read projection instruction field ${key}`); + } + const requestId = validId( + obj.requestId, + "context read projection instruction requestId", + ); + const entryId = validId( + obj.entryId, + "context read projection instruction entryId", + ); + const textDigest = obj.textDigest; + if (typeof textDigest !== "string" || !/^[0-9a-f]{64}$/.test(textDigest)) + reject("invalid context read projection instruction textDigest"); + return { requestId, entryId, textDigest }; +} + +function sameInstruction( + a: InstructionRef | null, + b: InstructionRef | null, +): boolean { + if (a === null && b === null) return true; + if (a === null || b === null) return false; + return ( + a.requestId === b.requestId && + a.entryId === b.entryId && + a.textDigest === b.textDigest + ); +} + +function copyWorking(working: WorkingState): WorkingState { + return { + revision: working.revision, + goal: working.goal, + decisions: [...working.decisions], + openItems: [...working.openItems], + nextSteps: [...working.nextSteps], + sourceEntryIds: [...working.sourceEntryIds], + }; +} + +function validateInput( + working: WorkingState, + projectedAt: string, + instruction: { requestId: string; entryId: string; text: string } | null, +): void { + // Validate working against the same bounds the store uses. + try { + parseWorking(working); + if (instruction !== null) { + validId(instruction.requestId, "instruction requestId"); + validId(instruction.entryId, "instruction entryId"); + } + } catch { + reject("invalid context read projection input"); + } + if (typeof projectedAt !== "string" || !isIso8601(projectedAt)) + reject("invalid context read projection input"); +} + +/** + * Builds a read projection from a working state and an optional instruction. + * `workingRevision` is always taken from `working.revision`; `instructionRevision` + * advances whenever the instruction identity changes. + */ +export function buildContextReadProjection(input: { + working: WorkingState; + instruction: { requestId: string; entryId: string; text: string } | null; + previous: ContextReadProjection | null; + projectedAt: string; +}): ContextReadProjection { + const { working, instruction, projectedAt } = input; + const previous = + input.previous === null ? null : parseContextReadProjection(input.previous); + validateInput(working, projectedAt, instruction); + + let instructionRef: InstructionRef | null = null; + if (instruction !== null) { + instructionRef = { + requestId: instruction.requestId, + entryId: instruction.entryId, + textDigest: sha256Hex(instruction.text), + }; + } + let instructionRevision = instructionRef === null ? 0 : 1; + if (previous !== null) + instructionRevision = + previous.instructionRevision + + (sameInstruction(previous.instruction, instructionRef) ? 0 : 1); + if (!Number.isSafeInteger(instructionRevision)) + reject("invalid context read projection instructionRevision"); + + return { + schemaVersion: 1, + workingRevision: working.revision, + instructionRevision, + instruction: instructionRef, + working: copyWorking(working), + projectedAt, + }; +} + +/** Parses and validates a serialized read projection. */ +export function parseContextReadProjection( + value: unknown, +): ContextReadProjection { + if (typeof value !== "object" || value === null || Array.isArray(value)) + reject("invalid context read projection"); + const obj = value as Partial>; + + const schemaVersion = obj.schemaVersion; + if (schemaVersion !== 1) + reject("Unsupported context read projection schema version"); + + for (const key of Object.keys(obj)) { + if (!ALLOWED_KEYS.has(key)) + reject(`unknown context read projection field ${key}`); + } + + const workingRevision = obj.workingRevision; + if (!Number.isSafeInteger(workingRevision) || (workingRevision as number) < 0) + reject("invalid context read projection workingRevision"); + + const instructionRevision = obj.instructionRevision; + if ( + !Number.isSafeInteger(instructionRevision) || + (instructionRevision as number) < 0 + ) + reject("invalid context read projection instructionRevision"); + + const instructionRaw = obj.instruction; + if (instructionRaw !== null && instructionRaw !== undefined) + parseInstructionRef(instructionRaw); + + if ((instructionRevision as number) === 0 && instructionRaw !== null) + reject("invalid context read projection instructionRevision"); + + const projectedAt = obj.projectedAt; + if (typeof projectedAt !== "string" || !isIso8601(projectedAt)) + reject("invalid context read projection projectedAt"); + + const working = parseWorking(obj.working); + if (working.revision !== workingRevision) + reject("invalid context read projection workingRevision"); + + return { + schemaVersion: 1, + workingRevision: workingRevision as number, + instructionRevision: instructionRevision as number, + instruction: + instructionRaw === null ? null : parseInstructionRef(instructionRaw), + working, + projectedAt, + }; +} diff --git a/packages/lina-core/test/attachments-image-quota.test.ts b/packages/lina-core/test/attachments-image-quota.test.ts index ae3b67f..f69d36a 100644 --- a/packages/lina-core/test/attachments-image-quota.test.ts +++ b/packages/lina-core/test/attachments-image-quota.test.ts @@ -1,4 +1,5 @@ import { afterEach, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; import { mkdirSync, mkdtempSync, @@ -8,12 +9,14 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { atomicJson } from "../src/attachments/filesystem.ts"; import { ATTACHMENT_MAX_BYTES, ATTACHMENT_MAX_FILES, ATTACHMENT_MAX_TOTAL_BYTES, AttachmentStore, } from "../src/attachments/store.ts"; +import type { AttachmentManifest } from "../src/attachments/types.ts"; const roots: string[] = []; afterEach(() => { @@ -43,12 +46,30 @@ test.each(["files", "bytes"] as const)( ? ATTACHMENT_MAX_TOTAL_BYTES / bytes.length : ATTACHMENT_MAX_FILES; const store = new AttachmentStore(root, binding); - for (let index = 0; index < limit - 1; index++) - store.put(`saved-${index}.txt`, bytes); + const seed = store.put("saved-0.txt", bytes); store.close(); + // Persist the inert population once, using a real upload's metadata. + // Reopen still validates every file; adoption and replay use the real store. + const files = [seed]; + for (let index = 1; index < limit - 1; index++) { + const saved = { + ...seed, + id: randomUUID(), + name: `saved-${index}.txt`, + }; + writeFileSync(join(root, "attachments/files", saved.id), bytes); + files.push(saved); + } + atomicJson(join(root, "attachments/manifest.json"), { + version: 1, + binding, + files, + totalBytes: bytes.length * files.length, + } satisfies AttachmentManifest); const id = "33333333-3333-4333-8333-333333333333"; writeFileSync(join(root, "attachments/files", id), bytes); const recovered = new AttachmentStore(root, binding); + expect(() => recovered.get(id)).toThrow(/not found/); expect(() => recovered.preflight(1)).toThrow(/quota/); expect(() => recovered.put("overflow.txt", new TextEncoder().encode("x")), @@ -63,6 +84,7 @@ test.each(["files", "bytes"] as const)( const reopened = new AttachmentStore(root, binding); expect(() => reopened.preflight(1)).toThrow(/quota/); expect(reopened.put("result.txt", bytes, id)).toEqual(receipt); + expect(reopened.bytes(id)).toEqual(bytes); expect(() => reopened.put("overflow.txt", new TextEncoder().encode("x")), ).toThrow(/quota/); @@ -70,6 +92,7 @@ test.each(["files", "bytes"] as const)( readFileSync(join(root, "attachments/manifest.json"), "utf8"), ); expect(manifest.files).toHaveLength(limit); + expect(manifest.files).toEqual([...files, receipt]); expect(manifest.totalBytes).toBe(bytes.length * limit); reopened.close(); }, diff --git a/packages/lina-core/test/context-read-projection.test.ts b/packages/lina-core/test/context-read-projection.test.ts new file mode 100644 index 0000000..a4e1b60 --- /dev/null +++ b/packages/lina-core/test/context-read-projection.test.ts @@ -0,0 +1,541 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { join } from "node:path"; +import { + buildContextReadProjection, + type ContextReadProjection, + type InstructionRef, + parseContextReadProjection, + WORKING_GOAL_MAX_CHARS, + WORKING_ITEM_MAX_CHARS, + WORKING_LIST_MAX_ITEMS, + WORKING_SOURCES_MAX, + type WorkingState, +} from "../src/context/index.ts"; +import { ContextStore } from "../src/context/store.ts"; +import { appendContextEntry } from "./context-journal-fixture.ts"; +import { entry, Fixture } from "./fixture.ts"; + +describe("ContextReadProjection", () => { + let fixture: Fixture; + let file: string; + let store: ContextStore; + + const openStore = () => { + const durable = fixture.store(); + return fixture.keep( + new ContextStore(file, fixture.binding, (id) => durable.sourceEntry(id), { + lookupRequest: (id) => + durable.sourceEntry(durable.request(id)?.entryId ?? ""), + }), + ); + }; + + beforeEach(() => { + fixture = new Fixture(); + file = join(fixture.dir, "context.sqlite"); + store = openStore(); + }); + + afterEach(() => fixture.close()); + + function makeInstruction( + entryId: string, + text: string, + ): { + requestId: string; + entryId: string; + text: string; + } { + const requestId = appendContextEntry( + fixture.store(), + fixture.binding.sessionId, + entry(entryId, { role: "user", text }), + ); + return { requestId, entryId, text }; + } + + it("increments workingRevision when store working changes, keeps instructionRevision stable", () => { + const instruction = makeInstruction("i1", "hello"); + const requestId = instruction.requestId; + const projectedAt = "2026-09-10T00:00:00.000Z"; + const working = store.working(); + const p0 = buildContextReadProjection({ + working, + instruction, + previous: null, + projectedAt, + }); + expect(p0.workingRevision).toBe(working.revision); + expect(p0.instructionRevision).toBe(1); + + const updated = store.updateWorking( + p0.workingRevision, + { goal: "g2" }, + { activeRequestId: requestId }, + ); + const p1 = buildContextReadProjection({ + working: updated, + instruction, + previous: p0, + projectedAt, + }); + expect(p1.workingRevision).toBe(p0.workingRevision + 1); + expect(p1.instructionRevision).toBe(p0.instructionRevision); + }); + + it("increments instructionRevision when entryId changes, keeps workingRevision stable", () => { + const instruction1 = makeInstruction("i1", "hello"); + const projectedAt = "2026-09-10T00:00:00.000Z"; + const working = store.working(); + const p0 = buildContextReadProjection({ + working, + instruction: instruction1, + previous: null, + projectedAt, + }); + const instruction2 = makeInstruction("i2", "hello"); + const p1 = buildContextReadProjection({ + working, + instruction: instruction2, + previous: p0, + projectedAt, + }); + expect(p1.instructionRevision).toBe(p0.instructionRevision + 1); + expect(p1.workingRevision).toBe(p0.workingRevision); + }); + + it("rejects instruction revision overflow but retains the maximum revision for unchanged input", () => { + const input = { + working: store.working(), + instruction: makeInstruction("overflow-entry", "original"), + previous: null, + projectedAt: "2026-09-12T00:00:00.000Z", + }; + const previous = { + ...buildContextReadProjection(input), + instructionRevision: Number.MAX_SAFE_INTEGER, + }; + expect(parseContextReadProjection(previous)).toEqual(previous); + const unchanged = buildContextReadProjection({ ...input, previous }); + expect(unchanged).toEqual(previous); + expect(() => + buildContextReadProjection({ + ...input, + instruction: { ...input.instruction, text: "changed" }, + previous, + }), + ).toThrow(/instructionRevision/); + }); + + it.each([-1, 0])( + "rejects malformed previous instruction revision %s before comparing instructions", + (instructionRevision) => { + const input = { + working: store.working(), + instruction: makeInstruction("previous-entry", "same"), + previous: null, + projectedAt: "2026-09-12T00:00:00.000Z", + }; + const previous = { + ...buildContextReadProjection(input), + instructionRevision, + }; + for (const text of ["same", "changed"]) + expect(() => + buildContextReadProjection({ + ...input, + instruction: { ...input.instruction, text }, + previous, + }), + ).toThrow(/instructionRevision/); + }, + ); + + it("increments instructionRevision when text changes for the same entry", () => { + const instruction1 = makeInstruction("i1", "hello"); + const projectedAt = "2026-09-10T00:00:00.000Z"; + const working = store.working(); + const p0 = buildContextReadProjection({ + working, + instruction: instruction1, + previous: null, + projectedAt, + }); + const instruction2 = { ...instruction1, text: "world" }; + const p1 = buildContextReadProjection({ + working, + instruction: instruction2, + previous: p0, + projectedAt, + }); + expect(p1.instructionRevision).toBe(p0.instructionRevision + 1); + expect(p1.workingRevision).toBe(p0.workingRevision); + }); + + it("starts instructionRevision at 0 when there is no instruction and no previous", () => { + const working = store.working(); + const projectedAt = "2026-09-10T00:00:00.000Z"; + const p = buildContextReadProjection({ + working, + instruction: null, + previous: null, + projectedAt, + }); + expect(p.instructionRevision).toBe(0); + expect(p.instruction).toBeNull(); + }); + + it("copies working state so later mutation of the source arrays cannot leak in", () => { + const working = store.working(); + const projectedAt = "2026-09-10T00:00:00.000Z"; + const p = buildContextReadProjection({ + working, + instruction: null, + previous: null, + projectedAt, + }); + working.decisions.push("mutation"); + working.openItems.push("mutation"); + working.nextSteps.push("mutation"); + working.sourceEntryIds.push("mutation"); + expect(p.working.decisions).not.toContain("mutation"); + expect(p.working.openItems).not.toContain("mutation"); + expect(p.working.nextSteps).not.toContain("mutation"); + expect(p.working.sourceEntryIds).not.toContain("mutation"); + }); + + it("round-trips a golden projection through parseContextReadProjection byte-identically", () => { + const working: WorkingState = { + revision: 3, + goal: "g", + decisions: ["d1"], + openItems: ["o1"], + nextSteps: ["n1"], + sourceEntryIds: ["s1"], + }; + const instruction: InstructionRef = { + requestId: "req-1", + entryId: "e1", + textDigest: + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + }; + const golden: ContextReadProjection = { + schemaVersion: 1, + workingRevision: 3, + instructionRevision: 2, + instruction, + working, + projectedAt: "2026-09-10T00:00:00.000Z", + }; + const parsed = parseContextReadProjection(golden); + expect(JSON.stringify(parsed)).toBe(JSON.stringify(golden)); + }); + + it("rejects unsupported schemaVersion, workingRevision mismatch, and unknown fields", () => { + const working: WorkingState = { + revision: 3, + goal: "g", + decisions: [], + openItems: [], + nextSteps: [], + sourceEntryIds: [], + }; + const base = { + schemaVersion: 1, + workingRevision: 3, + instructionRevision: 0, + instruction: null, + working, + projectedAt: "2026-09-10T00:00:00.000Z", + }; + expect(() => + parseContextReadProjection({ ...base, schemaVersion: 2 }), + ).toThrow(/Unsupported context read projection schema version/); + expect(() => + parseContextReadProjection({ ...base, workingRevision: 4 }), + ).toThrow(/invalid context read projection workingRevision/); + expect(() => + parseContextReadProjection({ ...base, unknownField: true }), + ).toThrow(/unknown context read projection field unknownField/); + }); + + it("rejects instruction non-null when instructionRevision is zero", () => { + const working: WorkingState = { + revision: 3, + goal: "g", + decisions: [], + openItems: [], + nextSteps: [], + sourceEntryIds: [], + }; + const instruction: InstructionRef = { + requestId: "req-1", + entryId: "e1", + textDigest: + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + }; + expect(() => + parseContextReadProjection({ + schemaVersion: 1, + workingRevision: 3, + instructionRevision: 0, + instruction, + working, + projectedAt: "2026-09-10T00:00:00.000Z", + }), + ).toThrow(/invalid context read projection instructionRevision/); + }); + + it.each(["requestId", "entryId"] as const)( + "rejects invalid instruction %s in both builder and restored state", + (field) => { + const input = { + working: store.working(), + instruction: makeInstruction("bounded-entry", "hello"), + previous: null, + projectedAt: "2026-09-12T00:00:00.000Z", + }; + const projection = buildContextReadProjection(input); + for (const id of ["entry\0hidden", "x".repeat(100_000)]) { + expect(() => + buildContextReadProjection({ + ...input, + instruction: { ...input.instruction, [field]: id }, + }), + ).toThrow(/invalid context read projection/); + expect(() => + parseContextReadProjection({ + ...projection, + instruction: { ...projection.instruction, [field]: id }, + }), + ).toThrow(/invalid context read projection/); + } + }, + ); + + it.each([ + [""], + [" "], + ["entry\0id"], + ["x".repeat(100_000)], + ["entry-1", "entry-1"], + ])("rejects invalid restored source identifiers: %j", (...sourceEntryIds) => { + const projection = buildContextReadProjection({ + working: store.working(), + instruction: null, + previous: null, + projectedAt: "2026-09-12T00:00:00.000Z", + }); + expect(() => + parseContextReadProjection({ + ...projection, + working: { ...projection.working, sourceEntryIds }, + }), + ).toThrow(/sourceEntryIds/); + }); + + it.each(["bogus", "a".repeat(63), "a".repeat(65), "g".repeat(64)])( + "rejects invalid restored instruction digest: %s", + (textDigest) => { + const projection = buildContextReadProjection({ + working: store.working(), + instruction: makeInstruction("digest-entry", "hello"), + previous: null, + projectedAt: "2026-09-12T00:00:00.000Z", + }); + expect(() => + parseContextReadProjection({ + ...projection, + instruction: { ...projection.instruction, textDigest }, + }), + ).toThrow(/instruction textDigest/); + }, + ); + + it.each(["decisions", "openItems", "nextSteps"] as const)( + "rejects blank restored working %s", + (field) => { + const projection = buildContextReadProjection({ + working: store.working(), + instruction: null, + previous: null, + projectedAt: "2026-09-12T00:00:00.000Z", + }); + for (const item of ["", " \n\t "]) + expect(() => + parseContextReadProjection({ + ...projection, + working: { ...projection.working, [field]: [item] }, + }), + ).toThrow(/invalid working/); + }, + ); + + it("rejects non-ISO projectedAt in builder and parser", () => { + const working = store.working(); + const instruction = makeInstruction("i1", "hello"); + expect(() => + buildContextReadProjection({ + working, + instruction, + previous: null, + projectedAt: "September 10, 2026", + }), + ).toThrow(/invalid context read projection input/); + expect(() => + parseContextReadProjection({ + schemaVersion: 1, + workingRevision: working.revision, + instructionRevision: 1, + instruction: null, + working, + projectedAt: "September 10, 2026", + }), + ).toThrow(/invalid context read projection projectedAt/); + const iso = "2026-09-11T00:00:00.000Z"; + expect(() => + buildContextReadProjection({ + working, + instruction, + previous: null, + projectedAt: iso, + }), + ).not.toThrow(); + expect(() => + parseContextReadProjection({ + schemaVersion: 1, + workingRevision: working.revision, + instructionRevision: 1, + instruction: null, + working, + projectedAt: iso, + }), + ).not.toThrow(); + }); + + it("builder validates working state against the same bounds as the store", () => { + const instruction = makeInstruction("i1", "hello"); + const projectedAt = "2026-09-10T00:00:00.000Z"; + const base = store.working(); + expect(() => + buildContextReadProjection({ + working: { ...base, goal: "x".repeat(WORKING_GOAL_MAX_CHARS + 1) }, + instruction, + previous: null, + projectedAt, + }), + ).toThrow(/invalid context read projection input/); + expect(() => + buildContextReadProjection({ + working: { + ...base, + decisions: Array.from( + { length: WORKING_LIST_MAX_ITEMS + 1 }, + (_, i) => `d${i}`, + ), + }, + instruction, + previous: null, + projectedAt, + }), + ).toThrow(/invalid context read projection input/); + expect(() => + buildContextReadProjection({ + working: { + ...base, + openItems: ["x".repeat(WORKING_ITEM_MAX_CHARS + 1)], + }, + instruction, + previous: null, + projectedAt, + }), + ).toThrow(/invalid context read projection input/); + expect(() => + buildContextReadProjection({ + working: { + ...base, + sourceEntryIds: Array.from( + { length: WORKING_SOURCES_MAX + 1 }, + (_, i) => `s${i}`, + ), + }, + instruction, + previous: null, + projectedAt, + }), + ).toThrow(/invalid context read projection input/); + }); + + it("builder output round-trips through parseContextReadProjection", () => { + const instruction = makeInstruction("i1", "hello"); + const projectedAt = "2026-09-10T00:00:00.000Z"; + const built = buildContextReadProjection({ + working: store.working(), + instruction, + previous: null, + projectedAt, + }); + const roundTripped = parseContextReadProjection( + JSON.parse(JSON.stringify(built)), + ); + expect(JSON.stringify(roundTripped)).toBe(JSON.stringify(built)); + }); + + it("isIso8601 rejects impossible calendar dates and invalid time/offset components", () => { + const instruction = makeInstruction("i1", "hello"); + const working = store.working(); + + const rejected = [ + "2026-02-30T00:00:00.000Z", + "2026-04-31T00:00:00.000Z", + "2023-02-29T00:00:00.000Z", + "2026-13-01T00:00:00.000Z", + "2026-00-10T00:00:00.000Z", + "2026-09-00T00:00:00.000Z", + "2026-09-10T24:00:00.000Z", + "2026-09-10T23:60:00.000Z", + "2026-09-10T23:59:60.000Z", + "2026-09-10T00:00:00.000+24:00", + ]; + for (const projectedAt of rejected) { + expect(() => + buildContextReadProjection({ + working, + instruction, + previous: null, + projectedAt, + }), + ).toThrow(/invalid context read projection input/); + expect(() => + parseContextReadProjection({ + schemaVersion: 1, + workingRevision: working.revision, + instructionRevision: 1, + instruction: null, + working, + projectedAt, + }), + ).toThrow(/invalid context read projection projectedAt/); + } + + const accepted = [ + "2024-02-29T00:00:00.000Z", + "2000-02-29T00:00:00.000Z", + "2026-12-31T23:59:59.999Z", + "2026-09-10T00:00:00+09:00", + ]; + for (const projectedAt of accepted) { + const built = buildContextReadProjection({ + working, + instruction, + previous: null, + projectedAt, + }); + expect(built.projectedAt).toBe(projectedAt); + const roundTripped = parseContextReadProjection( + JSON.parse(JSON.stringify(built)), + ); + expect(roundTripped.projectedAt).toBe(projectedAt); + } + }); +}); diff --git a/packages/lina-core/test/judgment-cancellation-authority.test.ts b/packages/lina-core/test/judgment-cancellation-authority.test.ts new file mode 100644 index 0000000..9110226 --- /dev/null +++ b/packages/lina-core/test/judgment-cancellation-authority.test.ts @@ -0,0 +1,211 @@ +import { expect, test } from "bun:test"; +import { DatabaseSync } from "node:sqlite"; +import { + type IntentionRecord, + intentionDigest, + JudgmentStore, + parseIntentionRecord, + resolvePersonalRound, + transitionIntention, +} from "../src/agents/index.ts"; +import { canonicalJson } from "../src/agents/judgment-validation.ts"; +import { + policyEvidenceFixture, + policyOption, +} from "./judgment-policy-evidence-fixture.ts"; + +const paths = { + adopted: [], + active: ["active"], + suspended: ["active", "suspended"], +} as const; + +function advance( + f: ReturnType, + status: keyof typeof paths, +): IntentionRecord { + let source = f.adopted; + for (const to of paths[status]) + source = f.store.transitionIntention( + source.intentionId, + { + to, + reason: "prepare lifecycle", + evidenceRef: source.acceptance.sourceRef, + at: source.acceptance.acceptedAt, + }, + source.revision, + ); + return source; +} + +function cancellation(source: IntentionRecord) { + return { + to: "cancelled" as const, + reason: "new interest without a cancellation request", + evidenceRef: source.acceptance.sourceRef, + at: source.acceptance.acceptedAt, + }; +} + +for (const status of ["adopted", "active", "suspended"] as const) { + for (const boundary of ["helper", "store"] as const) + test(`3999164940 ${boundary} rejects acceptance-only cancellation from ${status}`, () => { + const f = policyEvidenceFixture(); + try { + const source = advance(f, status); + const before = structuredClone(source); + const revision = f.store.intentionRevision( + source.agentId, + source.scopeId, + ); + expect(() => + boundary === "helper" + ? transitionIntention(source, cancellation(source)) + : f.store.transitionIntention( + source.intentionId, + cancellation(source), + source.revision, + ), + ).toThrow("user commitment cancellation authority is unavailable"); + expect(source).toEqual(before); + const reopened = f.fixture.keep(new JudgmentStore(f.path)); + expect(reopened.getIntention(source.intentionId)).toEqual(before); + expect(reopened.intentionRevision(source.agentId, source.scopeId)).toBe( + revision, + ); + } finally { + f.fixture.close(); + } + }); + + test(`3999164940 historical cancellation from ${status} remains readable but cannot be inserted`, () => { + const f = policyEvidenceFixture(); + try { + const source = advance(f, status); + const historical: IntentionRecord = { + ...source, + status: "cancelled", + revision: source.revision + 1, + history: [ + ...source.history, + { ...cancellation(source), from: source.status }, + ], + }; + expect(parseIntentionRecord(historical)).toEqual(historical); + expect(() => f.store.putIntention(historical)).toThrow( + "intention must be proposed", + ); + // Reconstruct a pre-authority F1 ledger, not a newly authorized command. + const db = new DatabaseSync(f.path); + try { + db.prepare( + "UPDATE intention_records SET revision = ?, status = ?, digest = ?, body = ? WHERE intention_id = ?", + ).run( + historical.revision, + historical.status, + intentionDigest(historical), + canonicalJson(historical), + historical.intentionId, + ); + db.prepare( + "INSERT INTO intention_transitions(intention_id, revision, from_status, to_status, reason, evidence_ref, at) VALUES (?, ?, ?, ?, ?, ?, ?)", + ).run( + source.intentionId, + historical.revision, + source.status, + "cancelled", + cancellation(source).reason, + source.acceptance.sourceRef, + source.acceptance.acceptedAt, + ); + } finally { + db.close(); + } + const reopened = f.fixture.keep(new JudgmentStore(f.path)); + expect(reopened.getIntention(source.intentionId)).toEqual(historical); + expect(() => + reopened.transitionIntention( + source.intentionId, + { + ...cancellation(source), + to: "active", + }, + historical.revision, + ), + ).toThrow("invalid intention transition: cancelled -> active"); + } finally { + f.fixture.close(); + } + }); + + for (const kind of ["autonomous_goal", "task_binding"] as const) + test(`3999164940 ${kind} cancellation from ${status} retains the existing lifecycle`, () => { + const f = policyEvidenceFixture(); + try { + const source = { ...advance(f, status), kind }; + const result = transitionIntention(source, cancellation(source)); + expect(parseIntentionRecord(result).status).toBe("cancelled"); + expect(result.acceptance).toEqual(source.acceptance); + } finally { + f.fixture.close(); + } + }); +} + +test("3999164940 proposed user commitment withdrawal still persists and reopens", () => { + const f = policyEvidenceFixture(); + try { + const source: IntentionRecord = { + ...f.adopted, + intentionId: "proposal", + revision: 0, + status: "proposed", + history: [], + }; + f.store.putIntention(source); + const result = f.store.transitionIntention( + source.intentionId, + cancellation(source), + 0, + ); + expect(result.status).toBe("cancelled"); + const reopened = f.fixture.keep(new JudgmentStore(f.path)); + expect(reopened.getIntention(source.intentionId)).toEqual(result); + } finally { + f.fixture.close(); + } +}); + +test("3999164940 ranking and receipt-shaped catalog strings cannot authorize cancellation", () => { + const f = policyEvidenceFixture(policyOption("intention.cancel")); + try { + f.oppose("atropos", ["promise"]); + const result = resolvePersonalRound(f.input); + expect(result.resolution.status).toBe("resolved"); + expect(() => + f.store.transitionIntention("promise", cancellation(f.adopted), 1), + ).toThrow("user commitment cancellation authority is unavailable"); + } finally { + f.fixture.close(); + } +}); + +for (const field of ["authorityRef", "userConfirmationRef", "authorized"]) + test(`3999164940 caller-controlled ${field} cannot unlock cancellation`, () => { + const f = policyEvidenceFixture(); + try { + const input = { + ...cancellation(f.adopted), + [field]: "claimed-authority", + }; + expect(() => transitionIntention(f.adopted, input)).toThrow( + "invalid intention transition", + ); + expect(() => f.store.transitionIntention("promise", input, 1)).toThrow( + `unknown intention transition field ${field}`, + ); + } finally { + f.fixture.close(); + } + }); diff --git a/packages/lina-core/test/judgment-candidates.test.ts b/packages/lina-core/test/judgment-candidates.test.ts new file mode 100644 index 0000000..578c9f9 --- /dev/null +++ b/packages/lina-core/test/judgment-candidates.test.ts @@ -0,0 +1,799 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { + assessmentInputDigest, + buildCandidateSet, + buildCanonicalOption, + type CandidateSet, + type CanonicalOption, + type IntentionRecord, + intentionDigest, + type JudgmentSnapshotRef, + JudgmentStore, + judgmentDigest, + MODULE_KINDS, + PERSONAL_POLICY_V1, + parseAssessment, + parseCandidateSet, + parseIntentionRecord, + parseResolutionRecord, + parseSelectionSpec, + resolvePersonalRound, + snapshotDigest, +} from "../src/agents/index.ts"; +import { Fixture } from "./fixture.ts"; + +let fixture: Fixture; +let store: JudgmentStore; +let path: string; +let snapshot: JudgmentSnapshotRef; +const actor = { agentId: "agent-1", scopeId: "scope-1" }; +const at = "2026-09-12T00:00:00.000Z"; +function option(reason = "one"): CanonicalOption { + return buildCanonicalOption({ + kind: "noop", + actor, + targetId: null, + args: {}, + preconditions: { kind: "noop", reason }, + }); +} +function openFixtureRound(): void { + if (store.getRound(snapshot.roundId)) return; + snapshot = { + ...snapshot, + intentionRevision: store.intentionRevision(actor.agentId, actor.scopeId), + }; + store.openRound(snapshot); +} +function candidate( + options = [option()], + intentionRefs: CandidateSet["intentionRefs"] = [], +): CandidateSet { + openFixtureRound(); + return buildCandidateSet({ + roundId: snapshot.roundId, + snapshotDigest: snapshotDigest(snapshot), + options, + eligibility: options.map((o) => ({ + optionKey: o.optionKey, + eligible: true, + reason: null, + })), + intentionRefs, + }); +} +function intention(id = "intention-1"): IntentionRecord { + return parseIntentionRecord({ + schemaVersion: 1, + intentionId: id, + ...actor, + revision: 0, + kind: "user_commitment", + purposeRef: "purpose", + text: "Keep commitment", + acceptance: { + sourceRef: "request-1", + acceptedBy: "user", + policyRevision: 1, + acceptedAt: at, + }, + priority: 0, + deadline: null, + completionCondition: "receipt", + abortConditions: [], + relatedIntentions: [], + status: "proposed", + history: [], + }); +} +function transition( + id: string, + to: IntentionRecord["status"], + revision: number, +) { + return store.transitionIntention( + id, + { to, reason: "transition", evidenceRef: "request-1", at }, + revision, + ); +} +function intentionRef( + record: IntentionRecord, +): CandidateSet["intentionRefs"][number] { + return { + intentionId: record.intentionId, + revision: record.revision, + status: record.status, + digest: intentionDigest(record), + }; +} +function assess(options = [option()], breachIds?: string[]) { + openFixtureRound(); + for (const moduleKind of MODULE_KINDS) { + const input = { + snapshotDigest: snapshotDigest(snapshot), + objectiveRef: snapshot.objectiveProfileRefs[moduleKind], + mechanismRevision: 1, + }; + store.putAssessment( + parseAssessment({ + schemaVersion: 1, + moduleKind, + snapshotId: snapshot.roundId, + ...input, + inputDigest: assessmentInputDigest(input), + completeText: "Assessment", + evidenceRefs: [], + proposedOptionKeys: [], + recommendedOptionKeys: [], + objectiveAssessments: options.map((o) => ({ + optionKey: o.optionKey, + stance: breachIds && moduleKind === "atropos" ? "oppose" : "accept", + severity: + breachIds && moduleKind === "atropos" ? "commitment_breach" : null, + unavailableReason: null, + gain: "gain", + loss: "loss", + uncertainty: "uncertainty", + evidenceRefs: [], + ...(breachIds && moduleKind === "atropos" + ? { breachedIntentionIds: breachIds } + : {}), + })), + detail: { + kind: { + clotho: "forecasts", + lachesis: "values", + atropos: "continuity", + }[moduleKind], + body: {}, + }, + diagnostics: {}, + }), + ); + } +} +function resolve(set: CandidateSet) { + return resolvePersonalRound({ + policy: PERSONAL_POLICY_V1, + snapshot, + options: set.options, + eligibility: set.eligibility, + set: store.assessmentSet(snapshot.roundId), + bias: {}, + evidence: { + candidates: set, + lookupIntention: (id) => store.getIntention(id), + }, + }); +} +function reopen() { + store.close(); + store = new JudgmentStore(path); +} +function db() { + return fixture.keep(new DatabaseSync(path)); +} +function rehash(set: CandidateSet) { + return parseCandidateSet({ + ...set, + candidateDigest: judgmentDigest({ ...set, candidateDigest: undefined }), + }); +} +function held() { + openFixtureRound(); + return parseResolutionRecord({ + schemaVersion: 1, + roundId: snapshot.roundId, + policyId: snapshot.policyId, + policyRevision: snapshot.policyRevision, + situation: snapshot.situation, + order: ["atropos", "clotho", "lachesis"], + recommendations: { clotho: [], lachesis: [], atropos: [] }, + conflicts: [], + excluded: [], + abstentions: [], + ranking: [], + conceded: [], + status: "held", + holdReason: "missing evidence", + }); +} +beforeEach(() => { + fixture = new Fixture(); + path = join(fixture.dir, "judgment.sqlite"); + store = new JudgmentStore(path, { now: () => 1234 }); + for (const moduleKind of MODULE_KINDS) { + const ref = store.putObjectiveProfile({ + schemaVersion: 1, + objectiveId: moduleKind, + moduleKind, + revision: 1, + objective: "Compare", + comparisonCriteria: [], + reconsiderationConditions: [], + }); + store.activateObjectiveProfile(actor.agentId, actor.scopeId, ref); + } + const refs = store.activeObjectiveProfiles(actor.agentId, actor.scopeId); + if (!refs) throw Error("missing fixture profiles"); + snapshot = { + schemaVersion: 1, + roundId: "round-1", + ...actor, + sourceRefs: [], + workingRevision: 0, + instructionRevision: 0, + policyId: "personal.v1", + policyRevision: 1, + identityRevision: 0, + domainRevisions: {}, + intentionRevision: 0, + objectiveProfileRefs: refs, + observationRef: null, + frozenNeuralRef: null, + situation: "user_request", + clockId: "clock", + sequence: 1, + bindingGeneration: 0, + }; +}); +afterEach(() => { + store.close(); + fixture.close(); +}); + +test("candidate builder sorts canonical options and refs but preserves exact Host eligibility order and missing rows", () => { + const a = option("a"), + b = option("b"); + const original = candidate([a, b]); + const reversed = buildCandidateSet({ + roundId: original.roundId, + snapshotDigest: original.snapshotDigest, + options: [b, a], + eligibility: [...original.eligibility].reverse(), + intentionRefs: [], + }); + expect(reversed.options).toEqual(original.options); + expect( + parseCandidateSet({ + ...original, + options: [...original.options].reverse(), + }), + ).toEqual(original); + const refs = candidate( + [a], + [intentionRef(intention("b")), intentionRef(intention("a"))], + ); + expect(refs.intentionRefs.map((r) => r.intentionId)).toEqual(["a", "b"]); + expect(reversed.eligibility).toEqual([...original.eligibility].reverse()); + expect(reversed.candidateDigest).not.toBe(original.candidateDigest); + expect(parseCandidateSet(JSON.parse(JSON.stringify(original)))).toEqual( + original, + ); + const missing = buildCandidateSet({ + roundId: snapshot.roundId, + snapshotDigest: snapshotDigest(snapshot), + options: [a, b], + eligibility: [], + intentionRefs: [], + }); + expect(missing.eligibility).toEqual([]); + store.closeCandidateSet(missing); + assess([a, b]); + const result = resolve(missing); + expect(result.resolution.status).toBe("deferred"); + store.recordResolution(snapshot.roundId, result.resolution, result.spec); + reopen(); + expect(store.candidateSet(snapshot.roundId)).toEqual(missing); + expect(store.getResolution(snapshot.roundId)).toEqual(result.resolution); +}); + +test("candidate parser rejects unknown, duplicate, malformed and foreign input without normalizing Host semantics", () => { + const set = candidate(); + for (const changed of [ + { ...set, schemaVersion: 2 }, + { ...set, extra: true }, + { ...set, candidateDigest: "forged" }, + { ...set, options: [option(), option()] }, + { ...set, options: [{ ...option(), optionKey: "forged" }] }, + { ...set, eligibility: [...set.eligibility, ...set.eligibility] }, + { + ...set, + eligibility: [{ optionKey: "foreign", eligible: true, reason: null }], + }, + { + ...set, + eligibility: [ + { optionKey: option().optionKey, eligible: "true", reason: null }, + ], + }, + { + ...set, + eligibility: [{ optionKey: option().optionKey, eligible: false }], + }, + { + ...set, + intentionRefs: [intentionRef(intention()), intentionRef(intention())], + }, + { ...set, intentionRefs: [{ ...intentionRef(intention()), revision: -1 }] }, + { ...set, intentionRefs: [{ ...intentionRef(intention()), extra: true }] }, + ]) + expect(() => parseCandidateSet(changed)).toThrow(); + const foreign = buildCanonicalOption({ + ...option("foreign"), + actor: { ...actor, scopeId: "foreign" }, + }); + expect(() => candidate([option(), foreign])).toThrow( + "candidate actor mismatch", + ); +}); + +test("closure requires an open matching frozen round and is immutable and detached", () => { + expect(store.candidateSet(snapshot.roundId)).toBeNull(); + for (const changed of [ + rehash({ ...candidate(), roundId: "missing" }), + rehash({ ...candidate(), snapshotDigest: "wrong" }), + candidate([ + buildCanonicalOption({ + ...option(), + actor: { ...actor, agentId: "foreign" }, + }), + ]), + ]) + expect(() => store.closeCandidateSet(changed)).toThrow(); + const set = candidate(); + store.closeCandidateSet(set); + expect(() => store.closeCandidateSet(set)).toThrow( + "candidate set already closed", + ); + set.eligibility.length = 0; + expect(store.candidateSet(snapshot.roundId)).toEqual(candidate()); + store.recordResolution(snapshot.roundId, held(), null); + expect(() => store.closeCandidateSet(candidate())).toThrow( + "judgment round is not open", + ); + reopen(); + expect(store.candidateSet(snapshot.roundId)).toEqual(candidate()); +}); + +for (const complete of [false, true]) + test(`only held/no-spec can record missing candidate evidence (assessments complete=${complete})`, () => { + if (complete) assess(); + expect(() => + store.recordResolution( + snapshot.roundId, + { ...held(), status: "deferred" }, + null, + ), + ).toThrow(); + store.recordResolution(snapshot.roundId, held(), null); + reopen(); + expect(store.getResolution(snapshot.roundId)).toEqual(held()); + }); + +test("complete inputs replay held and deferred, rejecting descriptive mutations atomically", () => { + const set = candidate(); + store.closeCandidateSet(set); + assess([]); + const result = resolve(set); + expect(result.resolution.status).toBe("held"); + expect(() => + store.recordResolution( + snapshot.roundId, + { ...result.resolution, status: "deferred" }, + null, + ), + ).toThrow("resolution policy replay mismatch"); + expect(store.getRound(snapshot.roundId)?.status).toBe("open"); + store.recordResolution(snapshot.roundId, result.resolution, null); + db() + .prepare("UPDATE resolution_records SET body = ?, digest = ?") + .run(JSON.stringify(held()), judgmentDigest(held())); + expect(() => store.getResolution(snapshot.roundId)).toThrow( + "resolution policy replay mismatch", + ); +}); + +for (const stage of [ + "infeasible", + "commitment_protection", + "host_eligibility", +] as const) { + for (const persisted of [false, true]) + test(`closed all-accept inputs reject fully rehashed ${stage} exclusion (persisted=${persisted})`, () => { + const set = candidate([option("a"), option("b")]); + store.closeCandidateSet(set); + assess(set.options); + const result = resolve(set); + const spec = result.spec; + if (!spec) throw Error("missing spec"); + const [first, second] = set.options; + if (!first || !second) throw Error("missing options"); + const forged = parseResolutionRecord({ + ...result.resolution, + ranking: [{ optionKey: first.optionKey, rank: 1 }], + excluded: [ + { + optionKey: second.optionKey, + stage, + byModule: + stage === "infeasible" + ? "clotho" + : stage === "commitment_protection" + ? "atropos" + : null, + reason: "forged", + }, + ], + }); + const changed = { + ...spec, + resolutionDigest: judgmentDigest(forged), + candidates: spec.candidates.map((c) => ({ + ...c, + p0: c.optionKey === first.optionKey ? 1 : 0, + })), + eligibleDigest: judgmentDigest([first.optionKey]), + }; + const forgedSpec = parseSelectionSpec({ + ...changed, + specDigest: judgmentDigest({ ...changed, specDigest: undefined }), + }); + if (persisted) { + store.recordResolution(snapshot.roundId, result.resolution, spec); + const sql = db(); + sql + .prepare("UPDATE resolution_records SET body = ?, digest = ?") + .run(JSON.stringify(forged), judgmentDigest(forged)); + sql + .prepare("UPDATE selection_specs SET body = ?, spec_digest = ?") + .run(JSON.stringify(forgedSpec), forgedSpec.specDigest); + expect(() => store.getResolution(snapshot.roundId)).toThrow( + "resolution policy replay mismatch", + ); + reopen(); + expect(() => store.getSelectionSpec(snapshot.roundId)).toThrow( + "resolution policy replay mismatch", + ); + } else { + expect(() => + store.recordResolution(snapshot.roundId, forged, forgedSpec), + ).toThrow("resolution policy replay mismatch"); + expect(store.getResolution(snapshot.roundId)).toBeNull(); + expect(store.getRound(snapshot.roundId)?.status).toBe("open"); + store.recordResolution(snapshot.roundId, result.resolution, spec); + } + }); +} + +for (const column of [ + "round_id", + "snapshot_digest", + "candidate_digest", + "body", + "delete", +] as const) + test(`candidate audit detects ${column} tampering on external commit and reopen`, () => { + const set = candidate(); + store.closeCandidateSet(set); + assess(); + const result = resolve(set); + store.recordResolution(snapshot.roundId, result.resolution, result.spec); + const sql = db(); + sql.exec("PRAGMA foreign_keys = OFF"); + if (column === "delete") sql.exec("DELETE FROM candidate_sets"); + else + sql + .prepare(`UPDATE candidate_sets SET ${column} = ?`) + .run( + column === "body" + ? JSON.stringify({ ...set, eligibility: [] }) + : "forged", + ); + expect(() => store.getRound(snapshot.roundId)).toThrow(); + reopen(); + expect(() => store.candidateSet(snapshot.roundId)).toThrow(); + }); + +function suspend(id = "intention-1", acceptanceSourceRef = "request-1") { + return buildCanonicalOption({ + kind: "intention.suspend", + actor, + targetId: id, + args: {}, + preconditions: { + kind: "intention.suspend", + intentionId: id, + acceptanceSourceRef, + reason: "pause", + }, + }); +} + +test("eligible intention targets require matching current scoped references, acceptance and legal transition", () => { + store.putIntention(intention()); + const adopted = transition("intention-1", "adopted", 0); + const active = transition("intention-1", "active", 1); + store.putIntention({ ...intention("foreign"), scopeId: "foreign" }); + for (const set of [ + candidate([suspend()]), + candidate([suspend()], [intentionRef(adopted)]), + candidate([suspend()], [{ ...intentionRef(active), digest: "wrong" }]), + candidate([suspend()], [{ ...intentionRef(active), status: "suspended" }]), + candidate( + [suspend("missing")], + [{ ...intentionRef(active), intentionId: "missing" }], + ), + candidate( + [suspend("foreign")], + [intentionRef({ ...intention("foreign"), scopeId: "foreign" })], + ), + candidate([suspend("intention-1", "fabricated")], [intentionRef(active)]), + candidate( + [ + buildCanonicalOption({ + kind: "intention.resume", + actor, + targetId: active.intentionId, + args: {}, + preconditions: { + kind: "intention.resume", + intentionId: active.intentionId, + acceptanceSourceRef: "request-1", + reason: "resume", + }, + }), + ], + [intentionRef(active)], + ), + ]) { + expect(() => store.closeCandidateSet(set)).toThrow(); + expect(store.candidateSet(snapshot.roundId)).toBeNull(); + } + store.closeCandidateSet(candidate([suspend()], [intentionRef(active)])); +}); + +for (const tamper of ["none", "metadata", "acceptance", "delete"] as const) + test(`frozen intention revision survives valid later transitions but rejects ${tamper} rewrite`, () => { + store.putIntention(intention()); + transition("intention-1", "adopted", 0); + const active = transition("intention-1", "active", 1); + const set = candidate([suspend()], [intentionRef(active)]); + store.closeCandidateSet(set); + assess(set.options); + const result = resolve(set); + store.recordResolution(snapshot.roundId, result.resolution, result.spec); + transition("intention-1", "suspended", 2); + transition("intention-1", "active", 3); + const current = transition("intention-1", "completed", 4); + const sql = db(); + if (tamper === "none") { + reopen(); + expect(store.candidateSet(snapshot.roundId)).toEqual(set); + expect(store.getResolution(snapshot.roundId)).toEqual(result.resolution); + expect(store.getIntention(current.intentionId)).toEqual(current); + return; + } + if (tamper === "delete") + sql.exec( + "PRAGMA foreign_keys = OFF; DELETE FROM intention_transitions; DELETE FROM intention_records", + ); + else { + const changed = parseIntentionRecord( + tamper === "metadata" + ? { ...current, text: "replacement" } + : { + ...current, + acceptance: { + ...current.acceptance, + acceptedAt: "2026-09-11T00:00:00.000Z", + }, + }, + ); + sql + .prepare("UPDATE intention_records SET body = ?, digest = ?") + .run(JSON.stringify(changed), intentionDigest(changed)); + } + expect(() => store.getResolution(snapshot.roundId)).toThrow( + /candidate intention/, + ); + reopen(); + expect(() => store.candidateSet(snapshot.roundId)).toThrow( + /candidate intention/, + ); + }); + +test("candidate insertion failure rolls back closure and leaves the round open", () => { + const sql = db(); + sql.exec( + "CREATE TRIGGER fail_candidate BEFORE INSERT ON candidate_sets BEGIN SELECT RAISE(ABORT, 'fixture candidate failure'); END", + ); + expect(() => store.closeCandidateSet(candidate())).toThrow( + "fixture candidate failure", + ); + expect(store.candidateSet(snapshot.roundId)).toBeNull(); + expect(store.getRound(snapshot.roundId)?.status).toBe("open"); + sql.exec("DROP TRIGGER fail_candidate"); + store.closeCandidateSet(candidate()); + expect(store.candidateSet(snapshot.roundId)).toEqual(candidate()); +}); + +test("rehashed candidate input tampering must agree with the persisted policy outcome", () => { + const set = candidate(); + store.closeCandidateSet(set); + assess(); + const result = resolve(set); + store.recordResolution(snapshot.roundId, result.resolution, result.spec); + const changed = rehash({ ...set, eligibility: [] }); + db() + .prepare("UPDATE candidate_sets SET body = ?, candidate_digest = ?") + .run(JSON.stringify(changed), changed.candidateDigest); + expect(() => store.getResolution(snapshot.roundId)).toThrow( + "resolution policy replay mismatch", + ); + reopen(); + expect(() => store.candidateSet(snapshot.roundId)).toThrow( + "resolution policy replay mismatch", + ); +}); + +test("complete replay binds conflicts, recommendations, abstentions, concessions and ordering", () => { + const set = candidate(); + store.closeCandidateSet(set); + assess(); + const result = resolve(set); + if (!result.spec) throw Error("missing spec"); + const key = option().optionKey; + for (const change of [ + { + conflicts: [ + { + optionKey: key, + stances: { clotho: "prefer", lachesis: "accept", atropos: "accept" }, + }, + ], + }, + { recommendations: { clotho: [key], lachesis: [], atropos: [] } }, + { + abstentions: [ + { optionKey: key, moduleKind: "clotho", reason: "fabricated" }, + ], + }, + { conceded: [{ optionKey: key, moduleKind: "atropos" }] }, + { order: ["clotho", "atropos", "lachesis"] }, + ]) { + const record = parseResolutionRecord({ ...result.resolution, ...change }); + const spec = { ...result.spec, resolutionDigest: judgmentDigest(record) }; + const selection = parseSelectionSpec({ + ...spec, + specDigest: judgmentDigest({ ...spec, specDigest: undefined }), + }); + expect(() => + store.recordResolution(snapshot.roundId, record, selection), + ).toThrow("resolution policy replay mismatch"); + expect(store.getResolution(snapshot.roundId)).toBeNull(); + } + const spec = { + ...result.spec, + candidates: result.spec.candidates.map((c) => ({ ...c, b: 0.75 })), + }; + const selection = parseSelectionSpec({ + ...spec, + specDigest: judgmentDigest({ ...spec, specDigest: undefined }), + }); + store.recordResolution(snapshot.roundId, result.resolution, selection); + reopen(); + expect(store.getSelectionSpec(snapshot.roundId)).toEqual(selection); +}); + +test("historical evidence rejects rewritten original acceptance even with a consistent current history ledger", () => { + store.putIntention(intention()); + transition("intention-1", "adopted", 0); + const active = transition("intention-1", "active", 1); + store.closeCandidateSet(candidate([suspend()], [intentionRef(active)])); + const suspended = transition("intention-1", "suspended", 2); + const changed = parseIntentionRecord({ + ...suspended, + acceptance: { ...suspended.acceptance, sourceRef: "replacement" }, + history: suspended.history.map((t) => ({ + ...t, + evidenceRef: "replacement", + })), + }); + const sql = db(); + sql + .prepare("UPDATE intention_records SET body = ?, digest = ?") + .run(JSON.stringify(changed), intentionDigest(changed)); + sql.exec("UPDATE intention_transitions SET evidence_ref = 'replacement'"); + expect(() => store.candidateSet(snapshot.roundId)).toThrow( + "candidate intention digest mismatch", + ); + reopen(); + expect(() => store.getIntention("intention-1")).toThrow( + "candidate intention digest mismatch", + ); +}); + +test("eligible resume references the suspended intention and original acceptance when present", () => { + store.putIntention(intention()); + transition("intention-1", "adopted", 0); + const suspended = transition("intention-1", "suspended", 1); + const resume = buildCanonicalOption({ + kind: "intention.resume", + actor, + targetId: suspended.intentionId, + args: {}, + preconditions: { + kind: "intention.resume", + intentionId: suspended.intentionId, + acceptanceSourceRef: "request-1", + reason: "resume", + }, + }); + const set = candidate([resume], [intentionRef(suspended)]); + store.closeCandidateSet(set); + assess(set.options); + const result = resolve(set); + store.recordResolution(snapshot.roundId, result.resolution, result.spec); + transition(suspended.intentionId, "active", 2); + reopen(); + expect(store.candidateSet(snapshot.roundId)).toEqual(set); +}); + +test("Atropos attributed breach evidence is required whether assessments arrive before or after closure", () => { + assess([option()], ["missing"]); + expect(() => store.closeCandidateSet(candidate())).toThrow( + "missing protected commitment evidence", + ); + expect(store.candidateSet(snapshot.roundId)).toBeNull(); +}); + +for (const kind of ["user_commitment", "autonomous_goal"] as const) + for (const status of ["proposed", "active"] as const) + test(`attributed protected evidence requires accepted live user commitment (${kind}/${status})`, () => { + const proposed = { ...intention(), kind }; + store.putIntention(proposed); + let record: IntentionRecord = proposed; + if (status === "active") { + transition(record.intentionId, "adopted", 0); + record = transition(record.intentionId, "active", 1); + } + const set = candidate([option()], [intentionRef(record)]); + store.closeCandidateSet(set); + if (kind !== "user_commitment" || status !== "active") + expect(() => assess(set.options, [record.intentionId])).toThrow( + "missing protected commitment evidence", + ); + else { + assess(set.options, [record.intentionId]); + const result = resolve(set); + expect(result.resolution.status).toBe("deferred"); + store.recordResolution( + snapshot.roundId, + result.resolution, + result.spec, + ); + reopen(); + expect(store.getResolution(snapshot.roundId)).toEqual( + result.resolution, + ); + } + }); + +test("targeted waiver with verified protected intention is replayed and remains historical", () => { + store.putIntention(intention()); + transition("intention-1", "adopted", 0); + const active = transition("intention-1", "active", 1); + const set = candidate([suspend()], [intentionRef(active)]); + assess(set.options, [active.intentionId]); + store.closeCandidateSet(set); + const result = resolve(set); + expect(result.resolution.status).toBe("resolved"); + store.recordResolution(snapshot.roundId, result.resolution, result.spec); + transition(active.intentionId, "suspended", 2); + reopen(); + expect(store.getSelectionSpec(snapshot.roundId)).toEqual(result.spec); +}); diff --git a/packages/lina-core/test/judgment-catalog-review.test.ts b/packages/lina-core/test/judgment-catalog-review.test.ts new file mode 100644 index 0000000..c41523f --- /dev/null +++ b/packages/lina-core/test/judgment-catalog-review.test.ts @@ -0,0 +1,253 @@ +import { expect, test } from "bun:test"; +import { + buildCanonicalOption, + type CanonicalOption, + canonicalOptionKey, + normalizeOptionArgs, + PERSONAL_OPTION_KINDS, + type PersonalOptionKind, + type PersonalPrecondition, + parseCanonicalOption, +} from "../src/agents/index.ts"; + +const preconditions = [ + { + kind: "intention.adopt", + intentionKind: "user_commitment", + sourceRef: "source", + }, + { kind: "intention.activate", intentionId: "intention" }, + { + kind: "intention.suspend", + intentionId: "intention", + acceptanceSourceRef: "acceptance", + reason: "pause work", + }, + { + kind: "intention.resume", + intentionId: "intention", + acceptanceSourceRef: "acceptance", + reason: "resume work", + }, + { + kind: "intention.cancel", + intentionId: "intention", + acceptanceSourceRef: "acceptance", + authorityRef: "authority", + userConfirmationRef: null, + }, + { + kind: "intention.complete", + intentionId: "intention", + outcomeRef: "outcome", + }, + { + kind: "task.start", + authorityRef: "authority", + taskText: "perform work", + intentionId: "intention", + }, + { kind: "task.send", taskId: "task", ownerId: "owner", revision: 1 }, + { kind: "task.interrupt", taskId: "task", ownerId: "owner", revision: 1 }, + { kind: "task.handover", taskId: "task", ownerId: "owner", revision: 1 }, + { kind: "inquire", authorityRef: "authority" }, + { kind: "defer", resumeCondition: "input arrives" }, + { kind: "noop", reason: "nothing needed" }, +] satisfies PersonalPrecondition[]; + +function option(precondition: PersonalPrecondition): CanonicalOption { + return buildCanonicalOption({ + kind: precondition.kind, + actor: { agentId: "agent", scopeId: "scope" }, + targetId: "target", + args: {}, + preconditions: precondition, + }); +} + +for (const [label, value] of [ + ["spaces", " ".repeat(1001)], + ["tabs", "\t".repeat(1001)], +] as const) { + const original = option({ kind: "noop", reason: "nothing needed" }); + const changed = { ...original, args: { empty: value } }; + const operations = { + normalize: () => normalizeOptionArgs(changed.args), + key: () => canonicalOptionKey(changed), + build: () => buildCanonicalOption(changed), + parse: () => parseCanonicalOption(changed), + }; + for (const [operation, run] of Object.entries(operations)) + test(`3999091818 ${operation} rejects oversized ${label} before omission`, () => { + expect(run).toThrow("invalid option argument"); + }); +} + +test("3999091818 bounded empty arguments are omitted while the full text ceiling survives", () => { + expect( + normalizeOptionArgs({ + empty: "", + spaces: " ".repeat(1000), + tabs: "\t".repeat(1000), + text: "x".repeat(1000), + }), + ).toEqual({ text: "x".repeat(1000) }); +}); + +test("identity fixtures cover every personal option kind", () => { + expect(preconditions.map((p) => p.kind)).toEqual([...PERSONAL_OPTION_KINDS]); +}); + +for (const precondition of preconditions) { + for (const length of [100, 160]) { + test(`${precondition.kind} round-trips a ${length}-character target with a bounded key`, () => { + const original = option(precondition); + const targetId = "x".repeat(length); + const built = buildCanonicalOption({ ...original, targetId }); + expect(built.targetId).toBe(targetId); + expect(built.optionKey.length).toBeLessThanOrEqual(160); + expect(built.optionKey.length).toBe(original.optionKey.length); + expect(parseCanonicalOption(JSON.parse(JSON.stringify(built)))).toEqual( + built, + ); + const changed = buildCanonicalOption({ + ...built, + targetId: `${targetId.slice(0, -1)}y`, + }); + expect(changed.optionKey).not.toBe(built.optionKey); + expect(() => + parseCanonicalOption({ ...changed, optionKey: built.optionKey }), + ).toThrow("canonical option key mismatch"); + }); + } + + for (const [field, value] of Object.entries(precondition)) { + if (field === "kind") continue; + test(`${precondition.kind} identity binds preconditions.${field}`, () => { + const original = option(precondition); + const changedValue = + field === "intentionKind" + ? "autonomous_goal" + : typeof value === "number" + ? value + 1 + : `${value ?? "confirmation"}-changed`; + const changed = buildCanonicalOption({ + ...original, + preconditions: { ...precondition, [field]: changedValue }, + }); + expect(changed.optionKey).not.toBe(original.optionKey); + expect(parseCanonicalOption(changed)).toEqual(changed); + expect(() => + parseCanonicalOption({ ...changed, optionKey: original.optionKey }), + ).toThrow(); + }); + } + + test(`${precondition.kind} keys preserve normalized equivalence and ignore insertion order`, () => { + const original = buildCanonicalOption({ + ...option(precondition), + args: { a: "\u00e9 x", z: "last" }, + }); + const reordered = { + ...original, + kind: original.kind.toUpperCase() as PersonalOptionKind, + targetId: " target ", + actor: { scopeId: "scope", agentId: "agent" }, + args: { z: " last ", empty: " \t ", a: " e\u0301 x " }, + preconditions: Object.fromEntries( + Object.entries(precondition).reverse(), + ) as PersonalPrecondition, + effect: { scope: "scope", owner: original.effect.owner }, + }; + expect(canonicalOptionKey(reordered)).toBe(original.optionKey); + expect(buildCanonicalOption(reordered)).toEqual(original); + expect(parseCanonicalOption(reordered)).toEqual(original); + expect( + canonicalOptionKey({ + ...original, + optionKey: "ignored", + } as CanonicalOption), + ).toBe(original.optionKey); + }); + + test(`${precondition.kind} rejects an effect outside the actor scope even with a new key`, () => { + const original = option(precondition); + const changed = { + ...original, + effect: { ...original.effect, scope: "other-scope" }, + }; + expect(() => + parseCanonicalOption({ + ...changed, + optionKey: canonicalOptionKey(changed), + }), + ).toThrow(); + }); +} + +for (const field of ["agentId", "scopeId"] as const) { + test(`identity binds actor.${field}`, () => { + const original = option({ kind: "noop", reason: "nothing needed" }); + const changed = buildCanonicalOption({ + ...original, + actor: { ...original.actor, [field]: "other" }, + }); + expect(changed.optionKey).not.toBe(original.optionKey); + expect(() => + parseCanonicalOption({ ...changed, optionKey: original.optionKey }), + ).toThrow(); + }); +} + +for (const effect of [ + { owner: "host", scope: "scope" }, + { owner: "none", scope: "other-scope" }, +] as const) { + test(`identity binds effect ${JSON.stringify(effect)}`, () => { + const original = option({ kind: "noop", reason: "nothing needed" }); + const changed = { ...original, effect }; + expect(canonicalOptionKey(changed)).not.toBe(original.optionKey); + expect(() => + parseCanonicalOption({ + ...changed, + optionKey: canonicalOptionKey(changed), + }), + ).toThrow(); + }); +} + +test("bounded keys retain catalog/kind prefix and bind arguments and nullable target", () => { + const original = option({ kind: "noop", reason: "nothing needed" }); + expect(original.optionKey).toMatch(/^personal\.v1:noop:[a-f0-9]{64}$/); + const untargeted = buildCanonicalOption({ ...original, targetId: null }); + expect(untargeted.optionKey).toMatch(/^personal\.v1:noop:[a-f0-9]{64}$/); + expect(untargeted.optionKey).not.toBe(original.optionKey); + expect(parseCanonicalOption(untargeted)).toEqual(untargeted); + expect(canonicalOptionKey({ ...original, targetId: "other" })).not.toBe( + original.optionKey, + ); + expect( + canonicalOptionKey({ ...original, args: { text: "different" } }), + ).not.toBe(original.optionKey); + for (const targetId of [" - ", "x".repeat(161)]) { + expect(() => canonicalOptionKey({ ...original, targetId })).toThrow(); + expect(() => buildCanonicalOption({ ...original, targetId })).toThrow(); + expect(() => parseCanonicalOption({ ...original, targetId })).toThrow(); + } +}); + +test("precondition text is not normalized like display arguments", () => { + const original = option({ + kind: "task.start", + authorityRef: "authority", + taskText: "perform work", + intentionId: "intention", + }); + const changed = option({ + kind: "task.start", + authorityRef: "authority", + taskText: "perform work", + intentionId: "intention", + }); + expect(changed.optionKey).not.toBe(original.optionKey); +}); diff --git a/packages/lina-core/test/judgment-contract.test.ts b/packages/lina-core/test/judgment-contract.test.ts new file mode 100644 index 0000000..4deebdf --- /dev/null +++ b/packages/lina-core/test/judgment-contract.test.ts @@ -0,0 +1,1169 @@ +import { expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + ACCEPTED_BY, + type Assessment, + assessmentInputDigest, + EXCLUSION_STAGES, + INTENTION_KINDS, + INTENTION_RELATIONS, + INTENTION_STATUSES, + INTENTION_TRANSITIONS, + type IntentionRecord, + intentionDigest, + type JudgmentSnapshotRef, + judgmentDigest, + MODULE_KINDS, + type ModuleKind, + type ObjectiveProfile, + type OptionAssessment, + parseAssessment, + parseAssessmentSet, + parseIntentionRecord, + parseIntentionTransition, + parseJudgmentSnapshotRef, + parseObjectiveProfile, + parseOptionAssessment, + parseResolutionRecord, + parseSelectionSpec, + type ResolutionRecord, + ROUND_STATUSES, + SEVERITIES, + type SelectionSpec, + SITUATIONS, + STANCES, + snapshotDigest, + transitionIntention, +} from "../src/agents/index.ts"; + +const profile: ObjectiveProfile = { + schemaVersion: 1, + objectiveId: "objective-clotho", + moduleKind: "clotho", + revision: 1, + objective: "Compare fixture outcomes", + comparisonCriteria: ["cost", "outcome"], + reconsiderationConditions: ["new evidence"], +}; +const snapshot: JudgmentSnapshotRef = { + schemaVersion: 1, + roundId: "round-1", + agentId: "agent-1", + scopeId: "scope-1", + sourceRefs: [{ kind: "request", id: "request-1", revision: 0 }], + workingRevision: 2, + instructionRevision: 1, + policyId: "personal.v1", + policyRevision: 1, + identityRevision: 1, + domainRevisions: { life: 0 }, + intentionRevision: 0, + objectiveProfileRefs: { + clotho: { + objectiveId: "objective-clotho", + revision: 1, + digest: "profile-digest", + }, + lachesis: { + objectiveId: "objective-lachesis", + revision: 1, + digest: "profile-digest", + }, + atropos: { + objectiveId: "objective-atropos", + revision: 1, + digest: "profile-digest", + }, + }, + observationRef: null, + frozenNeuralRef: null, + situation: "user_request", + clockId: "clock-1", + sequence: 0, + bindingGeneration: 0, +}; +const option: OptionAssessment = { + optionKey: "a", + stance: "prefer", + severity: null, + unavailableReason: null, + gain: "fixture gain", + loss: "fixture loss", + uncertainty: "fixture uncertainty", + evidenceRefs: ["evidence-1"], +}; +const assessment: Assessment = { + schemaVersion: 1, + moduleKind: "clotho", + snapshotId: "round-1", + snapshotDigest: "snapshot-digest", + inputDigest: + "8a5ceb71d411139b4ad10b5d5a8e78045ae9b788fd3fb9d64bf4c440094504e7", + objectiveRef: { + objectiveId: "objective-clotho", + revision: 1, + digest: "profile-digest", + }, + mechanismRevision: 1, + completeText: "Fixture forecast", + evidenceRefs: ["evidence-1"], + proposedOptionKeys: ["a"], + objectiveAssessments: [ + { + optionKey: "a", + stance: "prefer", + severity: null, + unavailableReason: null, + gain: "fixture gain", + loss: "fixture loss", + uncertainty: "fixture uncertainty", + evidenceRefs: ["evidence-1"], + }, + ], + recommendedOptionKeys: ["a"], + detail: { + kind: "forecasts", + body: { forecast: [null, true, 1, "outcome", { possible: false }] }, + }, + diagnostics: {}, +}; +const spec: SelectionSpec = { + schemaVersion: 1, + roundId: "round-1", + snapshotDigest: "snapshot-digest", + assessmentSetDigest: "set-digest", + objectiveProfileRefs: { + clotho: { + objectiveId: "objective-clotho", + revision: 1, + digest: "profile-digest", + }, + lachesis: { + objectiveId: "objective-lachesis", + revision: 1, + digest: "profile-digest", + }, + atropos: { + objectiveId: "objective-atropos", + revision: 1, + digest: "profile-digest", + }, + }, + resolutionDigest: "resolution-digest", + policyId: "personal.v1", + policyRevision: 1, + situation: "user_request", + lambda: 0, + candidates: [ + { optionKey: "a", p0: 0.5, b: null }, + { optionKey: "b", p0: 0.5, b: 1 }, + ], + eligibleDigest: + "0473ef2dc0d324ab659d3580c1134e9d812035905c4781fdd6d529b0c6860e13", + specDigest: + "1ec632276bd8febd508ce2d47ae110e4fb029b94777d97aeafdfff43e00aecc7", +}; +const intention: IntentionRecord = { + schemaVersion: 1, + intentionId: "intention-1", + agentId: "agent-1", + scopeId: "scope-1", + revision: 0, + kind: "user_commitment", + purposeRef: "purpose-1", + text: "Complete fixture task", + acceptance: { + sourceRef: "request-1", + acceptedBy: "user", + policyRevision: 1, + acceptedAt: "2026-09-11T00:00:00.000Z", + }, + priority: 0, + deadline: null, + completionCondition: "Outcome receipt", + abortConditions: ["request withdrawn"], + relatedIntentions: [{ intentionId: "intention-2", relation: "depends" }], + status: "proposed", + history: [], +}; +const transition = { + from: "proposed", + to: "adopted", + reason: "accepted", + evidenceRef: null, + at: "2026-09-11T01:00:00.000Z", +} as const; +const resolution: ResolutionRecord = { + schemaVersion: 1, + roundId: "round-1", + policyId: "personal.v1", + policyRevision: 1, + situation: "user_request", + order: ["atropos", "clotho", "lachesis"], + recommendations: { clotho: ["a"], lachesis: [], atropos: ["b"] }, + conflicts: [ + { + optionKey: "a", + stances: { clotho: "prefer", lachesis: "unavailable", atropos: "oppose" }, + }, + ], + excluded: [ + { + optionKey: "b", + stage: "commitment_protection", + byModule: "atropos", + reason: "acceptance protected", + }, + ], + abstentions: [ + { optionKey: "a", moduleKind: "lachesis", reason: "missing observation" }, + ], + ranking: [{ optionKey: "a", rank: 1 }], + conceded: [{ moduleKind: "atropos", optionKey: "b" }], + status: "resolved", + holdReason: null, +}; +function moduleAssessment(moduleKind: ModuleKind): Assessment { + const objectiveRef = snapshot.objectiveProfileRefs[moduleKind]; + return parseAssessment({ + ...assessment, + moduleKind, + objectiveRef, + inputDigest: assessmentInputDigest({ + snapshotDigest: assessment.snapshotDigest, + objectiveRef, + mechanismRevision: 1, + }), + detail: { + kind: { clotho: "forecasts", lachesis: "values", atropos: "continuity" }[ + moduleKind + ], + body: {}, + }, + }); +} +function assessmentSet() { + return { + schemaVersion: 1, + roundId: "round-1", + snapshotDigest: "snapshot-digest", + assessments: MODULE_KINDS.map(moduleAssessment), + }; +} +function signedSpec(changes: Partial): SelectionSpec { + const changed = { ...spec, ...changes }; + return { + ...changed, + eligibleDigest: judgmentDigest( + changed.candidates.filter((c) => c.p0 > 0).map((c) => c.optionKey), + ), + specDigest: judgmentDigest({ + ...changed, + eligibleDigest: judgmentDigest( + changed.candidates.filter((c) => c.p0 > 0).map((c) => c.optionKey), + ), + specDigest: undefined, + }), + }; +} +const topLevel: Array<[string, (value: unknown) => unknown, () => object]> = [ + ["objective profile", parseObjectiveProfile, () => profile], + ["judgment snapshot ref", parseJudgmentSnapshotRef, () => snapshot], + ["assessment", parseAssessment, () => assessment], + ["assessment set", parseAssessmentSet, assessmentSet], + ["resolution record", parseResolutionRecord, () => resolution], + ["selection spec", parseSelectionSpec, () => spec], + ["intention record", parseIntentionRecord, () => intention], +]; +for (const [label, parse, golden] of topLevel) { + test(`${label}: golden JSON round-trip and version-first rejection`, () => { + const json = JSON.stringify(golden()); + expect(JSON.stringify(parse(JSON.parse(json)))).toBe(json); + for (const schemaVersion of [0, 2]) + expect(() => parse({ ...golden(), schemaVersion, extra: true })).toThrow( + /Unsupported .* schema version/, + ); + expect(() => parse({ ...golden(), extra: true })).toThrow( + /unknown .* field extra/, + ); + for (const bad of [null, [], "record", 1]) + expect(() => parse(bad)).toThrow(); + }); +} + +test("nested records have no schema version", () => { + for (const [parse, value] of [ + [parseOptionAssessment, option], + [parseIntentionTransition, transition], + ] as const) { + expect(parse(value)).toEqual(value); + expect(() => parse({ ...value, schemaVersion: 1 })).toThrow( + /unknown .* field schemaVersion/, + ); + expect(() => parse(null)).toThrow(); + } +}); + +test("declared vocabulary is exported without adding numeric assessment scores", () => { + expect(MODULE_KINDS).toEqual(["clotho", "lachesis", "atropos"]); + expect(STANCES).toEqual(["prefer", "accept", "oppose", "unavailable"]); + expect(SEVERITIES).toEqual(["commitment_breach", "infeasible", "preference"]); + expect(SITUATIONS).toEqual(["user_request", "autonomous", "transition"]); + expect(INTENTION_KINDS).toEqual([ + "user_commitment", + "autonomous_goal", + "task_binding", + ]); + expect(INTENTION_STATUSES).toEqual([ + "proposed", + "adopted", + "active", + "suspended", + "completed", + "cancelled", + ]); + expect(INTENTION_RELATIONS).toEqual(["depends", "conflicts", "supersedes"]); + expect(ACCEPTED_BY).toEqual(["user", "host_autonomy"]); + expect(ROUND_STATUSES).toEqual(["open", "resolved", "deferred", "held"]); + expect(EXCLUSION_STAGES).toEqual([ + "host_eligibility", + "commitment_protection", + "infeasible", + ]); + expect(() => parseOptionAssessment({ ...option, score: 1 })).toThrow( + /unknown/, + ); +}); + +test("digests canonicalize nested object keys, preserve arrays, and omit undefined fields", () => { + expect(judgmentDigest({ a: 1, b: [{ d: 1, c: 2 }] })).toBe( + judgmentDigest({ b: [{ c: 2, d: 1 }], a: 1 }), + ); + expect(judgmentDigest([1, 2])).not.toBe(judgmentDigest([2, 1])); + expect(judgmentDigest({ a: 1, b: undefined })).toBe(judgmentDigest({ a: 1 })); + expect(snapshotDigest(snapshot)).toBe(judgmentDigest(snapshot)); + expect(intentionDigest(intention)).toBe(judgmentDigest(intention)); + expect(judgmentDigest(profile)).toMatch(/^[a-f0-9]{64}$/); + expect(assessmentInputDigest(assessment)).toBe(assessment.inputDigest); +}); + +test("canonical JSON rejects unsupported top-level values", () => { + for (const value of [undefined, () => 1, Symbol("value"), 1n]) + expect(() => judgmentDigest(value)).toThrow("unsupported canonical value"); +}); + +test("canonical JSON bounds nesting and rejects cycles clearly", () => { + let value: unknown = null; + for (let depth = 0; depth < 64; depth += 1) value = [value]; + expect(judgmentDigest(value)).toBe( + createHash("sha256").update(JSON.stringify(value)).digest("hex"), + ); + expect(() => judgmentDigest([value])).toThrow("canonical value too deep"); + const cyclic: { self?: unknown } = {}; + cyclic.self = cyclic; + expect(() => judgmentDigest(cyclic)).toThrow("canonical value too deep"); +}); + +test("snapshot opaque refs and independently bounded revisions", () => { + for (const field of ["frozenNeuralRef", "observationRef"] as const) { + for (const ref of [null, "any-opaque-id"]) + expect( + parseJudgmentSnapshotRef({ ...snapshot, [field]: ref })[field], + ).toBe(ref); + for (const ref of [42, "", "x".repeat(161)]) + expect(() => + parseJudgmentSnapshotRef({ ...snapshot, [field]: ref }), + ).toThrow(); + } + for (const value of [-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + expect(() => + parseJudgmentSnapshotRef({ ...snapshot, workingRevision: value }), + ).toThrow(); + expect(() => + parseJudgmentSnapshotRef({ + ...snapshot, + domainRevisions: { life: value }, + }), + ).toThrow(); + } + expect(() => + parseJudgmentSnapshotRef({ + ...snapshot, + objectiveProfileRefs: { ...snapshot.objectiveProfileRefs, fourth: {} }, + }), + ).toThrow(/unknown/); + expect(() => + parseJudgmentSnapshotRef({ + ...snapshot, + sourceRefs: [{ ...snapshot.sourceRefs[0], extra: 1 }], + }), + ).toThrow(/unknown/); +}); + +test("snapshot source IDs use context owner bounds without widening agent or unknown-owner IDs", () => { + for (const kind of ["request", "entry", "summary"]) { + for (const length of [160, 161, 256]) { + const source = { kind, id: "x".repeat(length), revision: 0 }; + expect( + parseJudgmentSnapshotRef({ ...snapshot, sourceRefs: [source] }) + .sourceRefs, + ).toEqual([source]); + } + for (const id of ["x".repeat(257), "", " ", "x\u0000y", null, 42]) + expect(() => + parseJudgmentSnapshotRef({ + ...snapshot, + sourceRefs: [{ kind, id, revision: 0 }], + }), + ).toThrow("invalid source id"); + } + for (const kind of ["custom-owner", "Entry", " entry "]) { + const source = { kind, id: "x".repeat(160), revision: 0 }; + expect( + parseJudgmentSnapshotRef({ ...snapshot, sourceRefs: [source] }) + .sourceRefs, + ).toEqual([source]); + expect(() => + parseJudgmentSnapshotRef({ + ...snapshot, + sourceRefs: [{ ...source, id: "x".repeat(161) }], + }), + ).toThrow("invalid source id"); + } + for (const agentId of ["x".repeat(161), "x".repeat(256)]) + expect(() => parseJudgmentSnapshotRef({ ...snapshot, agentId })).toThrow( + "invalid agent id", + ); +}); + +test("3999054856 snapshot rejects conflicting revisions for one logical source", () => { + expect(() => + parseJudgmentSnapshotRef({ + ...snapshot, + sourceRefs: [ + { kind: "request", id: "same-source", revision: 1 }, + { kind: "request", id: "same-source", revision: 2 }, + ], + }), + ).toThrow("duplicate source refs"); + expect( + parseJudgmentSnapshotRef({ + ...snapshot, + sourceRefs: [ + { kind: "request", id: "same-source", revision: 1 }, + { kind: "entry", id: "same-source", revision: 2 }, + { kind: "request", id: "other-source", revision: 2 }, + ], + }).sourceRefs, + ).toHaveLength(3); +}); + +test("snapshot requires a bounded policy identity without a fallback", () => { + const { policyRevision: _revision, ...withoutRevision } = snapshot; + expect(() => parseJudgmentSnapshotRef(withoutRevision)).toThrow(); + for (const policyId of [undefined, null, "", " ", 42, "x".repeat(161)]) + expect(() => parseJudgmentSnapshotRef({ ...snapshot, policyId })).toThrow(); + const withPolicy = { ...snapshot, policyId: "personal.v1" }; + const { policyId: _policyId, ...withoutPolicy } = withPolicy; + expect(() => parseJudgmentSnapshotRef(withoutPolicy)).toThrow(); + expect(parseJudgmentSnapshotRef(withPolicy)).toEqual(withPolicy); + expect(snapshotDigest({ ...withPolicy, policyId: "other-policy" })).not.toBe( + snapshotDigest(withPolicy), + ); +}); + +test("selection bias is bounded even for excluded candidates and zero lambda", () => { + for (const b of [-1 - Number.EPSILON, 1 + Number.EPSILON, -2, 2]) { + for (const p0 of [0, 1]) { + const candidates = [ + { optionKey: "a", p0, b }, + { optionKey: "b", p0: 1 - p0, b: null }, + ]; + expect(() => parseSelectionSpec(signedSpec({ candidates }))).toThrow( + "invalid bias", + ); + } + } + for (const b of [null, -1, -0.5, 0, 0.5, 1]) { + const valid = signedSpec({ candidates: [{ optionKey: "a", p0: 1, b }] }); + expect(parseSelectionSpec(valid)).toEqual(valid); + } +}); + +test("bounded text, ids, lists, uniqueness and canonical list order", () => { + for (const objective of ["", " ", "x\u0000y", "x".repeat(1001)]) + expect(() => parseObjectiveProfile({ ...profile, objective })).toThrow(); + expect(() => + parseObjectiveProfile({ ...profile, objectiveId: "x".repeat(161) }), + ).toThrow(); + expect(() => parseObjectiveProfile({ ...profile, revision: 0 })).toThrow(); + expect(() => + parseObjectiveProfile({ ...profile, comparisonCriteria: ["a", "a"] }), + ).toThrow(); + expect(() => + parseObjectiveProfile({ + ...profile, + comparisonCriteria: Array.from( + { length: 257 }, + (_, i) => `criterion-${i}`, + ), + }), + ).toThrow(); + expect( + parseObjectiveProfile({ ...profile, comparisonCriteria: ["b", "a"] }) + .comparisonCriteria, + ).toEqual(["a", "b"]); + expect( + parseObjectiveProfile({ + ...profile, + comparisonCriteria: Array.from( + { length: 256 }, + (_, i) => `criterion-${i}`, + ), + }).comparisonCriteria, + ).toHaveLength(256); +}); + +test("assessment enforces module detail, input digest, JSON, and recommendation membership", () => { + for (const moduleKind of MODULE_KINDS) + expect(moduleAssessment(moduleKind).moduleKind).toBe(moduleKind); + expect(() => + parseAssessment({ ...assessment, detail: { kind: "values", body: {} } }), + ).toThrow(); + expect(() => + parseAssessment({ ...assessment, inputDigest: "wrong" }), + ).toThrow(); + expect(() => + parseAssessment({ ...assessment, recommendedOptionKeys: ["missing"] }), + ).toThrow(); + expect(() => + parseAssessment({ ...assessment, objectiveAssessments: [option, option] }), + ).toThrow(); + expect(() => + parseAssessment({ ...assessment, proposedOptionKeys: ["a", "a"] }), + ).toThrow(); + for (const body of [ + [], + { bad: undefined }, + { bad: Infinity }, + { bad: () => 1 }, + ]) + expect(() => + parseAssessment({ ...assessment, detail: { kind: "forecasts", body } }), + ).toThrow(); + expect(() => + parseAssessment({ + ...assessment, + detail: { ...assessment.detail, extra: true }, + }), + ).toThrow(/unknown/); +}); + +test("option stance requires exactly the corresponding severity or unavailable reason", () => { + for (const severity of SEVERITIES) + expect( + parseOptionAssessment({ ...option, stance: "oppose", severity }).severity, + ).toBe(severity); + expect( + parseOptionAssessment({ + ...option, + stance: "unavailable", + unavailableReason: "insufficient_evidence", + }).stance, + ).toBe("unavailable"); + for (const change of [ + { stance: "oppose", severity: null }, + { stance: "unavailable", unavailableReason: null }, + { stance: "accept", severity: "preference" }, + { unavailableReason: "unexpected" }, + { stance: "unavailable", unavailableReason: " " }, + { stance: "neutral" }, + ]) + expect(() => parseOptionAssessment({ ...option, ...change })).toThrow(); +}); + +test("commitment opposition can identify the protected intentions explicitly", () => { + const attributed = { + ...option, + stance: "oppose", + severity: "commitment_breach", + breachedIntentionIds: ["intention-a", "intention-b"], + }; + expect(JSON.stringify(parseOptionAssessment(attributed))).toBe( + JSON.stringify(attributed), + ); + for (const breachedIntentionIds of [ + [""], + ["a", "a"], + ["x".repeat(161)], + "not-an-array", + ]) + expect(() => + parseOptionAssessment({ ...attributed, breachedIntentionIds }), + ).toThrow(); + expect(() => + parseOptionAssessment({ + ...attributed, + stance: "prefer", + severity: null, + }), + ).toThrow(); +}); + +test("assessment sets require one of each module in declared order and matching snapshot", () => { + const set = assessmentSet(); + expect(parseAssessmentSet(set).assessments.map((a) => a.moduleKind)).toEqual([ + ...MODULE_KINDS, + ]); + for (const assessments of [ + set.assessments.slice(0, 2), + [assessment, assessment, moduleAssessment("atropos")], + [...set.assessments].reverse(), + set.assessments.map((a) => ({ ...a, snapshotId: "other" })), + ]) + expect(() => parseAssessmentSet({ ...set, assessments })).toThrow(); + expect(() => + parseAssessmentSet({ ...set, snapshotDigest: "other" }), + ).toThrow(); +}); + +test("selection rejects invalid mass, lambda, bias, order, duplicates, and tampered digests", () => { + for (const candidates of [ + [], + [{ optionKey: "a", p0: 0, b: null }], + [{ optionKey: "a", p0: 0.9, b: null }], + [ + { optionKey: "a", p0: -1, b: null }, + { optionKey: "b", p0: 2, b: null }, + ], + [{ optionKey: "a", p0: Infinity, b: null }], + [{ optionKey: "a", p0: 1, b: NaN }], + [...spec.candidates].reverse(), + [ + { optionKey: "a", p0: 0.5, b: null }, + { optionKey: "a", p0: 0.5, b: null }, + ], + ]) + expect(() => parseSelectionSpec(signedSpec({ candidates }))).toThrow(); + for (const lambda of [-1, Infinity, NaN]) + expect(() => parseSelectionSpec(signedSpec({ lambda }))).toThrow(); + expect(() => + parseSelectionSpec({ ...spec, eligibleDigest: "wrong" }), + ).toThrow(); + expect(() => parseSelectionSpec({ ...spec, specDigest: "wrong" })).toThrow(); + const withExcluded = signedSpec({ + candidates: [ + { optionKey: "a", p0: 1, b: -1 }, + { optionKey: "b", p0: 0, b: null }, + ], + }); + expect(parseSelectionSpec(withExcluded)).toEqual(withExcluded); + expect(withExcluded.eligibleDigest).toBe(judgmentDigest(["a"])); + expect( + parseSelectionSpec( + signedSpec({ candidates: [{ optionKey: "a", p0: 1 - 5e-13, b: null }] }), + ).candidates, + ).toHaveLength(1); +}); + +test("resolution hold reason is present exactly when unresolved", () => { + for (const status of ["held", "deferred"] as const) { + expect( + parseResolutionRecord({ + ...resolution, + status, + holdReason: "no eligible candidate", + }).status, + ).toBe(status); + expect(() => parseResolutionRecord({ ...resolution, status })).toThrow(); + } + expect(() => + parseResolutionRecord({ ...resolution, holdReason: "unexpected" }), + ).toThrow(); + expect(() => + parseResolutionRecord({ ...resolution, status: "open" }), + ).toThrow(); + expect(() => + parseResolutionRecord({ ...resolution, order: ["clotho", "clotho"] }), + ).toThrow(); + expect(() => + parseResolutionRecord({ + ...resolution, + ranking: [{ optionKey: "a", rank: 0 }], + }), + ).toThrow(); +}); + +test("intention acceptance requires user authority only for user commitments", () => { + for (const kind of INTENTION_KINDS) { + for (const acceptedBy of ACCEPTED_BY) { + const record = { + ...intention, + kind, + acceptance: { ...intention.acceptance, acceptedBy }, + }; + if (kind === "user_commitment" && acceptedBy === "host_autonomy") { + const adopted = { ...adopt(), kind, acceptance: record.acceptance }; + for (const candidate of [record, adopted]) + expect(() => parseIntentionRecord(candidate)).toThrow( + "user commitment requires user acceptance", + ); + expect(() => adopt(record)).toThrow( + "user commitment requires user acceptance", + ); + } else { + expect(parseIntentionRecord(record)).toEqual(record); + expect(parseIntentionRecord(adopt(record))).toEqual(adopt(record)); + } + } + } +}); + +for (const relation of ["conflicts", "supersedes", "depends"] as const) { + test(`intention rejects self-${relation} while retaining an independent target`, () => { + const independent = { + ...intention, + relatedIntentions: [{ intentionId: "intention-2", relation }], + }; + expect(parseIntentionRecord(independent)).toEqual(independent); + expect(() => + parseIntentionRecord({ + ...intention, + relatedIntentions: [{ intentionId: intention.intentionId, relation }], + }), + ).toThrow("self-referential intention relation"); + }); +} + +function adopt(record = intention): IntentionRecord { + return transitionIntention(record, { + to: "adopted", + reason: "accepted", + evidenceRef: null, + at: transition.at, + }); +} +function activate(): IntentionRecord { + return transitionIntention(adopt(), { + to: "active", + reason: "started", + evidenceRef: null, + at: transition.at, + }); +} + +const laterAdoption = transitionIntention(intention, { + to: "adopted", + reason: "accepted", + evidenceRef: null, + at: "2026-09-12T00:00:00.000Z", +}); +for (const { label, source, change } of [ + { + label: "before acceptance", + source: intention, + change: { + to: "adopted" as const, + reason: "accepted", + evidenceRef: null, + at: "2026-09-10T00:00:00.000Z", + }, + }, + { + label: "before previous transition", + source: laterAdoption, + change: { + to: "active" as const, + reason: "started", + evidenceRef: null, + at: "2026-09-11T12:00:00.000Z", + }, + }, +]) { + test(`3999091815 restored history rejects ${label}`, () => { + expect(() => + parseIntentionRecord({ + ...source, + revision: source.revision + 1, + status: change.to, + history: [...source.history, { ...change, from: source.status }], + }), + ).toThrow("nonchronological intention history"); + }); + test(`3999091815 pure transition rejects ${label}`, () => { + expect(() => transitionIntention(source, change)).toThrow( + "nonchronological intention history", + ); + }); +} +test.each(["2026-09-12T00:00:00.000Z", "2026-09-12T01:00:00.000Z"])( + "3999091815 equal or increasing transition time remains valid: %s", + (at) => { + const result = transitionIntention(laterAdoption, { + to: "active", + reason: "started", + evidenceRef: null, + at, + }); + expect(parseIntentionRecord(result)).toEqual(result); + expect(result.history.at(-1)?.at).toBe(at); + }, +); + +test("intention transitions complete the legal lifecycle without mutating input", () => { + let record = intention; + for (const to of [ + "adopted", + "active", + "suspended", + "active", + "completed", + ] as const) { + const before = structuredClone(record); + const evidenceRef = + to === "completed" + ? "outcome-1" + : to === "suspended" || record.status === "suspended" + ? intention.acceptance.sourceRef + : null; + const next = transitionIntention(record, { + to, + reason: "fixture transition", + evidenceRef, + at: transition.at, + }); + expect(record).toEqual(before); + expect(next).not.toBe(record); + expect(next.history).not.toBe(record.history); + expect(next.revision).toBe(record.revision + 1); + expect(next.status).toBe(to); + expect(next.history.at(-1)).toEqual({ + from: record.status, + to, + reason: "fixture transition", + evidenceRef, + at: transition.at, + }); + expect(parseIntentionRecord(next)).toEqual(next); + record = next; + } + for (const to of INTENTION_STATUSES) + expect(() => + transitionIntention(record, { + to, + reason: "attempt", + evidenceRef: "outcome-1", + at: transition.at, + }), + ).toThrow(`invalid intention transition: completed -> ${to}`); +}); + +const malformedTransitionSources: IntentionRecord[] = [ + { ...intention, revision: -1 }, + { ...intention, revision: 1 }, + { + ...intention, + acceptance: { ...intention.acceptance, acceptedBy: "host_autonomy" }, + }, + { + ...intention, + relatedIntentions: [ + { intentionId: intention.intentionId, relation: "depends" as const }, + ], + }, +]; +test.each(malformedTransitionSources)( + "3998986145 transitions reject malformed source records: %j", + (record) => { + const before = structuredClone(record); + expect(() => + transitionIntention(record, { + to: "adopted", + reason: "accepted", + evidenceRef: null, + at: transition.at, + }), + ).toThrow(); + expect(record).toEqual(before); + }, +); + +test("caller-supplied from cannot skip intention states", () => { + const forged = { + ...transition, + from: "active", + to: "completed" as const, + evidenceRef: "outcome-1", + }; + expect(() => transitionIntention(intention, forged)).toThrow( + /intention transition/, + ); + expect(intention.status).toBe("proposed"); + expect(intention.history).toEqual([]); +}); + +test("intention changes reject unknown fields", () => { + const change = { + to: "adopted" as const, + reason: "accepted", + evidenceRef: null, + at: transition.at, + }; + for (const extra of [{ unexpected: true }, { from: "proposed" }]) + expect(() => + transitionIntention(intention, { ...change, ...extra }), + ).toThrow(/intention transition/); +}); + +test("intention transition table is frozen at every level", () => { + expect(Object.isFrozen(INTENTION_TRANSITIONS)).toBe(true); + for (const edges of Object.values(INTENTION_TRANSITIONS)) + expect(Object.isFrozen(edges)).toBe(true); +}); + +test("assignment cannot enable a forbidden intention edge", () => { + const original = INTENTION_TRANSITIONS.proposed; + try { + expect(Reflect.set(INTENTION_TRANSITIONS, "proposed", ["completed"])).toBe( + false, + ); + expect(Reflect.set(original, "0", "completed")).toBe(false); + const change = { + to: "completed" as const, + reason: "done", + evidenceRef: "outcome-1", + at: transition.at, + }; + expect(() => transitionIntention(intention, change)).toThrow( + "invalid intention transition", + ); + expect(() => + parseIntentionRecord({ + ...intention, + revision: 1, + status: "completed", + history: [{ ...change, from: "proposed" }], + }), + ).toThrow("invalid intention transition"); + expect(parseIntentionRecord(adopt()).status).toBe("adopted"); + } finally { + Reflect.set(INTENTION_TRANSITIONS, "proposed", original); + Reflect.set(original, "0", "adopted"); + } +}); + +test("intention table and all prohibited edges are explicit", () => { + expect(INTENTION_TRANSITIONS).toEqual({ + proposed: ["adopted", "cancelled"], + adopted: ["active", "suspended", "cancelled"], + active: ["suspended", "completed", "cancelled"], + suspended: ["active", "cancelled"], + completed: [], + cancelled: [], + }); + const active = activate(); + const suspended = transitionIntention(active, { + to: "suspended", + reason: "pause", + evidenceRef: intention.acceptance.sourceRef, + at: transition.at, + }); + const completed = transitionIntention(active, { + to: "completed", + reason: "done", + evidenceRef: "outcome-1", + at: transition.at, + }); + const cancelled = transitionIntention(intention, { + to: "cancelled", + reason: "withdrawn", + evidenceRef: intention.acceptance.sourceRef, + at: transition.at, + }); + for (const record of [ + intention, + adopt(), + active, + suspended, + completed, + cancelled, + ]) { + for (const to of INTENTION_STATUSES) { + const change = { + to, + reason: "fixture edge", + evidenceRef: + to === "completed" ? "outcome-1" : intention.acceptance.sourceRef, + at: transition.at, + }; + if (INTENTION_TRANSITIONS[record.status].includes(to)) { + expect( + parseIntentionRecord({ + ...record, + status: to, + revision: record.revision + 1, + history: [...record.history, { ...change, from: record.status }], + }).status, + ).toBe(to); + if ( + to === "cancelled" && + ["adopted", "active", "suspended"].includes(record.status) + ) + expect(() => transitionIntention(record, change)).toThrow( + "user commitment cancellation authority is unavailable", + ); + else + expect( + parseIntentionRecord(transitionIntention(record, change)).status, + ).toBe(to); + } else + expect(() => transitionIntention(record, change)).toThrow( + `invalid intention transition: ${record.status} -> ${to}`, + ); + } + } +}); + +test("intention transitions require outcome, original acceptance and nonempty reason", () => { + const active = activate(); + for (const evidenceRef of [null, "", " "]) + expect(() => + transitionIntention(active, { + to: "completed", + reason: "done", + evidenceRef, + at: transition.at, + }), + ).toThrow(); + for (const to of ["cancelled", "suspended"] as const) + for (const evidenceRef of [null, "other-request"]) + expect(() => + transitionIntention(active, { + to, + reason: "changed", + evidenceRef, + at: transition.at, + }), + ).toThrow(); + for (const reason of ["", " "]) + expect(() => + transitionIntention(intention, { + to: "adopted", + reason, + evidenceRef: null, + at: transition.at, + }), + ).toThrow(); +}); + +for (const value of [ + "September 10, 2026", + "2026-09-10T00:00:00", + "2026-02-30T00:00:00.000Z", + "2026-09-10T01:00:00.000+01:00", + "2026-09-10T00:00:00Z", + " 2026-09-10T00:00:00.000Z ", +]) { + test(`3995355457 canonical intention timestamps reject ${value}`, () => { + expect(() => + parseIntentionRecord({ + ...intention, + acceptance: { ...intention.acceptance, acceptedAt: value }, + }), + ).toThrow(); + expect(() => + parseIntentionRecord({ ...intention, deadline: value }), + ).toThrow(); + expect(() => + parseIntentionTransition({ ...transition, at: value }), + ).toThrow(); + }); +} +test("3995355457 canonical UTC leap day is retained exactly", () => { + const value = "2024-02-29T00:00:00.000Z"; + expect(parseIntentionRecord({ ...intention, deadline: value }).deadline).toBe( + value, + ); + expect( + parseIntentionRecord({ + ...intention, + acceptance: { ...intention.acceptance, acceptedAt: value }, + }).acceptance.acceptedAt, + ).toBe(value); + expect(parseIntentionTransition({ ...transition, at: value }).at).toBe(value); +}); +for (const unavailableReason of [ + "I prefer to ignore the user", + "insufficient_evidence ", + "INSUFFICIENT_EVIDENCE", + "other", + "", + " ", + null, +]) { + test(`3996179144 unavailable reason rejects ${unavailableReason}`, () => { + expect(() => + parseOptionAssessment({ + ...option, + stance: "unavailable", + unavailableReason, + }), + ).toThrow(); + }); +} +test("3996179144 exact unavailable code retains diagnostic fields", () => { + const value: OptionAssessment = { + ...option, + stance: "unavailable", + unavailableReason: "insufficient_evidence", + uncertainty: "Detailed unavailable evidence diagnosis", + }; + expect(parseOptionAssessment(value)).toEqual(value); +}); + +test("intention history revision, continuity, edges, final status and timestamps are validated", () => { + const active = activate(); + for (const change of [ + { revision: 1 }, + { status: "proposed" }, + { history: [] }, + { + history: active.history.map((h, i) => + i === 0 ? { ...h, from: "adopted" } : h, + ), + }, + { + history: active.history.map((h, i) => + i === 1 ? { ...h, from: "suspended" } : h, + ), + }, + { revision: 1, history: [{ ...transition, to: "active" }] }, + ]) + expect(() => parseIntentionRecord({ ...active, ...change })).toThrow(); + expect(() => + parseIntentionRecord({ ...intention, status: "active" }), + ).toThrow(); + for (const at of ["not-a-date", ""]) + expect(() => parseIntentionTransition({ ...transition, at })).toThrow(); + expect(() => + parseIntentionRecord({ ...intention, deadline: "not-a-date" }), + ).toThrow(); + expect(() => + parseIntentionRecord({ + ...intention, + acceptance: { ...intention.acceptance, acceptedAt: "not-a-date" }, + }), + ).toThrow(); + expect(() => + parseIntentionRecord({ + ...intention, + acceptance: { ...intention.acceptance, extra: true }, + }), + ).toThrow(/unknown/); + expect(() => + parseIntentionRecord({ + ...active, + history: active.history.map((h) => ({ ...h, schemaVersion: 1 })), + }), + ).toThrow(/unknown .* field schemaVersion/); +}); diff --git a/packages/lina-core/test/judgment-dialogue-contract.test.ts b/packages/lina-core/test/judgment-dialogue-contract.test.ts new file mode 100644 index 0000000..19c3def --- /dev/null +++ b/packages/lina-core/test/judgment-dialogue-contract.test.ts @@ -0,0 +1,618 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import * as api from "../src/agents/index.ts"; +import { Fixture } from "./fixture.ts"; + +let fixture: Fixture; +let path: string; +let store: api.JudgmentStore; +let snapshot: api.JudgmentSnapshotRef; +let assessments: api.Assessment[]; +beforeEach(() => { + fixture = new Fixture(); + path = join(fixture.dir, "dialogue.sqlite"); + store = new api.JudgmentStore(path); + for (const moduleKind of api.MODULE_KINDS) { + const ref = store.putObjectiveProfile({ + schemaVersion: 1, + objectiveId: moduleKind, + moduleKind, + revision: 1, + objective: "Compare", + comparisonCriteria: [], + reconsiderationConditions: [], + }); + store.activateObjectiveProfile("agent", "scope", ref); + } + const refs = store.activeObjectiveProfiles("agent", "scope"); + if (!refs) throw Error("missing profiles"); + snapshot = { + schemaVersion: 1, + roundId: "dialogue", + agentId: "agent", + scopeId: "scope", + sourceRefs: [{ kind: "request", id: "request", revision: 0 }], + dialogueSource: { + requestId: "request", + requestDigest: api.judgmentDigest({ request: "original" }), + sourceDigest: api.judgmentDigest({ source: "original" }), + }, + workingRevision: 0, + instructionRevision: 0, + policyId: "personal.v1", + policyRevision: 1, + identityRevision: 0, + domainRevisions: {}, + intentionRevision: store.intentionRevision("agent", "scope"), + objectiveProfileRefs: refs, + observationRef: null, + frozenNeuralRef: null, + situation: "user_request", + clockId: "clock", + sequence: 1, + bindingGeneration: 0, + }; + assessments = api.MODULE_KINDS.map((moduleKind) => { + const input = { + snapshotDigest: api.snapshotDigest(snapshot), + objectiveRef: refs[moduleKind], + mechanismRevision: 1, + }; + return api.parseAssessment({ + schemaVersion: 1, + moduleKind, + snapshotId: snapshot.roundId, + ...input, + inputDigest: api.assessmentInputDigest(input), + completeText: `Initial ${moduleKind} judgment`, + evidenceRefs: ["source"], + proposedOptionKeys: [], + objectiveAssessments: [], + recommendedOptionKeys: [], + detail: { + kind: { + clotho: "forecasts", + lachesis: "values", + atropos: "continuity", + }[moduleKind], + body: {}, + }, + diagnostics: {}, + }); + }); +}); +afterEach(() => { + store.close(); + fixture.close(); +}); +function record() { + return { + schemaVersion: 2 as const, + mode: "dialogue" as const, + roundId: snapshot.roundId, + snapshotDigest: api.snapshotDigest(snapshot), + objectiveProfileRefs: snapshot.objectiveProfileRefs, + assessmentDigests: { + clotho: api.judgmentDigest(assessments[0]), + lachesis: api.judgmentDigest(assessments[1]), + atropos: api.judgmentDigest(assessments[2]), + }, + policyId: snapshot.policyId, + policyRevision: snapshot.policyRevision, + situation: snapshot.situation, + requestId: "request", + requestDigest: api.judgmentDigest({ request: "original" }), + sourceDigest: api.judgmentDigest({ source: "original" }), + recommendations: { + clotho: "Explain likely outcomes", + lachesis: "Respect the user's priorities", + atropos: "Retain the original commitment", + }, + alignment: "aligned" as const, + conflicts: [], + concessions: [], + synthesis: "A textual response synthesis", + rationale: "Reasons for this synthesis", + status: "resolved" as const, + holdReason: null, + }; +} +function seed(count = 3) { + store.openRound(snapshot); + for (const a of assessments.slice(0, count)) store.putAssessment(a); +} +function reopen() { + store.close(); + store = new api.JudgmentStore(path); +} +function db() { + return fixture.keep(new DatabaseSync(path)); +} + +for (const [field, value] of [ + ["requestDigest", "0".repeat(64)], + ["sourceDigest", "1".repeat(64)], +] as const) + for (const boundary of ["reference", "store", "reopen"] as const) + test(`3999243465 ${boundary} rejects an invented ${field}`, () => { + seed(); + const original = record(); + const forged = { ...original, [field]: value }; + switch (boundary) { + case "reference": + expect(() => + api.buildDialogueJudgmentRef({ + snapshot, + assessments, + resolution: forged, + }), + ).toThrow("dialogue source digest mismatch"); + break; + case "store": + expect(() => + store.recordResolution(snapshot.roundId, forged, null), + ).toThrow("dialogue source digest mismatch"); + expect(store.getResolution(snapshot.roundId)).toBeNull(); + break; + case "reopen": + store.recordResolution(snapshot.roundId, original, null); + db() + .prepare( + "UPDATE resolution_records SET body = ?, digest = ? WHERE round_id = ?", + ) + .run( + JSON.stringify(forged), + api.judgmentDigest(forged), + snapshot.roundId, + ); + reopen(); + expect(() => store.dialogueJudgmentRef(snapshot.roundId)).toThrow( + "dialogue source digest mismatch", + ); + break; + } + }); + +test("3996179135 action v1 bytes and digest remain unchanged alongside dialogue v2", () => { + const { dialogueSource: _source, ...legacySnapshot } = snapshot; + const actionSnapshot = { + ...legacySnapshot, + roundId: "historic-action", + sequence: 0, + }; + expect(api.parseJudgmentSnapshotRef(actionSnapshot)).toEqual(actionSnapshot); + const action = api.parseResolutionRecord({ + schemaVersion: 1, + roundId: actionSnapshot.roundId, + policyId: snapshot.policyId, + policyRevision: snapshot.policyRevision, + situation: snapshot.situation, + order: ["atropos", "clotho", "lachesis"], + recommendations: { clotho: [], lachesis: [], atropos: [] }, + conflicts: [], + excluded: [], + abstentions: [ + { + optionKey: "historical", + moduleKind: "clotho", + reason: "historic diagnostic text", + }, + ], + ranking: [], + conceded: [], + status: "held", + holdReason: "historical missing input", + }); + store.openRound(actionSnapshot); + store.recordResolution(actionSnapshot.roundId, action, null); + const sql = db(); + const original = sql + .prepare("SELECT body,digest FROM resolution_records WHERE round_id = ?") + .get(actionSnapshot.roundId); + seed(); + store.recordResolution(snapshot.roundId, record(), null); + reopen(); + expect(api.parseStoredResolutionRecord(action)).toEqual(action); + expect(store.getResolution(actionSnapshot.roundId, "action")).toEqual(action); + expect(store.getResolution(actionSnapshot.roundId, "dialogue")).toBeNull(); + expect(store.dialogueJudgmentRef(actionSnapshot.roundId)).toBeNull(); + expect(store.getResolution(snapshot.roundId, "action")).toBeNull(); + expect( + sql + .prepare("SELECT body,digest FROM resolution_records WHERE round_id = ?") + .get(actionSnapshot.roundId), + ).toEqual(original); + expect(original?.["digest"]).toBe(api.judgmentDigest(action)); +}); + +for (const field of ["requestDigest", "sourceDigest"] as const) + for (const value of ["not-a-digest", "A".repeat(64), "0".repeat(63), null]) + test(`3999243465 snapshot rejects malformed ${field}: ${String(value)}`, () => { + expect(() => + api.parseJudgmentSnapshotRef({ + ...snapshot, + dialogueSource: { ...snapshot.dialogueSource, [field]: value }, + }), + ).toThrow(); + }); + +test("3999243465 frozen source metadata is bound to a request source and snapshot digest", () => { + const source = snapshot.dialogueSource; + if (!source) throw Error("missing frozen source"); + expect(() => + api.parseJudgmentSnapshotRef({ + ...snapshot, + dialogueSource: { ...source, requestId: "other-request" }, + }), + ).toThrow("dialogue request source mismatch"); + const changed = api.parseJudgmentSnapshotRef({ + ...snapshot, + dialogueSource: { ...source, sourceDigest: "a".repeat(64) }, + }); + expect(api.snapshotDigest(changed)).not.toBe(api.snapshotDigest(snapshot)); + expect(changed.dialogueSource?.sourceDigest).toBe("a".repeat(64)); +}); + +test("3999243465 even held dialogue requires source digests frozen by the Host", () => { + const { dialogueSource: _source, ...legacySnapshot } = snapshot; + store.openRound(legacySnapshot); + expect(() => + store.recordResolution( + snapshot.roundId, + { + ...record(), + snapshotDigest: api.snapshotDigest(legacySnapshot), + assessmentDigests: { clotho: null, lachesis: null, atropos: null }, + recommendations: { clotho: null, lachesis: null, atropos: null }, + alignment: "incomplete", + status: "held", + holdReason: "missing input", + }, + null, + ), + ).toThrow("dialogue source provenance missing"); + expect(store.getResolution(snapshot.roundId)).toBeNull(); +}); + +test("3996179135 dialogue v2 golden round-trip retains prose and immutable provenance", () => { + const original = record(); + const parsed = api.parseDialogueResolutionRecord( + JSON.parse(JSON.stringify(original)), + ); + expect(parsed).toEqual(original); + expect(api.judgmentDigest(parsed)).toBe(api.judgmentDigest(original)); + expect(api.parseStoredResolutionRecord(original)).toEqual(original); + const { + schemaVersion: _version, + mode: _mode, + roundId: _round, + snapshotDigest: _digest, + objectiveProfileRefs: _refs, + assessmentDigests: _assessments, + policyId: _policy, + policyRevision: _revision, + situation: _situation, + ...input + } = original; + expect( + api.buildDialogueResolution({ ...input, snapshot, assessments }), + ).toEqual(original); +}); +for (const change of [ + { schemaVersion: 1 }, + { schemaVersion: 3 }, + { mode: "action" }, + { extra: true }, + { ranking: [] }, + { candidates: [] }, + { assessmentSetDigest: "fake" }, + { selectionSpec: null }, + { assessmentDigests: { clotho: "a", lachesis: "b" } }, + { assessmentDigests: { clotho: null, lachesis: null, atropos: null } }, + { recommendations: { clotho: "text", lachesis: "text" } }, + { rationale: " " }, + { synthesis: "" }, + { alignment: "conflicted" }, +]) + test(`3996179135 strict dialogue rejects ${JSON.stringify(change)}`, () => { + expect(() => + api.parseDialogueResolutionRecord({ ...record(), ...change }), + ).toThrow(); + }); + +test("3996179135 resolved candidate-free dialogue persists with zero selection/candidate rows and reconstructible ref", () => { + seed(); + const original = record(); + store.recordResolution(snapshot.roundId, original, null); + const ref = store.dialogueJudgmentRef(snapshot.roundId); + expect(ref).not.toBeNull(); + if (!ref) throw Error("missing dialogue ref"); + expect(api.parseDialogueJudgmentRef(JSON.parse(JSON.stringify(ref)))).toEqual( + ref, + ); + expect(ref.resolutionDigest).toBe(api.judgmentDigest(original)); + expect(ref.assessmentDigests).toEqual(original.assessmentDigests); + expect(ref.requestDigest).toBe(original.requestDigest); + expect(ref.sourceDigest).toBe(original.sourceDigest); + expect(() => store.validateDialogueJudgmentRef(ref)).not.toThrow(); + for (const changed of [ + { ...ref, schemaVersion: 2 }, + { ...ref, extra: true }, + { + ...ref, + assessmentDigests: { + clotho: ref.assessmentDigests.clotho, + lachesis: ref.assessmentDigests.lachesis, + }, + }, + { ...ref, refDigest: api.judgmentDigest("wrong") }, + ]) + expect(() => api.parseDialogueJudgmentRef(changed)).toThrow(); + for (const field of [ + "requestDigest", + "sourceDigest", + "snapshotDigest", + "resolutionDigest", + ] as const) { + const changed = { ...ref, [field]: api.judgmentDigest("tampered") }; + const forged = { + ...changed, + refDigest: api.judgmentDigest({ ...changed, refDigest: undefined }), + }; + expect(() => store.validateDialogueJudgmentRef(forged)).toThrow(); + } + const sql = db(); + for (const table of ["candidate_sets", "selection_specs"]) + expect(sql.prepare(`SELECT count(*) AS n FROM ${table}`).get()).toEqual({ + n: 0, + }); + expect(sql.prepare("SELECT count(*) AS n FROM assessments").get()).toEqual({ + n: 3, + }); + const first = assessments[0]; + if (!first) throw Error("missing fixture assessment"); + expect(() => store.putAssessment(first)).toThrow("not open"); + reopen(); + expect(store.getResolution(snapshot.roundId)).toEqual(original); + expect(store.getResolution(snapshot.roundId, "dialogue")).toEqual(original); + expect(store.dialogueJudgmentRef(snapshot.roundId)).toEqual(ref); + expect(store.assessmentSet(snapshot.roundId).assessments).toEqual( + assessments, + ); + expect(store.getSelectionSpec(snapshot.roundId)).toBeNull(); +}); + +test("3996179135 dialogue forbids SelectionSpec and action candidate closure atomically", () => { + seed(); + const value = record(); + const specBody = { + schemaVersion: 1, + roundId: snapshot.roundId, + snapshotDigest: api.snapshotDigest(snapshot), + assessmentSetDigest: api.judgmentDigest( + store.assessmentSet(snapshot.roundId), + ), + objectiveProfileRefs: snapshot.objectiveProfileRefs, + resolutionDigest: api.judgmentDigest(value), + policyId: snapshot.policyId, + policyRevision: snapshot.policyRevision, + situation: snapshot.situation, + lambda: 0, + candidates: [{ optionKey: "fake", p0: 1, b: null }], + eligibleDigest: api.judgmentDigest(["fake"]), + }; + const spec = api.parseSelectionSpec({ + ...specBody, + specDigest: api.judgmentDigest(specBody), + }); + expect(() => store.recordResolution(snapshot.roundId, value, spec)).toThrow(); + expect(store.getResolution(snapshot.roundId)).toBeNull(); + const option = api.buildCanonicalOption({ + kind: "noop", + actor: { agentId: "agent", scopeId: "scope" }, + targetId: null, + args: {}, + preconditions: { kind: "noop", reason: "wait" }, + }); + store.closeCandidateSet( + api.buildCandidateSet({ + roundId: snapshot.roundId, + snapshotDigest: api.snapshotDigest(snapshot), + options: [option], + eligibility: [ + { optionKey: option.optionKey, eligible: true, reason: null }, + ], + intentionRefs: [], + }), + ); + expect(() => store.recordResolution(snapshot.roundId, value, null)).toThrow(); + expect(store.getRound(snapshot.roundId)?.status).toBe("open"); +}); +for (const tamper of [ + "missing", + "assessmentDigest", + "objective", + "snapshot", + "request", +] as const) { + test(`3996179135 dialogue rejects ${tamper} on write and rehashed historical restore`, () => { + seed(); + const original = record(); + const changed = structuredClone(original); + if (tamper === "assessmentDigest") + changed.assessmentDigests.clotho = api.judgmentDigest("changed"); + if (tamper === "objective") + changed.objectiveProfileRefs.clotho.digest = + api.judgmentDigest("changed"); + if (tamper === "snapshot") + changed.snapshotDigest = api.judgmentDigest("changed"); + if (tamper === "request") changed.requestId = "other-request"; + if (tamper === "missing") + db().exec("DELETE FROM assessments WHERE module_kind = 'atropos'"); + expect(() => + store.recordResolution(snapshot.roundId, changed, null), + ).toThrow(); + expect(store.getResolution(snapshot.roundId)).toBeNull(); + if (tamper === "missing") { + const third = assessments[2]; + if (!third) throw Error("missing fixture assessment"); + store.putAssessment(third); + } + store.recordResolution(snapshot.roundId, original, null); + if (tamper === "missing") + db().exec("DELETE FROM assessments WHERE module_kind = 'atropos'"); + else + db() + .prepare("UPDATE resolution_records SET body = ?, digest = ?") + .run(JSON.stringify(changed), api.judgmentDigest(changed)); + expect(() => store.getResolution(snapshot.roundId)).toThrow(); + reopen(); + expect(() => store.dialogueJudgmentRef(snapshot.roundId)).toThrow(); + }); +} + +test.each(["held", "deferred"] as const)( + "3999054852 incomplete %s dialogue preserves only available module refs", + (status) => { + seed(2); + const value = { + ...record(), + status, + holdReason: + status === "held" ? "missing module" : "evaluation budget exhausted", + alignment: "incomplete" as const, + assessmentDigests: { ...record().assessmentDigests, atropos: null }, + recommendations: { ...record().recommendations, atropos: null }, + }; + store.recordResolution(snapshot.roundId, value, null); + reopen(); + expect(store.getResolution(snapshot.roundId)).toEqual(value); + expect(store.getRound(snapshot.roundId)?.status).toBe(status); + expect(store.dialogueJudgmentRef(snapshot.roundId)).toBeNull(); + }, +); + +test("3996179135 conflicted dialogue retains textual conflict/concession reasons without option keys", () => { + seed(); + const value = { + ...record(), + alignment: "conflicted" as const, + conflicts: [ + { + moduleKind: "clotho" as const, + reason: "Forecast conflicts with continuity", + }, + ], + concessions: [ + { + moduleKind: "lachesis" as const, + reason: "Give continuity priority here", + }, + ], + }; + store.recordResolution(snapshot.roundId, value, null); + reopen(); + expect(store.getResolution(snapshot.roundId)).toEqual(value); +}); + +test("3998853070 dialogue checks intention currentness only on resolution, not history", () => { + seed(); + const original = record(); + store.recordResolution(snapshot.roundId, original, null); + const second = { ...snapshot, roundId: "second", sequence: 2 }; + store.openRound(second); + store.putIntention({ + schemaVersion: 1, + intentionId: "new-intention", + agentId: snapshot.agentId, + scopeId: snapshot.scopeId, + revision: 0, + kind: "user_commitment", + purposeRef: "purpose", + text: "New commitment", + acceptance: { + sourceRef: "request", + acceptedBy: "user", + policyRevision: 1, + acceptedAt: "2026-09-12T00:00:00.000Z", + }, + priority: 0, + deadline: null, + completionCondition: "receipt", + abortConditions: [], + relatedIntentions: [], + status: "proposed", + history: [], + }); + expect(() => + store.recordResolution( + second.roundId, + { ...original, roundId: second.roundId }, + null, + ), + ).toThrow("stale intention revision"); + expect(store.getRound(second.roundId)?.status).toBe("open"); + expect(store.getResolution(second.roundId)).toBeNull(); + reopen(); + expect(store.getResolution(snapshot.roundId)).toEqual(original); + expect(store.dialogueJudgmentRef(snapshot.roundId)).not.toBeNull(); +}); + +test("3996179135 action-shaped initial assessment cannot masquerade as dialogue", () => { + const first = assessments[0]; + if (!first) throw Error("missing fixture assessment"); + const changed = api.parseAssessment({ + ...first, + proposedOptionKeys: ["fake-option"], + }); + assessments[0] = changed; + seed(); + expect(() => + store.recordResolution(snapshot.roundId, record(), null), + ).toThrow("dialogue forbids action assessments"); + expect(store.getResolution(snapshot.roundId)).toBeNull(); +}); + +test("3996179135 rehashed assessment rewrite disagrees with immutable dialogue refs", () => { + seed(); + store.recordResolution(snapshot.roundId, record(), null); + const first = assessments[0]; + if (!first) throw Error("missing fixture assessment"); + const changed = { ...first, completeText: "Rewritten initial opinion" }; + db() + .prepare( + "UPDATE assessments SET body = ?, digest = ? WHERE module_kind = 'clotho'", + ) + .run(JSON.stringify(changed), api.judgmentDigest(changed)); + expect(() => store.getResolution(snapshot.roundId)).toThrow( + "dialogue assessment digest mismatch", + ); + reopen(); + expect(() => store.getResolution(snapshot.roundId)).toThrow( + "dialogue assessment digest mismatch", + ); +}); + +test("3998853057 dialogue rejects new stale objective resolution but keeps already resolved history", () => { + seed(); + const original = record(); + store.recordResolution(snapshot.roundId, original, null); + const second = { ...snapshot, roundId: "second", sequence: 2 }; + store.openRound(second); + const profile = store.getObjectiveProfile("clotho", 1); + if (!profile) throw Error("missing fixture profile"); + const next = store.putObjectiveProfile({ ...profile, revision: 2 }); + store.activateObjectiveProfile("agent", "scope", next); + expect(() => + store.recordResolution( + second.roundId, + { ...original, roundId: second.roundId }, + null, + ), + ).toThrow("stale objective profile refs"); + reopen(); + expect(store.getResolution(snapshot.roundId)).toEqual(original); + expect(store.dialogueJudgmentRef(snapshot.roundId)).not.toBeNull(); + expect(store.getResolution(second.roundId)).toBeNull(); +}); diff --git a/packages/lina-core/test/judgment-integrity-review.test.ts b/packages/lina-core/test/judgment-integrity-review.test.ts new file mode 100644 index 0000000..1d28f6f --- /dev/null +++ b/packages/lina-core/test/judgment-integrity-review.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from "bun:test"; +import { + type IntentionRecord, + parseIntentionRecord, + transitionIntention, +} from "../src/agents/index.ts"; + +const at = "2026-09-12T00:00:00.000Z"; +function suspendedIntention(): IntentionRecord { + let record: IntentionRecord = { + schemaVersion: 1, + intentionId: "integrity-intention", + agentId: "agent-1", + scopeId: "scope-1", + revision: 0, + kind: "user_commitment", + purposeRef: "purpose-1", + text: "Complete the accepted task", + acceptance: { + sourceRef: "original-request", + acceptedBy: "user", + policyRevision: 1, + acceptedAt: at, + }, + priority: 0, + deadline: null, + completionCondition: "Outcome receipt", + abortConditions: [], + relatedIntentions: [], + status: "proposed", + history: [], + }; + for (const to of ["adopted", "active", "suspended"] as const) + record = transitionIntention(record, { + to, + reason: "Fixture transition", + evidenceRef: to === "suspended" ? record.acceptance.sourceRef : null, + at, + }); + return record; +} + +for (const evidenceRef of [null, "unrelated-request"]) { + test(`3995131658: pure resume rejects ${String(evidenceRef)} evidence`, () => { + const record = suspendedIntention(); + const before = structuredClone(record); + expect(() => + transitionIntention(record, { + to: "active", + reason: "Resume", + evidenceRef, + at, + }), + ).toThrow("intention change requires original acceptance ref"); + expect(record).toEqual(before); + }); + + test(`3995131658: history parsing rejects ${String(evidenceRef)} resume evidence`, () => { + const record = suspendedIntention(); + expect(() => + parseIntentionRecord({ + ...record, + status: "active", + revision: record.revision + 1, + history: [ + ...record.history, + { + from: "suspended", + to: "active", + reason: "Resume", + evidenceRef, + at, + }, + ], + }), + ).toThrow("intention change requires original acceptance ref"); + }); +} + +test("3995131658: resume preserves the original acceptance and round-trips", () => { + const record = suspendedIntention(); + const resumed = transitionIntention(record, { + to: "active", + reason: "Resume", + evidenceRef: record.acceptance.sourceRef, + at, + }); + expect(resumed.acceptance).toEqual(record.acceptance); + expect(resumed.status).toBe("active"); + expect(resumed.revision).toBe(record.revision + 1); + expect(parseIntentionRecord(JSON.parse(JSON.stringify(resumed)))).toEqual( + resumed, + ); +}); diff --git a/packages/lina-core/test/judgment-objective-activation.test.ts b/packages/lina-core/test/judgment-objective-activation.test.ts new file mode 100644 index 0000000..cb2ef37 --- /dev/null +++ b/packages/lina-core/test/judgment-objective-activation.test.ts @@ -0,0 +1,164 @@ +import { expect, test } from "bun:test"; +import { + JudgmentStore, + MODULE_KINDS, + type ModuleKind, + resolvePersonalRound, +} from "../src/agents/index.ts"; +import { policyEvidenceFixture } from "./judgment-policy-evidence-fixture.ts"; + +function replacement( + f: ReturnType, + moduleKind: ModuleKind, + revision: number, +) { + const ref = f.input.snapshot.objectiveProfileRefs[moduleKind]; + const profile = f.store.getObjectiveProfile(ref.objectiveId, ref.revision); + if (!profile) throw Error("missing objective"); + return f.store.putObjectiveProfile({ ...profile, revision }); +} + +function assessed(f: ReturnType) { + f.store.closeCandidateSet(f.input.evidence.candidates); + for (const assessment of f.input.set.assessments) + f.store.putAssessment(assessment); + return resolvePersonalRound(f.input); +} + +for (const moduleKind of MODULE_KINDS) + test(`3999233699 ${moduleKind} cannot reactivate a stale open round across reopen`, () => { + const f = policyEvidenceFixture(); + try { + const { agentId, scopeId, roundId, objectiveProfileRefs } = + f.input.snapshot; + const result = assessed(f); + const a = objectiveProfileRefs[moduleKind]; + expect( + f.store.activateObjectiveProfile(agentId, scopeId, a) + .activationRevision, + ).toBe(1); + const b = replacement(f, moduleKind, 2); + expect( + f.store.activateObjectiveProfile(agentId, scopeId, b) + .activationRevision, + ).toBe(2); + expect(() => + f.store.activateObjectiveProfile(agentId, scopeId, a), + ).toThrow("objective reactivation would revive stale round"); + const reopened = f.fixture.keep(new JudgmentStore(f.path)); + expect( + reopened.activeObjectiveProfiles(agentId, scopeId)?.[moduleKind], + ).toEqual(b); + expect( + reopened.activateObjectiveProfile(agentId, scopeId, b) + .activationRevision, + ).toBe(2); + expect(() => + reopened.activateObjectiveProfile(agentId, scopeId, a), + ).toThrow("objective reactivation would revive stale round"); + expect(() => + reopened.recordResolution(roundId, result.resolution, result.spec), + ).toThrow("stale objective profile refs"); + // A fresh revision can express the original objective without reviving old work. + const next = replacement(f, moduleKind, 3); + expect( + reopened.activateObjectiveProfile(agentId, scopeId, next) + .activationRevision, + ).toBe(3); + const refs = reopened.activeObjectiveProfiles(agentId, scopeId); + if (!refs) throw Error("missing active objectives"); + const fresh = { + ...f.input.snapshot, + roundId: "fresh", + sequence: 2, + objectiveProfileRefs: refs, + }; + reopened.openRound(fresh); + expect(f.store.getRound("fresh")?.snapshot).toEqual(fresh); + } finally { + f.fixture.close(); + } + }); + +test("3999233699 rotating different modules cannot revive an old snapshot", () => { + const f = policyEvidenceFixture(); + try { + const { agentId, scopeId, objectiveProfileRefs } = f.input.snapshot; + for (const moduleKind of ["clotho", "atropos"] as const) + f.store.activateObjectiveProfile( + agentId, + scopeId, + replacement(f, moduleKind, 2), + ); + // Restoring only one module is safe while another still invalidates the round. + expect( + f.store.activateObjectiveProfile( + agentId, + scopeId, + objectiveProfileRefs.clotho, + ).activationRevision, + ).toBe(3); + expect(() => + f.store.activateObjectiveProfile( + agentId, + scopeId, + objectiveProfileRefs.atropos, + ), + ).toThrow("objective reactivation would revive stale round"); + } finally { + f.fixture.close(); + } +}); + +for (const other of ["agent", "scope"] as const) + test(`3999233699 an open round does not block another ${other}`, () => { + const f = policyEvidenceFixture(); + try { + const { agentId, scopeId, objectiveProfileRefs } = f.input.snapshot; + const agent = other === "agent" ? "other-agent" : agentId; + const scope = other === "scope" ? "other-scope" : scopeId; + for (const ref of Object.values(objectiveProfileRefs)) + f.store.activateObjectiveProfile(agent, scope, ref); + f.store.activateObjectiveProfile( + agent, + scope, + replacement(f, "clotho", 2), + ); + expect( + f.store.activateObjectiveProfile( + agent, + scope, + objectiveProfileRefs.clotho, + ).activationRevision, + ).toBe(3); + } finally { + f.fixture.close(); + } + }); + +test("3999233699 closed rounds remain replayable after objective reactivation", () => { + const f = policyEvidenceFixture(); + try { + const { agentId, scopeId, roundId, objectiveProfileRefs } = + f.input.snapshot; + const result = assessed(f); + f.store.recordResolution(roundId, result.resolution, result.spec); + f.store.activateObjectiveProfile( + agentId, + scopeId, + replacement(f, "clotho", 2), + ); + expect( + f.store.activateObjectiveProfile( + agentId, + scopeId, + objectiveProfileRefs.clotho, + ).activationRevision, + ).toBe(3); + const reopened = f.fixture.keep(new JudgmentStore(f.path)); + expect(reopened.getResolution(roundId)).toEqual(result.resolution); + expect(reopened.getSelectionSpec(roundId)).toEqual(result.spec); + } finally { + f.fixture.close(); + } +}); diff --git a/packages/lina-core/test/judgment-objective-alias.test.ts b/packages/lina-core/test/judgment-objective-alias.test.ts new file mode 100644 index 0000000..bb36c8c --- /dev/null +++ b/packages/lina-core/test/judgment-objective-alias.test.ts @@ -0,0 +1,123 @@ +import { expect, test } from "bun:test"; +import { + assessmentInputDigest, + buildCandidateSet, + judgmentDigest, + MODULE_KINDS, + parseJudgmentSnapshotRef, + parseSelectionSpec, + resolvePersonalRound, + snapshotDigest, +} from "../src/agents/index.ts"; +import { policyEvidenceFixture } from "./judgment-policy-evidence-fixture.ts"; + +for (const moduleKind of ["lachesis", "atropos"] as const) + for (const changedDigest of [false, true]) + test(`3999164943 snapshot rejects ${moduleKind} alias with changed digest=${changedDigest}`, () => { + const f = policyEvidenceFixture(); + try { + const snapshot = structuredClone(f.input.snapshot); + snapshot.objectiveProfileRefs[moduleKind] = { + ...snapshot.objectiveProfileRefs.clotho, + ...(changedDigest ? { digest: "another-digest" } : {}), + }; + expect(() => parseJudgmentSnapshotRef(snapshot)).toThrow( + "duplicate objective profile refs", + ); + } finally { + f.fixture.close(); + } + }); + +test("3999164943 direct arbitration rejects aliased objectives with matching assessment digests", () => { + const f = policyEvidenceFixture(); + try { + const snapshot = structuredClone(f.input.snapshot); + snapshot.objectiveProfileRefs.atropos = + snapshot.objectiveProfileRefs.clotho; + const digest = snapshotDigest(snapshot); + const set = { + ...f.input.set, + snapshotDigest: digest, + assessments: f.input.set.assessments.map((assessment) => { + const ref = { + snapshotDigest: digest, + objectiveRef: snapshot.objectiveProfileRefs[assessment.moduleKind], + mechanismRevision: assessment.mechanismRevision, + }; + return { + ...assessment, + ...ref, + inputDigest: assessmentInputDigest(ref), + }; + }), + }; + const candidates = buildCandidateSet({ + roundId: snapshot.roundId, + snapshotDigest: digest, + options: f.input.options, + eligibility: f.input.eligibility, + intentionRefs: f.input.evidence.candidates.intentionRefs, + }); + expect(() => + resolvePersonalRound({ + ...f.input, + snapshot, + set, + evidence: { ...f.input.evidence, candidates }, + }), + ).toThrow("duplicate objective profile refs"); + } finally { + f.fixture.close(); + } +}); + +test("3999164943 selection parser rejects aliases even with a recomputed digest", () => { + const f = policyEvidenceFixture(); + try { + const { spec } = resolvePersonalRound(f.input); + if (!spec) throw Error("missing selection"); + spec.objectiveProfileRefs.lachesis = spec.objectiveProfileRefs.clotho; + spec.specDigest = judgmentDigest({ ...spec, specDigest: undefined }); + expect(() => parseSelectionSpec(spec)).toThrow( + "duplicate objective profile refs", + ); + } finally { + f.fixture.close(); + } +}); + +test("3999164943 distinct stored revisions of one objective ID retain their module ownership", () => { + const f = policyEvidenceFixture(); + try { + for (const [index, moduleKind] of MODULE_KINDS.entries()) { + const ref = f.store.putObjectiveProfile({ + schemaVersion: 1, + objectiveId: "shared-objective", + revision: index + 1, + moduleKind, + objective: "compare", + comparisonCriteria: [], + reconsiderationConditions: [], + }); + f.store.activateObjectiveProfile( + f.input.snapshot.agentId, + f.input.snapshot.scopeId, + ref, + ); + } + const refs = f.store.activeObjectiveProfiles( + f.input.snapshot.agentId, + f.input.snapshot.scopeId, + ); + if (!refs) throw Error("missing active profiles"); + expect( + parseJudgmentSnapshotRef({ + ...f.input.snapshot, + objectiveProfileRefs: refs, + }).objectiveProfileRefs, + ).toEqual(refs); + } finally { + f.fixture.close(); + } +}); diff --git a/packages/lina-core/test/judgment-owner-review.test.ts b/packages/lina-core/test/judgment-owner-review.test.ts new file mode 100644 index 0000000..d84d6d1 --- /dev/null +++ b/packages/lina-core/test/judgment-owner-review.test.ts @@ -0,0 +1,220 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { join } from "node:path"; +import { + ACCEPTED_BY, + INTENTION_KINDS, + type IntentionRecord, + type JudgmentSnapshotRef, + JudgmentStore, + MODULE_KINDS, + parseJudgmentSnapshotRef, + snapshotDigest, +} from "../src/agents/index.ts"; +import { + buildContextReadProjection, + parseContextReadProjection, +} from "../src/context/index.ts"; +import { ContextStore } from "../src/context/store.ts"; +import { entry, Fixture } from "./fixture.ts"; + +let fixture: Fixture; +const stores = new Set(); +beforeEach(() => { + fixture = new Fixture(); +}); +afterEach(() => { + for (const store of stores) store.close(); + stores.clear(); + fixture.close(); +}); + +const at = "2026-09-12T00:00:00.000Z"; + +function openJudgment(): JudgmentStore { + const store = new JudgmentStore(join(fixture.dir, "judgment.sqlite"), { + now: () => 1234, + }); + stores.add(store); + return store; +} + +function snapshot(store: JudgmentStore): JudgmentSnapshotRef { + for (const moduleKind of MODULE_KINDS) { + const ref = store.putObjectiveProfile({ + schemaVersion: 1, + objectiveId: `objective-${moduleKind}`, + moduleKind, + revision: 1, + objective: "Compare outcomes", + comparisonCriteria: ["cost"], + reconsiderationConditions: ["new evidence"], + }); + store.activateObjectiveProfile("agent-1", "scope-1", ref); + } + const refs = store.activeObjectiveProfiles("agent-1", "scope-1"); + if (!refs) throw Error("missing fixture profiles"); + return { + schemaVersion: 1, + roundId: "round-1", + agentId: "agent-1", + scopeId: "scope-1", + sourceRefs: [], + workingRevision: 0, + instructionRevision: 0, + policyId: "personal.v1", + policyRevision: 1, + identityRevision: 1, + domainRevisions: {}, + intentionRevision: 0, + objectiveProfileRefs: refs, + observationRef: null, + frozenNeuralRef: null, + situation: "user_request", + clockId: "clock-1", + sequence: 1, + bindingGeneration: 0, + }; +} + +test("durable 256-character entry survives context projection and persisted judgment round", () => { + const durable = fixture.store(); + // Durable requests are capped at 128; context entry IDs allow 256. + const requestId = "r".repeat(128); + const entryId = "e".repeat(256); + const input = entry(entryId, { role: "user", text: "Keep the commitment" }); + expect(durable.createRequest(requestId, input.text).created).toBe(true); + durable.registerRequestSource({ + version: 1, + purpose: "conversation", + sessionId: fixture.binding.sessionId, + requestId, + nativeEpoch: 1, + scopeDigest: "a".repeat(64), + contextReceiptIds: [], + }); + expect(durable.appendSourceEntry(input, requestId)).toBe(true); + durable.setRequest(requestId, "accepted", { entryId }); + durable.setRequest(requestId, "settled"); + const context = fixture.keep( + new ContextStore( + join(fixture.dir, "context.sqlite"), + fixture.binding, + (id) => durable.sourceEntry(id), + { + lookupRequest: (id) => + durable.sourceEntry(durable.request(id)?.entryId ?? ""), + }, + ), + ); + const working = context.updateWorking( + 0, + { goal: input.text, sourceEntryIds: [entryId] }, + { activeRequestId: requestId }, + ); + expect(context.working()).toEqual(working); + expect(working.sourceEntryIds).toEqual([entryId]); + const projection = buildContextReadProjection({ + working, + instruction: { requestId, entryId, text: input.text }, + previous: null, + projectedAt: at, + }); + expect( + parseContextReadProjection(JSON.parse(JSON.stringify(projection))), + ).toEqual(projection); + const instruction = projection.instruction; + if (!instruction) throw Error("missing fixture instruction"); + const store = openJudgment(); + const ref = { + ...snapshot(store), + workingRevision: projection.workingRevision, + instructionRevision: projection.instructionRevision, + sourceRefs: [ + { kind: "entry", id: instruction.entryId, revision: 0 }, + { kind: "request", id: instruction.requestId, revision: 0 }, + ], + }; + expect(parseJudgmentSnapshotRef(ref)).toEqual(ref); + expect(store.openRound(ref)).toEqual({ + roundId: ref.roundId, + snapshotDigest: snapshotDigest(ref), + }); + expect(store.getRound(ref.roundId)?.snapshot).toEqual(ref); + store.close(); + stores.delete(store); + expect(openJudgment().getRound(ref.roundId)).toEqual({ + snapshot: ref, + status: "open", + snapshotDigest: snapshotDigest(ref), + }); +}); + +test("context builder and parser preserve 256-character request and entry references for judgment", () => { + const projection = buildContextReadProjection({ + working: { + revision: 0, + goal: "", + decisions: [], + openItems: [], + nextSteps: [], + sourceEntryIds: [], + }, + instruction: { + requestId: "r".repeat(256), + entryId: "e".repeat(256), + text: "Instruction", + }, + previous: null, + projectedAt: at, + }); + const { instruction } = parseContextReadProjection(projection); + if (!instruction) throw Error("missing fixture instruction"); + const ref = { + ...snapshot(openJudgment()), + sourceRefs: [ + { kind: "entry", id: instruction.entryId, revision: 0 }, + { kind: "request", id: instruction.requestId, revision: 0 }, + ], + }; + expect(parseJudgmentSnapshotRef(ref)).toEqual(ref); +}); + +test("store rejects host-accepted user commitments without rejecting other authority pairs", () => { + const store = openJudgment(); + for (const kind of INTENTION_KINDS) { + for (const acceptedBy of ACCEPTED_BY) { + const record: IntentionRecord = { + schemaVersion: 1, + intentionId: `${kind}-${acceptedBy}`, + agentId: "agent-1", + scopeId: "scope-1", + revision: 0, + kind, + purposeRef: "purpose-1", + text: "Keep the commitment", + acceptance: { + sourceRef: "source-1", + acceptedBy, + policyRevision: 1, + acceptedAt: at, + }, + priority: 0, + deadline: null, + completionCondition: "Outcome receipt", + abortConditions: [], + relatedIntentions: [], + status: "proposed", + history: [], + }; + if (kind === "user_commitment" && acceptedBy === "host_autonomy") { + expect(() => store.putIntention(record)).toThrow( + "user commitment requires user acceptance", + ); + expect(store.getIntention(record.intentionId)).toBeNull(); + } else { + store.putIntention(record); + expect(store.getIntention(record.intentionId)).toEqual(record); + } + } + } +}); diff --git a/packages/lina-core/test/judgment-policy-commitment-fixture.ts b/packages/lina-core/test/judgment-policy-commitment-fixture.ts new file mode 100644 index 0000000..d015722 --- /dev/null +++ b/packages/lina-core/test/judgment-policy-commitment-fixture.ts @@ -0,0 +1,82 @@ +import type { IntentionRecord } from "../src/agents/judgment.ts"; +import { buildCandidateSet } from "../src/agents/judgment-candidates.ts"; +import type { resolvePersonalRound } from "../src/agents/judgment-policy.ts"; +import { + intentionDigest, + parseIntentionRecord, + snapshotDigest, + transitionIntention, +} from "../src/agents/judgment-validation.ts"; + +type PolicyInput = Parameters[0]; +/** Valid owner records for policy-only tests; SQLite owner integration is covered + * independently by judgment-policy-evidence-replay-review.test.ts. + */ +export function withCommitmentEvidence( + input: PolicyInput, + commitments: Array<{ id: string; sourceRef: string; suspended?: boolean }> = [ + { id: "intention", sourceRef: "request" }, + ], +): PolicyInput { + const at = "2026-09-12T00:00:00.000Z"; + const records = commitments.map(({ id, sourceRef, suspended }) => { + const proposed = parseIntentionRecord({ + schemaVersion: 1, + intentionId: id, + agentId: input.snapshot.agentId, + scopeId: input.snapshot.scopeId, + revision: 0, + kind: "user_commitment", + purposeRef: "purpose", + text: "keep promise", + acceptance: { + sourceRef, + acceptedBy: "user", + policyRevision: 1, + acceptedAt: at, + }, + priority: 0, + deadline: null, + completionCondition: "receipt", + abortConditions: [], + relatedIntentions: [], + status: "proposed", + history: [], + }); + const adopted = transitionIntention(proposed, { + to: "adopted", + reason: "accept", + evidenceRef: sourceRef, + at, + }); + return suspended + ? transitionIntention(adopted, { + to: "suspended", + reason: "pause", + evidenceRef: sourceRef, + at, + }) + : adopted; + }); + const owner = new Map( + records.map((record) => [record.intentionId, record]), + ); + return { + ...input, + evidence: { + candidates: buildCandidateSet({ + roundId: input.snapshot.roundId, + snapshotDigest: snapshotDigest(input.snapshot), + options: input.options, + eligibility: input.eligibility, + intentionRefs: records.map((record) => ({ + intentionId: record.intentionId, + revision: record.revision, + status: record.status, + digest: intentionDigest(record), + })), + }), + lookupIntention: (id) => owner.get(id) ?? null, + }, + }; +} diff --git a/packages/lina-core/test/judgment-policy-evidence-fixture.ts b/packages/lina-core/test/judgment-policy-evidence-fixture.ts new file mode 100644 index 0000000..2758589 --- /dev/null +++ b/packages/lina-core/test/judgment-policy-evidence-fixture.ts @@ -0,0 +1,226 @@ +import { join } from "node:path"; +import { type IntentionRecord, MODULE_KINDS } from "../src/agents/judgment.ts"; +import { + buildCandidateSet, + type CandidateSet, +} from "../src/agents/judgment-candidates.ts"; +import { + buildCanonicalOption, + type CanonicalOption, +} from "../src/agents/judgment-catalog.ts"; +import { + PERSONAL_POLICY_V1, + type resolvePersonalRound, +} from "../src/agents/judgment-policy.ts"; +import { JudgmentStore } from "../src/agents/judgment-store.ts"; +import { + assessmentInputDigest, + intentionDigest, + parseAssessmentSet, + snapshotDigest, +} from "../src/agents/judgment-validation.ts"; +import { Fixture } from "./fixture.ts"; + +const actor = { agentId: "policy-agent", scopeId: "policy-scope" }; +const at = "2026-09-12T00:00:00.000Z"; +export function policyOption( + kind: "noop" | "intention.suspend" | "intention.cancel" = "noop", + acceptanceSourceRef = "accepted-request", +): CanonicalOption { + const common = { + actor, + targetId: kind === "noop" ? null : "promise", + args: {}, + }; + switch (kind) { + case "noop": + return buildCanonicalOption({ + ...common, + kind, + preconditions: { kind, reason: "wait" }, + }); + case "intention.suspend": + return buildCanonicalOption({ + ...common, + kind, + preconditions: { + kind, + intentionId: "promise", + acceptanceSourceRef, + reason: "pause", + }, + }); + case "intention.cancel": + return buildCanonicalOption({ + ...common, + kind, + preconditions: { + kind, + intentionId: "promise", + acceptanceSourceRef, + authorityRef: "authority", + userConfirmationRef: "confirmation", + }, + }); + } +} +export function policyEvidenceFixture(option = policyOption()) { + const fixture = new Fixture(); + const path = join(fixture.dir, "policy.sqlite"); + const store = fixture.keep(new JudgmentStore(path, { now: () => 1234 })); + for (const moduleKind of MODULE_KINDS) { + const ref = store.putObjectiveProfile({ + schemaVersion: 1, + objectiveId: moduleKind, + moduleKind, + revision: 1, + objective: "compare", + comparisonCriteria: [], + reconsiderationConditions: [], + }); + store.activateObjectiveProfile(actor.agentId, actor.scopeId, ref); + } + const objectiveProfileRefs = store.activeObjectiveProfiles( + actor.agentId, + actor.scopeId, + ); + if (!objectiveProfileRefs) throw Error("missing profiles"); + const snapshot = { + schemaVersion: 1 as const, + roundId: "policy-evidence-round", + ...actor, + sourceRefs: [], + workingRevision: 0, + instructionRevision: 0, + policyId: PERSONAL_POLICY_V1.policyId, + policyRevision: 1, + identityRevision: 0, + domainRevisions: {}, + intentionRevision: 0, + objectiveProfileRefs, + observationRef: null, + frozenNeuralRef: null, + situation: "autonomous" as const, + clockId: "clock", + sequence: 1, + bindingGeneration: 0, + }; + const proposed: IntentionRecord = { + schemaVersion: 1, + intentionId: "promise", + ...actor, + revision: 0, + kind: "user_commitment", + purposeRef: "purpose", + text: "keep promise", + acceptance: { + sourceRef: "accepted-request", + acceptedBy: "user", + policyRevision: 1, + acceptedAt: at, + }, + priority: 0, + deadline: null, + completionCondition: "receipt", + abortConditions: [], + relatedIntentions: [], + status: "proposed", + history: [], + }; + store.putIntention(proposed); + const adopted = store.transitionIntention( + "promise", + { to: "adopted", reason: "accept", evidenceRef: "accepted-request", at }, + 0, + ); + snapshot.intentionRevision = store.intentionRevision( + actor.agentId, + actor.scopeId, + ); + store.openRound(snapshot); + const candidates = buildCandidateSet({ + roundId: snapshot.roundId, + snapshotDigest: snapshotDigest(snapshot), + options: [option], + eligibility: [ + { optionKey: option.optionKey, eligible: true, reason: null }, + ], + intentionRefs: [ + { + intentionId: adopted.intentionId, + revision: adopted.revision, + status: adopted.status, + digest: intentionDigest(adopted), + }, + ], + }); + const set = parseAssessmentSet({ + schemaVersion: 1, + roundId: snapshot.roundId, + snapshotDigest: snapshotDigest(snapshot), + assessments: MODULE_KINDS.map((moduleKind) => { + const ref = { + snapshotDigest: snapshotDigest(snapshot), + objectiveRef: objectiveProfileRefs[moduleKind], + mechanismRevision: 1, + }; + return { + schemaVersion: 1, + moduleKind, + snapshotId: snapshot.roundId, + ...ref, + inputDigest: assessmentInputDigest(ref), + completeText: "assessment", + evidenceRefs: [], + proposedOptionKeys: [], + recommendedOptionKeys: [], + objectiveAssessments: [ + { + optionKey: option.optionKey, + stance: "accept", + severity: null, + unavailableReason: null, + gain: "gain", + loss: "loss", + uncertainty: "unknown", + evidenceRefs: [], + }, + ], + detail: { + kind: { + clotho: "forecasts", + lachesis: "values", + atropos: "continuity", + }[moduleKind], + body: {}, + }, + diagnostics: {}, + }; + }), + }); + const input: Parameters[0] & { + evidence: { + candidates: CandidateSet; + lookupIntention: (id: string) => IntentionRecord | null; + }; + } = { + policy: PERSONAL_POLICY_V1, + snapshot, + options: candidates.options, + eligibility: candidates.eligibility, + set, + bias: {}, + evidence: { candidates, lookupIntention: (id) => store.getIntention(id) }, + }; + function oppose(moduleKind: "atropos" | "clotho", ids?: string[]) { + const opinion = input.set.assessments.find( + (a) => a.moduleKind === moduleKind, + )?.objectiveAssessments[0]; + if (!opinion) throw Error("missing opinion"); + opinion.stance = "oppose"; + opinion.severity = + moduleKind === "atropos" ? "commitment_breach" : "infeasible"; + if (ids !== undefined) opinion.breachedIntentionIds = ids; + } + return { fixture, store, path, input, oppose, adopted, option }; +} diff --git a/packages/lina-core/test/judgment-policy-evidence-replay-review.test.ts b/packages/lina-core/test/judgment-policy-evidence-replay-review.test.ts new file mode 100644 index 0000000..ced0fdc --- /dev/null +++ b/packages/lina-core/test/judgment-policy-evidence-replay-review.test.ts @@ -0,0 +1,78 @@ +import { afterEach, expect, test } from "bun:test"; +import { resolvePersonalRound } from "../src/agents/judgment-policy.ts"; +import { JudgmentStore } from "../src/agents/judgment-store.ts"; +import { + policyEvidenceFixture, + policyOption, +} from "./judgment-policy-evidence-fixture.ts"; + +const fixtures: ReturnType[] = []; +afterEach(() => { + for (const { fixture } of fixtures.splice(0)) fixture.close(); +}); + +for (const kind of ["noop", "intention.suspend", "intention.cancel"] as const) + test(`owner-verified ${kind} arbitration records and replays after later intention transitions`, () => { + const context = policyEvidenceFixture(policyOption(kind)); + fixtures.push(context); + const { input, oppose, store, path, fixture } = context; + oppose("atropos", ["promise"]); + store.closeCandidateSet(input.evidence.candidates); + for (const assessment of input.set.assessments) + store.putAssessment(assessment); + const result = resolvePersonalRound(input); + const at = "2026-09-12T00:00:00.000Z"; + + store.recordResolution( + input.snapshot.roundId, + result.resolution, + result.spec, + ); + store.transitionIntention( + "promise", + { to: "active", reason: "start", evidenceRef: "accepted-request", at }, + 1, + ); + store.transitionIntention( + "promise", + { to: "completed", reason: "finished", evidenceRef: "outcome", at }, + 2, + ); + const reopened = fixture.keep(new JudgmentStore(path)); + + expect(reopened.getResolution(input.snapshot.roundId)).toEqual( + result.resolution, + ); + expect(reopened.getSelectionSpec(input.snapshot.roundId)).toEqual( + result.spec, + ); + expect(result.resolution.status).toBe( + kind === "noop" ? "deferred" : "resolved", + ); + }); + +for (const moduleKind of ["atropos", "clotho"] as const) + test(`unattributed ${moduleKind} opposition persists without a veto`, () => { + const context = policyEvidenceFixture(); + fixtures.push(context); + const { input, oppose, store, fixture, path } = context; + oppose(moduleKind); + store.closeCandidateSet(input.evidence.candidates); + for (const assessment of input.set.assessments) + store.putAssessment(assessment); + const result = resolvePersonalRound(input); + + store.recordResolution( + input.snapshot.roundId, + result.resolution, + result.spec, + ); + const reopened = fixture.keep(new JudgmentStore(path)); + + expect( + reopened.getSelectionSpec(input.snapshot.roundId)?.candidates[0]?.p0, + ).toBe(1); + expect( + reopened.getResolution(input.snapshot.roundId, "action")?.excluded, + ).toEqual([]); + }); diff --git a/packages/lina-core/test/judgment-policy-evidence-review.test.ts b/packages/lina-core/test/judgment-policy-evidence-review.test.ts new file mode 100644 index 0000000..5d74cb4 --- /dev/null +++ b/packages/lina-core/test/judgment-policy-evidence-review.test.ts @@ -0,0 +1,222 @@ +import { afterEach, expect, test } from "bun:test"; +import { buildCandidateSet } from "../src/agents/judgment-candidates.ts"; +import { buildCanonicalOption } from "../src/agents/judgment-catalog.ts"; +import { + PERSONAL_POLICY_V1, + resolvePersonalRound, +} from "../src/agents/judgment-policy.ts"; +import { intentionDigest } from "../src/agents/judgment-validation.ts"; +import { + policyEvidenceFixture, + policyOption, +} from "./judgment-policy-evidence-fixture.ts"; + +const fixtures: ReturnType[] = []; +function setup(option = policyOption()) { + const fixture = policyEvidenceFixture(option); + fixtures.push(fixture); + return fixture; +} +afterEach(() => { + for (const { fixture } of fixtures.splice(0)) fixture.close(); +}); + +test("personal.v1 declares its closed unavailable reason vocabulary", () => { + expect(PERSONAL_POLICY_V1).toMatchObject({ + unavailableReasons: ["insufficient_evidence"], + }); +}); + +// Exclusion reasons are parsed receipt fields: missing evidence and an explicit +// negative Host decision must not become the same recorded state. +test("explicit Host false/null is distinct from an absent eligibility row", () => { + const { input } = setup(); + const { evidence: _evidence, ...direct } = input; + direct.eligibility = direct.eligibility.map((row) => ({ + ...row, + eligible: false, + })); + const explicit = resolvePersonalRound(direct); + const missing = resolvePersonalRound({ ...direct, eligibility: [] }); + expect(explicit.resolution.status).toBe("deferred"); + expect(explicit.resolution.excluded[0]?.stage).toBe("host_eligibility"); + expect(explicit.resolution.excluded[0]?.reason).not.toBe( + missing.resolution.excluded[0]?.reason, + ); +}); + +for (const kind of ["noop", "intention.suspend", "intention.cancel"] as const) + for (const ids of [undefined, []]) + test(`Atropos unattributed ${kind} opposition cannot hard-veto (${JSON.stringify(ids)})`, () => { + const { input, oppose } = setup(policyOption(kind)); + oppose("atropos", ids); + const result = resolvePersonalRound(input); + expect(result.resolution.excluded).toEqual([]); + expect(result.spec?.candidates[0]?.p0).toBe(1); + }); + +for (const ids of [["invented"], ["promise"]]) + test(`direct resolver cannot trust unverified attribution ${JSON.stringify(ids)}`, () => { + const { input, oppose } = setup(); + oppose("atropos", ids); + const { evidence: _evidence, ...direct } = input; + const result = resolvePersonalRound(direct); + expect(result.resolution.excluded).toEqual([]); + expect(result.spec?.candidates[0]?.p0).toBe(1); + }); + +test("Host-eligible Clotho infeasible opinion alone remains ranked opposition", () => { + const { input, oppose } = setup(); + oppose("clotho"); + const result = resolvePersonalRound(input); + expect(result.resolution.excluded).toEqual([]); + expect(result.spec?.candidates[0]?.p0).toBe(1); +}); + +test("factual prerequisite failure is represented by Host eligibility", () => { + const { input, oppose } = setup(); + oppose("clotho"); + const { evidence: _evidence, ...direct } = input; + direct.eligibility = direct.eligibility.map((row) => ({ + ...row, + eligible: false, + reason: "prerequisite receipt: unavailable resource", + })); + const result = resolvePersonalRound(direct); + expect(result.resolution.excluded[0]?.stage).toBe("host_eligibility"); + expect(result.spec).toBeNull(); +}); + +test("owner-verified accepted commitment still hard-vetoes an ordinary candidate", () => { + const { input, oppose } = setup(); + oppose("atropos", ["promise"]); + const result = resolvePersonalRound(input); + expect(result.resolution.excluded[0]?.stage).toBe("commitment_protection"); + expect(result.spec).toBeNull(); +}); + +for (const kind of ["intention.suspend", "intention.cancel"] as const) + test(`verified same-target ${kind} retains its original-acceptance waiver`, () => { + const { input, oppose } = setup(policyOption(kind)); + oppose("atropos", ["promise"]); + const result = resolvePersonalRound(input); + expect(result.resolution.excluded).toEqual([]); + expect(result.spec?.candidates[0]?.p0).toBe(1); + }); + +for (const forgery of [ + "id", + "digest", + "scope", + "lookup", + "candidate-universe", + "snapshot", + "kind", + "status", +] as const) + test(`direct resolver rejects ${forgery} evidence instead of manufacturing a veto`, () => { + const { input, oppose, adopted } = setup(); + oppose("atropos", [forgery === "id" ? "invented" : "promise"]); + const { candidateDigest: _digest, ...candidates } = + input.evidence.candidates; + if (forgery === "digest") + input.evidence.candidates = buildCandidateSet({ + ...candidates, + intentionRefs: candidates.intentionRefs.map((ref) => ({ + ...ref, + digest: "invented", + })), + }); + if (forgery === "scope") + input.evidence.lookupIntention = () => ({ + ...adopted, + scopeId: "foreign", + }); + if (forgery === "lookup") input.evidence.lookupIntention = () => null; + if (forgery === "candidate-universe") + input.evidence.candidates = buildCandidateSet({ + ...candidates, + eligibility: [], + }); + if (forgery === "snapshot") + input.evidence.candidates = buildCandidateSet({ + ...candidates, + snapshotDigest: "invented", + }); + if (forgery === "kind") + input.evidence.lookupIntention = () => ({ + ...adopted, + kind: "autonomous_goal", + }); + if (forgery === "status") + input.evidence.candidates = buildCandidateSet({ + ...candidates, + intentionRefs: candidates.intentionRefs.map((ref) => ({ + ...ref, + status: "proposed", + })), + }); + expect(() => resolvePersonalRound(input)).toThrow(); + }); + +for (const ids of [["other"], ["promise", "other"]]) + test(`targeted waiver cannot release another verified promise ${JSON.stringify(ids)}`, () => { + const { input, oppose, store, adopted } = setup( + policyOption("intention.cancel"), + ); + store.putIntention({ + ...adopted, + intentionId: "other", + status: "proposed", + revision: 0, + history: [], + }); + const other = store.transitionIntention( + "other", + { + to: "adopted", + reason: "accept", + evidenceRef: "accepted-request", + at: "2026-09-12T00:00:00.000Z", + }, + 0, + ); + const { candidateDigest: _digest, ...candidates } = + input.evidence.candidates; + input.evidence.candidates = buildCandidateSet({ + ...candidates, + intentionRefs: [ + ...candidates.intentionRefs, + { + intentionId: other.intentionId, + revision: other.revision, + status: other.status, + digest: intentionDigest(other), + }, + ], + }); + oppose("atropos", ids); + const result = resolvePersonalRound(input); + expect(result.resolution.excluded[0]?.stage).toBe("commitment_protection"); + expect(result.spec).toBeNull(); + }); + +test("a nonempty invented acceptance reference cannot obtain a waiver", () => { + const { input, oppose } = setup(policyOption("intention.cancel", "invented")); + oppose("atropos", ["promise"]); + expect(() => resolvePersonalRound(input)).toThrow( + "candidate original acceptance mismatch", + ); +}); + +test("a waiver cannot name a different canonical target", () => { + const option = buildCanonicalOption({ + ...policyOption("intention.cancel"), + targetId: "different", + }); + const { input, oppose } = setup(option); + oppose("atropos", ["promise"]); + expect(() => resolvePersonalRound(input)).toThrow( + "candidate intention target mismatch", + ); +}); diff --git a/packages/lina-core/test/judgment-policy-review.test.ts b/packages/lina-core/test/judgment-policy-review.test.ts new file mode 100644 index 0000000..9a95368 --- /dev/null +++ b/packages/lina-core/test/judgment-policy-review.test.ts @@ -0,0 +1,633 @@ +import { expect, test } from "bun:test"; +import { + assessmentInputDigest, + buildCanonicalOption, + judgmentDigest, + MODULE_KINDS, + PERSONAL_POLICY_V1, + parseAssessmentSet, + parseJudgmentSnapshotRef, + rankMass, + resolvePersonalRound, + sampleSelection, + snapshotDigest, +} from "../src/agents/index.ts"; +import { withCommitmentEvidence } from "./judgment-policy-commitment-fixture.ts"; + +test("rank mass remains normalized when absolute geometric weights overflow or underflow", () => { + expect(rankMass([1076], 0.5)).toEqual([1]); + expect(rankMass([1076, 1077], 0.5)).toEqual([2 / 3, 1 / 3]); + expect(rankMass([1076, 1077], 2)).toEqual([1 / 3, 2 / 3]); + expect(rankMass([2, 2], Number.MAX_VALUE)).toEqual([0.5, 0.5]); +}); + +function fixture(): Parameters[0] { + const option = buildCanonicalOption({ + kind: "noop", + actor: { agentId: "agent", scopeId: "scope" }, + targetId: null, + args: {}, + preconditions: { kind: "noop", reason: "nothing needed" }, + }); + const ref = (objectiveId: string) => ({ + objectiveId, + revision: 2, + digest: judgmentDigest({ objectiveId, revision: 2 }), + }); + const snapshot = { + schemaVersion: 1 as const, + roundId: "review-round", + ...option.actor, + sourceRefs: [], + workingRevision: 0, + instructionRevision: 0, + policyId: PERSONAL_POLICY_V1.policyId, + policyRevision: PERSONAL_POLICY_V1.revision, + identityRevision: 0, + domainRevisions: {}, + intentionRevision: 0, + objectiveProfileRefs: { + clotho: ref("clotho-objective"), + lachesis: ref("lachesis-objective"), + atropos: ref("atropos-objective"), + }, + observationRef: null, + frozenNeuralRef: null, + situation: "autonomous" as const, + clockId: "clock", + sequence: 0, + bindingGeneration: 0, + }; + const digest = snapshotDigest(snapshot); + return { + policy: PERSONAL_POLICY_V1, + snapshot, + options: [option], + set: parseAssessmentSet({ + schemaVersion: 1, + roundId: snapshot.roundId, + snapshotDigest: digest, + assessments: MODULE_KINDS.map((moduleKind) => { + const objectiveRef = snapshot.objectiveProfileRefs[moduleKind]; + return { + schemaVersion: 1, + moduleKind, + snapshotId: snapshot.roundId, + snapshotDigest: digest, + objectiveRef, + mechanismRevision: 1, + inputDigest: assessmentInputDigest({ + snapshotDigest: digest, + objectiveRef, + mechanismRevision: 1, + }), + completeText: "assessment", + evidenceRefs: [], + proposedOptionKeys: [option.optionKey], + objectiveAssessments: [ + { + optionKey: option.optionKey, + stance: "prefer", + severity: null, + unavailableReason: null, + gain: "gain", + loss: "loss", + uncertainty: "uncertainty", + evidenceRefs: [], + }, + ], + recommendedOptionKeys: [option.optionKey], + detail: { + kind: { + clotho: "forecasts", + lachesis: "values", + atropos: "continuity", + }[moduleKind], + body: {}, + }, + diagnostics: {}, + }; + }), + }), + eligibility: [ + { optionKey: option.optionKey, eligible: true, reason: null }, + ], + bias: {}, + }; +} + +function commitmentChange(breachedIntentionIds?: string[]) { + const input = fixture(); + const actor = { + agentId: input.snapshot.agentId, + scopeId: input.snapshot.scopeId, + }; + const change = buildCanonicalOption({ + kind: "intention.cancel", + actor, + targetId: "changed-intention", + args: {}, + preconditions: { + kind: "intention.cancel", + intentionId: "changed-intention", + acceptanceSourceRef: "accepted-request", + authorityRef: "host-authority", + userConfirmationRef: null, + }, + }); + input.options = [change]; + input.eligibility = [ + { optionKey: change.optionKey, eligible: true, reason: null }, + ]; + input.set = parseAssessmentSet({ + ...input.set, + assessments: input.set.assessments.map((assessment) => ({ + ...assessment, + proposedOptionKeys: [], + recommendedOptionKeys: [], + objectiveAssessments: [ + { + optionKey: change.optionKey, + stance: assessment.moduleKind === "atropos" ? "oppose" : "prefer", + severity: + assessment.moduleKind === "atropos" ? "commitment_breach" : null, + unavailableReason: null, + gain: "gain", + loss: "loss", + uncertainty: "unknown", + evidenceRefs: [], + ...(assessment.moduleKind === "atropos" && + breachedIntentionIds !== undefined + ? { breachedIntentionIds } + : {}), + }, + ], + })), + }); + return withCommitmentEvidence(input, [ + { id: "changed-intention", sourceRef: "accepted-request" }, + { id: "different-promise", sourceRef: "other-request" }, + ]); +} + +for (const ids of [undefined, []]) + test(`an unattributed breach cannot hard-veto an intention change (${JSON.stringify(ids)})`, () => { + const result = resolvePersonalRound(commitmentChange(ids)); + expect(result.resolution.status).toBe("resolved"); + expect(result.spec?.candidates[0]?.p0).toBe(1); + expect(result.resolution.excluded).toEqual([]); + }); + +for (const ids of [ + ["different-promise"], + ["changed-intention", "different-promise"], +]) { + test(`an intention change cannot waive protected promises ${JSON.stringify(ids)}`, () => { + const result = resolvePersonalRound(commitmentChange(ids)); + expect(result.resolution.status).toBe("deferred"); + expect(result.spec).toBeNull(); + }); +} + +test("an attributed change of the same accepted intention retains its exemption", () => { + const result = resolvePersonalRound(commitmentChange(["changed-intention"])); + expect(result.resolution.status).toBe("resolved"); + expect(result.resolution.excluded).toEqual([]); + expect(result.spec?.candidates[0]?.p0).toBe(1); +}); + +for (const field of ["agentId", "scopeId"] as const) { + test(`rejects candidate ${field} outside its frozen round`, () => { + const input = fixture(); + const original = input.options[0]; + if (!original) throw Error("missing candidate"); + const foreign = buildCanonicalOption({ + ...original, + actor: { ...original.actor, [field]: "foreign" }, + }); + input.options = [foreign]; + input.eligibility = [ + { optionKey: foreign.optionKey, eligible: true, reason: null }, + ]; + for (const assessment of input.set.assessments) { + assessment.proposedOptionKeys = [foreign.optionKey]; + assessment.recommendedOptionKeys = [foreign.optionKey]; + for (const opinion of assessment.objectiveAssessments) + opinion.optionKey = foreign.optionKey; + } + expect(parseAssessmentSet(input.set)).toEqual(input.set); + expect(() => resolvePersonalRound(input)).toThrow( + "option actor snapshot mismatch", + ); + }); +} + +for (const removal of ["host", "commitment", "infeasible", "ranked"] as const) { + test(`concessions distinguish ${removal} exclusion from a ranked preference loss`, () => { + const input = fixture(); + const preferred = input.options[0]; + if (!preferred) throw Error("missing candidate"); + const alternative = buildCanonicalOption({ + ...preferred, + targetId: "alternative", + }); + input.options.push(alternative); + input.eligibility = [ + { + optionKey: preferred.optionKey, + eligible: removal !== "host" && removal !== "infeasible", + reason: + removal === "host" + ? "not authorized" + : removal === "infeasible" + ? "failed prerequisite receipt" + : null, + }, + { optionKey: alternative.optionKey, eligible: true, reason: null }, + ]; + for (const assessment of input.set.assessments) { + const opinion = assessment.objectiveAssessments[0]; + if (!opinion) throw Error("missing opinion"); + assessment.objectiveAssessments.push({ + ...opinion, + optionKey: alternative.optionKey, + stance: + removal === "ranked" && assessment.moduleKind === "lachesis" + ? "prefer" + : "accept", + }); + if (removal === "ranked" && assessment.moduleKind === "lachesis") + opinion.stance = "accept"; + if (removal === "commitment" && assessment.moduleKind === "atropos") { + opinion.stance = "oppose"; + opinion.severity = "commitment_breach"; + opinion.breachedIntentionIds = ["intention"]; + } + if (removal === "infeasible" && assessment.moduleKind === "clotho") { + opinion.stance = "oppose"; + opinion.severity = "infeasible"; + } + } + const { resolution } = resolvePersonalRound( + removal === "commitment" ? withCommitmentEvidence(input) : input, + ); + expect(resolution.status).toBe("resolved"); + if (removal === "ranked") { + expect(resolution.conceded).toContainEqual({ + moduleKind: "clotho", + optionKey: preferred.optionKey, + }); + expect(resolution.ranking).toContainEqual({ + optionKey: preferred.optionKey, + rank: 2, + }); + } else { + expect(resolution.excluded.map((row) => row.optionKey)).toContain( + preferred.optionKey, + ); + expect(resolution.conceded).toEqual([]); + } + }); +} + +test("personal.v1 revision one rejects altered or malformed declarations", () => { + const input = fixture(); + for (const change of [ + { ratio: 0.9 }, + { ratio: 0 }, + { orders: { ...PERSONAL_POLICY_V1.orders, autonomous: [] } }, + { + orders: { + ...PERSONAL_POLICY_V1.orders, + user_request: PERSONAL_POLICY_V1.orders.autonomous, + }, + }, + { lambda: { ...PERSONAL_POLICY_V1.lambda, user_request: 1 } }, + { lambda: { ...PERSONAL_POLICY_V1.lambda, autonomous: 0 } }, + { stanceOrder: [] }, + { unavailableReasons: [] }, + { stanceOrder: [...PERSONAL_POLICY_V1.stanceOrder].reverse() }, + ]) { + expect(() => + resolvePersonalRound({ + ...input, + policy: { ...PERSONAL_POLICY_V1, ...change }, + }), + ).toThrow("unsupported personal policy declaration"); + } + expect( + resolvePersonalRound({ + ...input, + policy: structuredClone(PERSONAL_POLICY_V1), + }), + ).toEqual(resolvePersonalRound(input)); +}); + +test("a module omitted from round ordering makes no concession", () => { + const input = fixture(); + const original = input.options[0]; + if (!original) throw Error("missing candidate"); + const alternative = buildCanonicalOption({ + ...original, + targetId: "alternative", + }); + input.options.push(alternative); + input.eligibility.push({ + optionKey: alternative.optionKey, + eligible: true, + reason: null, + }); + for (const assessment of input.set.assessments) { + const opinion = assessment.objectiveAssessments[0]; + if (!opinion) throw Error("missing opinion"); + opinion.stance = assessment.moduleKind === "clotho" ? "prefer" : "accept"; + assessment.objectiveAssessments.push({ + ...opinion, + optionKey: alternative.optionKey, + stance: assessment.moduleKind === "clotho" ? "unavailable" : "prefer", + unavailableReason: + assessment.moduleKind === "clotho" ? "insufficient_evidence" : null, + }); + } + const result = resolvePersonalRound(input); + expect(result.resolution.ranking).toContainEqual({ + optionKey: original.optionKey, + rank: 2, + }); + expect(result.resolution.abstentions).toContainEqual({ + moduleKind: "clotho", + optionKey: alternative.optionKey, + reason: "insufficient_evidence", + }); + expect(result.resolution.conceded).toEqual([]); +}); + +for (const [moduleKind, severity] of [ + ["atropos", "commitment_breach"], + ["clotho", "infeasible"], +] as const) { + test(`${moduleKind} unavailable cannot gain a ${severity} veto`, () => { + const input = fixture(); + const assessment = input.set.assessments.find( + (a) => a.moduleKind === moduleKind, + ); + const opinion = assessment?.objectiveAssessments[0]; + if (!opinion) throw Error("missing opinion"); + opinion.stance = "unavailable"; + opinion.unavailableReason = "insufficient_evidence"; + const available = resolvePersonalRound(input); + expect(available.resolution.status).toBe("resolved"); + expect(available.resolution.excluded).toEqual([]); + expect(available.spec?.candidates).toEqual([ + { optionKey: opinion.optionKey, p0: 1, b: null }, + ]); + + opinion.severity = severity; + expect(() => resolvePersonalRound(input)).toThrow( + "severity requires oppose stance", + ); + + opinion.stance = "oppose"; + opinion.unavailableReason = null; + const opposed = resolvePersonalRound(input); + expect(opposed.resolution.status).toBe("resolved"); + expect(opposed.resolution.excluded).toEqual([]); + expect(opposed.spec?.candidates).toEqual([ + { optionKey: opinion.optionKey, p0: 1, b: null }, + ]); + }); +} + +for (const stances of [ + ["unavailable", "prefer", "prefer"], + ["unavailable", "unavailable", "prefer"], + ["unavailable", "unavailable", "unavailable"], + ["unavailable", "prefer", "accept"], + ["unavailable", "prefer", "oppose"], + ["prefer", "prefer", "accept"], + ["prefer", "prefer", "oppose"], +] as const) { + test(`${stances.join("/")} records only differing available stances as conflict`, () => { + const input = fixture(); + const option = input.options[0]; + if (!option) throw Error("missing candidate"); + for (const [index, assessment] of input.set.assessments.entries()) { + const opinion = assessment.objectiveAssessments[0]; + const stance = stances[index]; + if (!opinion || !stance) throw Error("missing opinion or stance"); + opinion.stance = stance; + opinion.severity = stance === "oppose" ? "preference" : null; + opinion.unavailableReason = + stance === "unavailable" ? "insufficient_evidence" : null; + } + expect(parseAssessmentSet(input.set)).toEqual(input.set); + const { resolution } = resolvePersonalRound(input); + expect(resolution.abstentions).toHaveLength( + stances.filter((stance) => stance === "unavailable").length, + ); + for (const assessment of input.set.assessments) { + for (const opinion of assessment.objectiveAssessments) { + if (opinion.unavailableReason !== null) + expect(resolution.abstentions).toContainEqual({ + optionKey: opinion.optionKey, + moduleKind: assessment.moduleKind, + reason: opinion.unavailableReason, + }); + } + } + const conflict = stances[2] === "accept" || stances[2] === "oppose"; + expect(resolution.conflicts).toEqual( + conflict + ? [ + { + optionKey: option.optionKey, + stances: { + clotho: stances[0], + lachesis: stances[1], + atropos: stances[2], + }, + }, + ] + : [], + ); + }); +} + +test("partial neural lookup failure keeps the entire decision at its baseline distribution", () => { + const input = fixture(); + const original = input.options[0]; + if (!original) throw Error("missing candidate"); + const alternative = buildCanonicalOption({ + ...original, + targetId: "alternative", + }); + input.options.push(alternative); + input.eligibility.push({ + optionKey: alternative.optionKey, + eligible: true, + reason: null, + }); + for (const assessment of input.set.assessments) { + const opinion = assessment.objectiveAssessments[0]; + if (!opinion) throw Error("missing opinion"); + assessment.objectiveAssessments.push({ + ...opinion, + optionKey: alternative.optionKey, + stance: "accept", + }); + } + input.bias = { [original.optionKey]: null, [alternative.optionKey]: 1 }; + const { spec } = resolvePersonalRound(input); + if (!spec) throw Error("missing selection spec"); + const restored = JSON.parse(JSON.stringify(spec)); + const sampled = sampleSelection(restored, 0.5); + expect( + sampled.probabilities.find((row) => row.optionKey === original.optionKey) + ?.p, + ).toBeCloseTo(2 / 3, 12); + expect( + sampled.probabilities.find((row) => row.optionKey === alternative.optionKey) + ?.p, + ).toBeCloseTo(1 / 3, 12); + + const excluded = buildCanonicalOption({ + ...original, + targetId: "excluded", + }); + input.options.push(excluded); + input.bias = { [original.optionKey]: 0, [alternative.optionKey]: 1 }; + const complete = resolvePersonalRound(input).spec; + if (!complete) throw Error("missing complete spec"); + const modulated = sampleSelection(complete, 0.5); + expect( + modulated.probabilities.find((row) => row.optionKey === original.optionKey) + ?.p, + ).toBeCloseTo(2 / (2 + Math.E), 12); + expect( + modulated.probabilities.find((row) => row.optionKey === excluded.optionKey) + ?.p, + ).toBe(0); +}); + +for (const outcome of ["resolved", "held", "deferred"] as const) { + function round() { + const input = fixture(); + if (outcome === "deferred") input.eligibility = []; + if (outcome === "held") { + for (const assessment of input.set.assessments) { + assessment.objectiveAssessments = []; + assessment.recommendedOptionKeys = []; + } + } + return input; + } + + test(`valid frozen round remains ${outcome}`, () => { + const input = round(); + const before = structuredClone(input); + const result = resolvePersonalRound(input); + expect(result.resolution.status).toBe(outcome); + expect(input).toEqual(before); + if (outcome === "resolved") { + if (!result.spec) throw Error("missing selection spec"); + const expected = input.options[0]; + if (!expected) throw Error("missing fixture candidate"); + expect(result.spec.snapshotDigest).toBe(snapshotDigest(input.snapshot)); + expect(sampleSelection(result.spec, NaN).optionKey).toBe( + expected.optionKey, + ); + } else expect(result.spec).toBeNull(); + }); + + for (const [change, error] of [ + [{ schemaVersion: 2 }, "Unsupported judgment snapshot ref schema version"], + [{ workingRevision: -1 }, "invalid working revision"], + [{ clockId: "" }, "invalid clock id"], + [{ extra: true }, "unknown judgment snapshot ref field extra"], + ] as const) { + test(`rejects bound malformed snapshot ${Object.keys(change)[0]} before ${outcome}`, () => { + const input = round(); + Object.assign(input.snapshot, change); + const digest = snapshotDigest(input.snapshot); + input.set.snapshotDigest = digest; + for (const assessment of input.set.assessments) { + assessment.snapshotDigest = digest; + assessment.inputDigest = assessmentInputDigest(assessment); + } + expect(parseAssessmentSet(input.set)).toEqual(input.set); + expect(() => parseJudgmentSnapshotRef(input.snapshot)).toThrow(error); + expect(() => resolvePersonalRound(input)).toThrow(error); + }); + } + + for (const change of [{ policyId: "foreign-policy" }, { revision: 2 }]) { + test(`rejects mismatched policy ${Object.keys(change)[0]} before ${outcome}`, () => { + const input = round(); + input.policy = { ...input.policy, ...change }; + expect(() => resolvePersonalRound(input)).toThrow( + "policy snapshot mismatch", + ); + }); + } + + for (const moduleKind of MODULE_KINDS) { + for (const change of [ + { objectiveId: "stale-objective" }, + { revision: 1 }, + { digest: judgmentDigest("stale-objective") }, + ]) { + test(`rejects ${moduleKind} stale ${Object.keys(change)[0]} with recomputed input digest before ${outcome}`, () => { + const input = round(); + const assessment = input.set.assessments.find( + (a) => a.moduleKind === moduleKind, + ); + if (!assessment) throw Error("missing assessment"); + assessment.objectiveRef = { ...assessment.objectiveRef, ...change }; + assessment.inputDigest = assessmentInputDigest(assessment); + expect(parseAssessmentSet(input.set)).toEqual(input.set); + expect(() => resolvePersonalRound(input)).toThrow( + "assessment objective mismatch", + ); + }); + } + + for (const field of [ + "proposedOptionKeys", + "objectiveAssessments", + "recommendedOptionKeys", + ] as const) { + test(`rejects ${moduleKind} foreign ${field} before ${outcome}`, () => { + const input = round(); + const assessment = input.set.assessments.find( + (a) => a.moduleKind === moduleKind, + ); + if (!assessment) throw Error("missing assessment"); + const foreign = buildCanonicalOption({ + kind: "noop", + actor: { agentId: "agent", scopeId: "scope" }, + targetId: "foreign", + args: {}, + preconditions: { kind: "noop", reason: "foreign candidate" }, + }); + if (field !== "proposedOptionKeys") { + assessment.objectiveAssessments.push({ + optionKey: foreign.optionKey, + stance: "prefer", + severity: null, + unavailableReason: null, + gain: "gain", + loss: "loss", + uncertainty: "uncertainty", + evidenceRefs: [], + }); + } + if (field !== "objectiveAssessments") + assessment[field].push(foreign.optionKey); + input.set = parseAssessmentSet(input.set); + expect(() => resolvePersonalRound(input)).toThrow( + "unknown assessment option key", + ); + }); + } + } +} diff --git a/packages/lina-core/test/judgment-policy.test.ts b/packages/lina-core/test/judgment-policy.test.ts new file mode 100644 index 0000000..7cf4d66 --- /dev/null +++ b/packages/lina-core/test/judgment-policy.test.ts @@ -0,0 +1,888 @@ +import { expect, test } from "bun:test"; +import { + type ArbitrationPolicy, + assessmentInputDigest, + buildCanonicalOption, + type CanonicalOption, + canonicalOptionKey, + EFFECT_OWNERS, + type JudgmentSnapshotRef, + judgmentDigest, + MODULE_KINDS, + type ModuleKind, + normalizeOptionArgs, + type OptionAssessment, + PERSONAL_CATALOG_ID, + PERSONAL_OPTION_KINDS, + PERSONAL_POLICY_V1, + type PersonalOptionKind, + type PersonalPrecondition, + parseAssessmentSet, + parseCanonicalOption, + parseResolutionRecord, + parseSelectionSpec, + rankMass, + resolvePersonalRound, + type SelectionSpec, + SITUATIONS, + type Situation, + type Stance, + sampleSelection, + snapshotDigest, +} from "../src/agents/index.ts"; +import { withCommitmentEvidence } from "./judgment-policy-commitment-fixture.ts"; + +const actor = { agentId: "agent", scopeId: "scope" }; +const preconditions: PersonalPrecondition[] = [ + { + kind: "intention.adopt", + intentionKind: "user_commitment", + sourceRef: "request", + }, + { kind: "intention.activate", intentionId: "intention" }, + { + kind: "intention.suspend", + intentionId: "intention", + acceptanceSourceRef: "request", + reason: "pause", + }, + { + kind: "intention.resume", + intentionId: "intention", + acceptanceSourceRef: "request", + reason: "resume", + }, + { + kind: "intention.cancel", + intentionId: "intention", + acceptanceSourceRef: "request", + authorityRef: "authority", + userConfirmationRef: null, + }, + { + kind: "intention.complete", + intentionId: "intention", + outcomeRef: "outcome", + }, + { + kind: "task.start", + authorityRef: "authority", + taskText: "fixture task", + intentionId: "intention", + }, + { kind: "task.send", taskId: "task", ownerId: "owner", revision: 0 }, + { kind: "task.interrupt", taskId: "task", ownerId: "owner", revision: 1 }, + { kind: "task.handover", taskId: "task", ownerId: "owner", revision: 1 }, + { kind: "inquire", authorityRef: "authority" }, + { kind: "defer", resumeCondition: "new input" }, + { kind: "noop", reason: "nothing needed" }, +]; +function option( + targetId: string, + kind: PersonalOptionKind = "noop", +): CanonicalOption { + const precondition = preconditions.find((p) => p.kind === kind); + if (!precondition) throw Error("missing fixture precondition"); + return buildCanonicalOption({ + kind, + actor, + targetId, + args: {}, + preconditions: precondition, + }); +} +function snapshot(situation: Situation): JudgmentSnapshotRef { + const ref = (moduleKind: ModuleKind) => ({ + objectiveId: `objective-${moduleKind}`, + revision: 1, + digest: judgmentDigest({ moduleKind, objective: "fixture objective" }), + }); + return { + schemaVersion: 1, + roundId: "round", + ...actor, + sourceRefs: [], + workingRevision: 0, + instructionRevision: 0, + policyId: PERSONAL_POLICY_V1.policyId, + policyRevision: 1, + identityRevision: 0, + domainRevisions: {}, + intentionRevision: 0, + objectiveProfileRefs: { + clotho: ref("clotho"), + lachesis: ref("lachesis"), + atropos: ref("atropos"), + }, + observationRef: null, + frozenNeuralRef: null, + situation, + clockId: "clock", + sequence: 0, + bindingGeneration: 0, + }; +} +function opinion( + stance: Stance, + severity: OptionAssessment["severity"] = null, +): Partial { + return { + stance, + severity: stance === "oppose" ? (severity ?? "preference") : null, + unavailableReason: + stance === "unavailable" ? "insufficient_evidence" : null, + }; +} +function fixture( + situation: Situation = "user_request", + options = [option("a"), option("b"), option("c")], + assess: ( + moduleKind: ModuleKind, + option: CanonicalOption, + ) => Partial | null = () => opinion("accept"), +): Parameters[0] { + const snap = snapshot(situation); + const digest = snapshotDigest(snap); + const set = parseAssessmentSet({ + schemaVersion: 1, + roundId: snap.roundId, + snapshotDigest: digest, + assessments: MODULE_KINDS.map((moduleKind) => { + const objectiveRef = snap.objectiveProfileRefs[moduleKind]; + const objectiveAssessments = options.flatMap((o) => { + const change = assess(moduleKind, o); + if (change === null) return []; + return [ + { + optionKey: o.optionKey, + stance: "accept", + severity: null, + unavailableReason: null, + gain: "gain", + loss: "loss", + uncertainty: "uncertainty", + evidenceRefs: [], + ...change, + }, + ]; + }); + return { + schemaVersion: 1, + moduleKind, + snapshotId: snap.roundId, + snapshotDigest: digest, + inputDigest: assessmentInputDigest({ + snapshotDigest: digest, + objectiveRef, + mechanismRevision: 1, + }), + objectiveRef, + mechanismRevision: 1, + completeText: "fixture assessment", + evidenceRefs: [], + proposedOptionKeys: [], + objectiveAssessments, + recommendedOptionKeys: objectiveAssessments + .filter((o) => o.stance === "prefer") + .map((o) => o.optionKey), + detail: { + kind: { + clotho: "forecasts", + lachesis: "values", + atropos: "continuity", + }[moduleKind], + body: {}, + }, + diagnostics: {}, + }; + }), + }); + return { + policy: PERSONAL_POLICY_V1, + snapshot: snap, + options, + set, + eligibility: options.map((o) => ({ + optionKey: o.optionKey, + eligible: true, + reason: null, + })), + bias: {}, + }; +} +function resolve(input: Parameters[0]) { + const before = structuredClone({ + ...input, + evidence: input.evidence?.candidates, + }); + const result = resolvePersonalRound(input); + expect({ ...input, evidence: input.evidence?.candidates }).toEqual(before); + expect(parseResolutionRecord(result.resolution)).toEqual(result.resolution); + if (result.spec) { + expect(parseSelectionSpec(result.spec)).toEqual(result.spec); + expect( + Math.abs(result.spec.candidates.reduce((sum, c) => sum + c.p0, 0) - 1), + ).toBeLessThanOrEqual(1e-12); + expect(result.spec.resolutionDigest).toBe( + judgmentDigest(result.resolution), + ); + expect(result.spec.assessmentSetDigest).toBe(judgmentDigest(input.set)); + expect(result.spec.snapshotDigest).toBe(snapshotDigest(input.snapshot)); + expect(result.spec.objectiveProfileRefs).toEqual( + input.snapshot.objectiveProfileRefs, + ); + } + return result; +} +function requireSpec( + result: ReturnType, +): SelectionSpec { + if (!result.spec) throw Error("expected resolved fixture"); + return result.spec; +} +function samplingSpec( + candidates: SelectionSpec["candidates"], + lambda = 1, +): SelectionSpec { + const base = requireSpec(resolve(fixture())); + const result = { + ...base, + candidates, + lambda, + eligibleDigest: judgmentDigest( + candidates.filter((c) => c.p0 > 0).map((c) => c.optionKey), + ), + }; + return parseSelectionSpec({ + ...result, + specDigest: judgmentDigest({ ...result, specDigest: undefined }), + }); +} + +test("catalog vocabulary, all per-kind fields and effect ownership", () => { + expect(PERSONAL_CATALOG_ID).toBe("personal.v1"); + expect([...PERSONAL_OPTION_KINDS]).toEqual(preconditions.map((p) => p.kind)); + expect(EFFECT_OWNERS).toEqual([ + "judgment_store", + "task_manager", + "host", + "none", + ]); + for (const p of preconditions) { + const o = option("target", p.kind); + expect(o.effect).toEqual({ + owner: p.kind.startsWith("intention.") + ? "judgment_store" + : p.kind.startsWith("task.") + ? "task_manager" + : p.kind === "inquire" + ? "host" + : "none", + scope: actor.scopeId, + }); + expect(parseCanonicalOption(o)).toEqual(o); + for (const key of Object.keys(p)) { + const missing = Object.fromEntries( + Object.entries(p).filter(([field]) => field !== key), + ); + expect(() => + parseCanonicalOption({ ...o, preconditions: missing }), + ).toThrow(); + } + } +}); + +test("(1) canonical keys normalize case, target, sorted args, NFC, whitespace and empty values", () => { + const base = option("task", "task.start"); + const a = { + ...base, + kind: "Task.Start" as PersonalOptionKind, + targetId: " task ", + args: { b: "x", a: " y z ", empty: " \t " }, + }; + const b = { ...base, args: { a: "y z", b: "x" } }; + expect(canonicalOptionKey(a)).toBe(canonicalOptionKey(b)); + expect(canonicalOptionKey({ ...b, targetId: "other" })).not.toBe( + canonicalOptionKey(b), + ); + expect(canonicalOptionKey({ ...b, args: { a: "e\u0301" } })).toBe( + canonicalOptionKey({ ...b, args: { a: "\u00e9" } }), + ); + expect(normalizeOptionArgs(a.args)).toEqual({ a: "y z", b: "x" }); + expect(Object.keys(normalizeOptionArgs(a.args))).toEqual(["a", "b"]); + const built = buildCanonicalOption(a); + expect(built.kind).toBe("task.start"); + expect(built.targetId).toBe("task"); + expect(built.args).toEqual(b.args); + expect(built.optionKey).toBe(canonicalOptionKey(b)); + expect(canonicalOptionKey({ ...base, targetId: null })).not.toBe( + canonicalOptionKey(base), + ); +}); + +test("reserved target sentinel is rejected without changing null keys", () => { + const base = option("target"); + const untargeted = buildCanonicalOption({ ...base, targetId: null }); + expect(untargeted.targetId).toBeNull(); + expect(untargeted.optionKey).toMatch(/^personal\.v1:noop:[a-f0-9]{64}$/); + expect(parseCanonicalOption(untargeted)).toEqual(untargeted); + for (const targetId of ["-", " - "]) + for (const action of [ + () => canonicalOptionKey({ ...base, targetId }), + () => buildCanonicalOption({ ...base, targetId }), + () => parseCanonicalOption({ ...untargeted, targetId }), + ]) + expect(action).toThrow("reserved target id"); +}); + +test("(2) golden option parses byte-identically and rejects invalid boundary data", () => { + const golden = { + schemaVersion: 1, + catalogId: "personal.v1", + kind: "noop", + actor, + targetId: null, + args: {}, + preconditions: { kind: "noop", reason: "nothing needed" }, + effect: { owner: "none", scope: "scope" }, + optionKey: + "personal.v1:noop:87529c8e7be62a166697c588491676440f0dd893f67561788cb6c2462904ddcf", + }; + expect(JSON.stringify(parseCanonicalOption(golden))).toBe( + JSON.stringify(golden), + ); + for (const schemaVersion of [0, 2]) + expect(() => + parseCanonicalOption({ ...golden, schemaVersion, extra: true }), + ).toThrow(/Unsupported .* schema version/); + expect(() => + parseCanonicalOption({ ...golden, kind: "memory.store" }), + ).toThrow("unknown personal.v1 option kind"); + for (const change of [ + { catalogId: "other" }, + { effect: { owner: "task_manager", scope: "scope" } }, + { optionKey: "tampered" }, + { preconditions: { kind: "defer", resumeCondition: "input" } }, + { extra: true }, + { actor: { ...actor, extra: true } }, + { effect: { ...golden.effect, extra: true } }, + { preconditions: { ...golden.preconditions, extra: true } }, + { actor: { ...actor, agentId: "x".repeat(161) } }, + { targetId: " " }, + { args: { key: 1 } }, + { args: { key: "x".repeat(1001) } }, + { args: { key: "x\u0000y" } }, + { preconditions: { kind: "noop", reason: " " } }, + ]) + expect(() => parseCanonicalOption({ ...golden, ...change })).toThrow(); + for (const value of [null, [], "option"]) + expect(() => parseCanonicalOption(value)).toThrow(); + for (const revision of [-1, 0.5, Infinity]) + expect(() => + parseCanonicalOption({ + ...option("task", "task.send"), + preconditions: { + kind: "task.send", + taskId: "task", + ownerId: "owner", + revision, + }, + }), + ).toThrow(); +}); + +test("precondition kind mismatch precedes effect owner mismatch", () => { + const o = buildCanonicalOption({ + kind: "task.start", + actor: { agentId: "agent", scopeId: "scope" }, + targetId: "x", + args: {}, + preconditions: { + kind: "task.start", + authorityRef: "authority", + taskText: "work", + intentionId: "intention", + }, + }); + expect(() => + parseCanonicalOption({ + ...o, + preconditions: { kind: "noop", reason: "x" }, + effect: { owner: "host", scope: "scope" }, + }), + ).toThrow(/^precondition kind mismatch$/); +}); + +const orders: Record = { + user_request: ["atropos", "clotho", "lachesis"], + autonomous: ["lachesis", "clotho", "atropos"], + transition: ["clotho", "atropos", "lachesis"], +}; +for (const situation of SITUATIONS) { + test(`(3,6,8) ${situation} uses declared order, lambda and reproducible digests`, () => { + const options = MODULE_KINDS.map((m) => option(m)); + const input = fixture(situation, options, (m, o) => + opinion(m === o.targetId ? "prefer" : "accept"), + ); + const result = resolve(input); + expect(result.resolution.order).toEqual(orders[situation]); + expect(result.resolution.ranking).toEqual( + orders[situation].map((m, i) => ({ + optionKey: option(m).optionKey, + rank: i + 1, + })), + ); + expect(result.resolution.policyRevision).toBe(1); + expect(result.resolution.conflicts).toHaveLength(3); + for (const m of MODULE_KINDS) + expect(result.resolution.recommendations[m]).toEqual([ + option(m).optionKey, + ]); + expect(result.resolution.conceded).toHaveLength(2); + expect(result.resolution.conceded).not.toContainEqual({ + moduleKind: orders[situation][0], + optionKey: option(orders[situation][0]).optionKey, + }); + const spec = requireSpec(result); + expect(spec.lambda).toBe(situation === "user_request" ? 0 : 1); + expect(spec.candidates.every((c) => c.b === null)).toBe(true); + expect(resolve(structuredClone(input)).spec?.specDigest).toBe( + spec.specDigest, + ); + const biased = resolve({ + ...input, + bias: Object.fromEntries(options.map((o) => [o.optionKey, 1])), + }); + expect(biased.spec?.candidates.map((c) => c.p0)).toEqual( + spec.candidates.map((c) => c.p0), + ); + expect(biased.spec?.candidates.every((c) => c.b === 1)).toBe(true); + }); +} + +test("(4) ties share dense rank and split rank mass, rather than doubling activity mass", () => { + expect(rankMass([1, 2, 3], 0.5)).toEqual([4 / 7, 2 / 7, 1 / 7]); + expect(rankMass([1, 1, 2], 0.5)).toEqual([1 / 3, 1 / 3, 1 / 3]); + expect(rankMass([2, 1, 1], 0.5)).toEqual([1 / 3, 1 / 3, 1 / 3]); + expect(rankMass([], 0.5)).toEqual([]); + const result = resolve( + fixture("user_request", undefined, (_, o) => + opinion(o.targetId === "c" ? "accept" : "prefer"), + ), + ); + expect(result.resolution.ranking).toEqual([ + { optionKey: option("a").optionKey, rank: 1 }, + { optionKey: option("b").optionKey, rank: 1 }, + { optionKey: option("c").optionKey, rank: 2 }, + ]); + expect(requireSpec(result).candidates.map((c) => c.p0)).toEqual([ + 1 / 3, + 1 / 3, + 1 / 3, + ]); +}); + +test("(5) only original-acceptance suspend/cancel escape commitment protection", () => { + const options = [ + "task.start", + "intention.cancel", + "intention.suspend", + "intention.resume", + ].map((kind) => { + const candidate = option("intention", kind as PersonalOptionKind); + return kind === "intention.resume" + ? buildCanonicalOption({ + ...candidate, + targetId: "resume-intention", + preconditions: { + kind: "intention.resume", + intentionId: "resume-intention", + acceptanceSourceRef: "request", + reason: "resume", + }, + }) + : candidate; + }); + const result = resolve( + withCommitmentEvidence( + fixture("user_request", options, (m) => { + if (m === "atropos") + return { + ...opinion("oppose", "commitment_breach"), + breachedIntentionIds: ["intention"], + }; + return opinion("accept"); + }), + [ + { id: "intention", sourceRef: "request" }, + { id: "resume-intention", sourceRef: "request", suspended: true }, + ], + ), + ); + for (const o of options) { + const exempt = + o.kind === "intention.cancel" || o.kind === "intention.suspend"; + expect( + requireSpec(result).candidates.find((c) => c.optionKey === o.optionKey) + ?.p0, + ).toBe(exempt ? 0.5 : 0); + if (!exempt) + expect(result.resolution.excluded).toContainEqual({ + optionKey: o.optionKey, + stage: "commitment_protection", + byModule: "atropos", + reason: expect.any(String), + }); + } + const cancel = option("cancel", "intention.cancel"); + const input = fixture("user_request", [cancel]); + input.options = [ + { + ...cancel, + preconditions: { + kind: "intention.cancel", + intentionId: "intention", + authorityRef: "authority", + acceptanceSourceRef: "", + userConfirmationRef: null, + }, + }, + ]; + expect(() => resolvePersonalRound(input)).toThrow(); +}); + +test("(5) preference and unverified infeasible opposition never exclude", () => { + for (const moduleKind of MODULE_KINDS) { + for (const severity of ["preference", "infeasible"] as const) { + const result = resolve( + fixture("autonomous", undefined, (m, o) => + m === moduleKind && o.targetId === "a" + ? opinion("oppose", severity) + : opinion("accept"), + ), + ); + const p0 = requireSpec(result).candidates.find( + (c) => c.optionKey === option("a").optionKey, + )?.p0; + expect(p0).toBeGreaterThan(0); + expect(result.resolution.excluded).toEqual([]); + } + } +}); + +test("(7) no host-eligible or no remaining candidates defers, never supplies uniform mass", () => { + const input = fixture(); + for (const eligibility of [ + [], + input.eligibility.map((row) => ({ + ...row, + eligible: false, + reason: "not authorized", + })), + ]) { + const result = resolve({ ...input, eligibility }); + expect(result.resolution.status).toBe("deferred"); + expect(result.resolution.holdReason).toBe("no eligible candidate"); + expect(result.resolution.ranking).toEqual([]); + expect( + result.resolution.excluded.every((e) => e.stage === "host_eligibility"), + ).toBe(true); + expect(result.spec).toBeNull(); + } + const excluded = resolve( + withCommitmentEvidence( + fixture("transition", undefined, (m) => + m === "atropos" + ? { + ...opinion("oppose", "commitment_breach"), + breachedIntentionIds: ["intention"], + } + : opinion("accept"), + ), + ), + ); + expect(excluded.resolution.status).toBe("deferred"); + expect(excluded.spec).toBeNull(); + expect(resolve(fixture("user_request", [])).resolution.status).toBe( + "deferred", + ); + expect(() => + resolvePersonalRound({ + ...input, + eligibility: [ + ...input.eligibility, + { optionKey: "unknown", eligible: true, reason: null }, + ], + }), + ).toThrow(); + const partial = resolve({ + ...input, + eligibility: input.eligibility.slice(1), + }); + expect( + requireSpec(partial).candidates.find( + (c) => c.optionKey === option("a").optionKey, + )?.p0, + ).toBe(0); +}); + +test("(7) eligible completeness precedes protection; ineligible candidates need no assessments", () => { + const input = fixture("user_request", undefined, (m, o) => + m === "lachesis" && o.targetId === "a" + ? null + : m === "atropos" + ? { + ...opinion("oppose", "commitment_breach"), + breachedIntentionIds: ["intention"], + } + : opinion("accept"), + ); + const result = resolve(withCommitmentEvidence(input)); + expect(result.resolution.status).toBe("held"); + expect(result.resolution.holdReason).toBe( + `missing assessment lachesis for ${option("a").optionKey}`, + ); + expect(result.spec).toBeNull(); + const without = resolve( + withCommitmentEvidence({ + ...input, + eligibility: input.eligibility.filter( + (e) => e.optionKey !== option("a").optionKey, + ), + }), + ); + expect(without.resolution.status).toBe("deferred"); + const mismatched = { + ...input, + snapshot: { ...input.snapshot, workingRevision: 1 }, + }; + expect(() => resolvePersonalRound(mismatched)).toThrow(/snapshot/); + expect(() => + resolvePersonalRound({ + ...input, + set: { ...input.set, assessments: input.set.assessments.slice(1) }, + }), + ).toThrow(); +}); + +for (const situation of SITUATIONS) { + test(`(7b) ${situation}: unavailable drops a module transitively for every candidate`, () => { + const [m1, m2] = orders[situation]; + const input = fixture(situation, undefined, (m, o) => { + if (m === m1) + return opinion( + o.targetId === "a" + ? "prefer" + : o.targetId === "b" + ? "accept" + : "unavailable", + ); + if (m === m2) + return opinion( + o.targetId === "a" + ? "oppose" + : o.targetId === "b" + ? "prefer" + : "accept", + ); + return opinion("accept"); + }); + const result = resolve(input); + expect(result.resolution.status).toBe("resolved"); + expect(result.resolution.ranking).toEqual( + ["b", "c", "a"].map((id, i) => ({ + optionKey: option(id).optionKey, + rank: i + 1, + })), + ); + expect(result.resolution.abstentions).toContainEqual({ + optionKey: option("c").optionKey, + moduleKind: m1, + reason: "insufficient_evidence", + }); + expect( + resolve({ ...input, options: [...input.options].reverse() }), + ).toEqual(result); + }); +} + +test("(7b) all modules unavailable for ordering holds and records every abstention", () => { + const result = resolve( + fixture( + "transition", + MODULE_KINDS.map((m) => option(m)), + (m, o) => opinion(m === o.targetId ? "unavailable" : "accept"), + ), + ); + expect(result.resolution.status).toBe("held"); + expect(result.resolution.holdReason).toBe( + "all modules unavailable for ordering", + ); + expect(result.resolution.abstentions).toHaveLength(3); + expect(result.spec).toBeNull(); +}); + +test("unavailable on an excluded option is recorded but does not drop ordering modules", () => { + const input = fixture("user_request", undefined, (m, o) => + o.targetId === "c" + ? opinion("unavailable") + : opinion(m === "atropos" && o.targetId === "a" ? "prefer" : "accept"), + ); + const result = resolve({ + ...input, + eligibility: input.eligibility.filter( + (e) => e.optionKey !== option("c").optionKey, + ), + }); + expect(result.resolution.status).toBe("resolved"); + expect(result.resolution.abstentions).toHaveLength(3); + expect(result.resolution.ranking.at(0)).toEqual({ + optionKey: option("a").optionKey, + rank: 1, + }); +}); + +test("(9) normalized duplicate options cannot double activity mass", () => { + const o = option("a"); + const input = fixture("user_request", [o]); + const variant = buildCanonicalOption({ + ...o, + targetId: " a ", + args: { omitted: " " }, + }); + expect(() => + resolvePersonalRound({ ...input, options: [o, variant] }), + ).toThrow("duplicate option key"); +}); + +test("(10) single positive candidate consumes no draw, including NaN", () => { + const spec = samplingSpec([ + { optionKey: "a", p0: 0, b: 1 }, + { optionKey: "b", p0: 1, b: null }, + ]); + expect(sampleSelection(spec, NaN)).toEqual({ + optionKey: "b", + probabilities: [ + { optionKey: "a", p: 0 }, + { optionKey: "b", p: 1 }, + ], + consumedDraw: false, + }); +}); + +test("(10) sampling uses normalized log weights and strict cumulative boundaries", () => { + const spec = samplingSpec([ + { optionKey: "a", p0: 0.5, b: Math.log(3) / 2 }, + { optionKey: "b", p0: 0.5, b: -Math.log(3) / 2 }, + ]); + const sample = sampleSelection(spec, 0); + expect(sample.optionKey).toBe("a"); + expect(sample.consumedDraw).toBe(true); + expect(sample.probabilities[0]?.p).toBeCloseTo(0.75, 12); + expect(sample.probabilities[1]?.p).toBeCloseTo(0.25, 12); + expect(sampleSelection(spec, 0.9).optionKey).toBe("b"); + const boundary = sample.probabilities[0]?.p; + if (boundary === undefined) throw Error("missing probability"); + expect(sampleSelection(spec, boundary).optionKey).toBe("b"); + for (const u of [1, -0.1, NaN, Infinity]) + expect(() => sampleSelection(spec, u)).toThrow("invalid selection draw"); + const unmodulated = sampleSelection(samplingSpec(spec.candidates, 0), 0.5); + expect(unmodulated.probabilities.map((c) => c.p)).toEqual([0.5, 0.5]); + expect(unmodulated.optionKey).toBe("b"); +}); + +test("(10) asymmetric and extreme log-space weights remain finite and normalized", () => { + const spec = samplingSpec([ + { optionKey: "a", p0: 0.8, b: 0.5 }, + { optionKey: "b", p0: 0.2, b: -0.5 }, + ]); + const { probabilities } = sampleSelection(spec, 0); + const [a, b] = probabilities; + if (!a || !b) throw Error("missing probabilities"); + expect(Math.abs(a.p + b.p - 1)).toBeLessThanOrEqual(1e-12); + expect(Math.abs(a.p / b.p - 4 * Math.E)).toBeLessThanOrEqual(1e-9); + for (const candidates of [ + [ + { optionKey: "a", p0: 1e-300, b: null }, + { optionKey: "b", p0: 1, b: null }, + ], + [ + { optionKey: "a", p0: 0.5, b: 1 }, + { optionKey: "b", p0: 0.5, b: 0.999 }, + ], + [ + { optionKey: "a", p0: 0.5, b: -1 }, + { optionKey: "b", p0: 0.5, b: -0.999 }, + ], + ]) { + const result = sampleSelection( + samplingSpec(candidates, 1000), + 1 - Number.EPSILON, + ); + expect(result.probabilities.every((c) => Number.isFinite(c.p))).toBe(true); + expect( + Math.abs(result.probabilities.reduce((sum, c) => sum + c.p, 0) - 1), + ).toBeLessThanOrEqual(1e-12); + } +}); + +test("personal policy is frozen at every level", () => { + for (const value of [ + PERSONAL_POLICY_V1, + PERSONAL_POLICY_V1.orders, + ...Object.values(PERSONAL_POLICY_V1.orders), + PERSONAL_POLICY_V1.lambda, + PERSONAL_POLICY_V1.stanceOrder, + PERSONAL_POLICY_V1.unavailableReasons, + ]) + expect(Object.isFrozen(value)).toBe(true); +}); + +test("assignment cannot change personal policy arbitration", () => { + const input = fixture( + "autonomous", + MODULE_KINDS.map((m) => option(m)), + (m, o) => opinion(m === o.targetId ? "prefer" : "accept"), + ); + const before = resolve(input); + const changes: Array<[object, string, unknown]> = [ + [PERSONAL_POLICY_V1, "ratio", 0.25], + [PERSONAL_POLICY_V1, "revision", 999], + [PERSONAL_POLICY_V1, "policyId", "other"], + [PERSONAL_POLICY_V1, "orders", {}], + [PERSONAL_POLICY_V1, "lambda", {}], + [PERSONAL_POLICY_V1, "stanceOrder", []], + [PERSONAL_POLICY_V1, "unavailableReasons", []], + [PERSONAL_POLICY_V1.unavailableReasons, "0", "invented"], + [ + PERSONAL_POLICY_V1.orders, + "autonomous", + ["atropos", "clotho", "lachesis"], + ], + [PERSONAL_POLICY_V1.orders.autonomous, "0", "atropos"], + [PERSONAL_POLICY_V1.lambda, "autonomous", 99], + [PERSONAL_POLICY_V1.stanceOrder, "0", "oppose"], + ]; + for (const [target, key, value] of changes) { + const original: unknown = Reflect.get(target, key); + try { + expect(Reflect.set(target, key, value)).toBe(false); + expect(resolve(input)).toEqual(before); + } finally { + Reflect.set(target, key, original); + } + } +}); + +test("policy public type exposes exactly the revision-one declaration", () => { + const policy: ArbitrationPolicy = PERSONAL_POLICY_V1; + expect(policy).toEqual({ + policyId: "personal.v1", + revision: 1, + ratio: 0.5, + orders, + lambda: { user_request: 0, autonomous: 1, transition: 1 }, + stanceOrder: ["prefer", "accept", "oppose"], + unavailableReasons: ["insufficient_evidence"], + }); +}); diff --git a/packages/lina-core/test/judgment-round.test.ts b/packages/lina-core/test/judgment-round.test.ts new file mode 100644 index 0000000..ce22932 --- /dev/null +++ b/packages/lina-core/test/judgment-round.test.ts @@ -0,0 +1,585 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + type Assessment, + assessmentInputDigest, + buildCandidateSet, + buildCanonicalOption, + type CanonicalOption, + type IntentionRecord, + intentionDigest, + type JudgmentSnapshotRef, + JudgmentStore, + judgmentDigest, + type ModuleKind, + type ObjectiveProfile, + PERSONAL_CATALOG_ID, + PERSONAL_POLICY_V1, + type PersonalOptionKind, + parseAssessment, + parseCanonicalOption, + parseIntentionRecord, + parseJudgmentSnapshotRef, + parseObjectiveProfile, + resolvePersonalRound, + type SelectionSpec, + type Situation, + sampleSelection, + snapshotDigest, + transitionIntention, +} from "../src/agents/index.ts"; +import { buildContextReadProjection } from "../src/context/index.ts"; + +const AGENT = "agent-1"; +const SCOPE = "scope-1"; +const PROJECTED_AT = "2026-09-12T00:00:00.000Z"; +const actor = { agentId: AGENT, scopeId: SCOPE }; + +let dir: string | undefined; +let sqlitePath = ""; +const stores = new Set(); + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "lina-core-judgment-round-")); + sqlitePath = join(dir, "judgment.sqlite"); +}); + +afterEach(() => { + for (const store of stores) store.close(); + stores.clear(); + if (dir !== undefined) rmSync(dir, { recursive: true, force: true }); + dir = undefined; +}); + +function open(path = sqlitePath): JudgmentStore { + const store = new JudgmentStore(path, { now: () => 1234 }); + stores.add(store); + return store; +} + +function close(store: JudgmentStore): void { + store.close(); + stores.delete(store); +} + +function contextProjection() { + const working = { + revision: 7, + goal: "Keep working revision at seven", + decisions: [], + openItems: [], + nextSteps: [], + sourceEntryIds: [], + }; + const previous = buildContextReadProjection({ + working, + instruction: { + requestId: "request-1", + entryId: "entry-1", + text: "first instruction", + }, + previous: null, + projectedAt: PROJECTED_AT, + }); + return buildContextReadProjection({ + working, + instruction: { + requestId: "request-2", + entryId: "entry-2", + text: "second instruction", + }, + previous, + projectedAt: PROJECTED_AT, + }); +} + +function profile(moduleKind: ModuleKind): ObjectiveProfile { + return parseObjectiveProfile({ + schemaVersion: 1, + objectiveId: `objective-${moduleKind}`, + moduleKind, + revision: 1, + objective: `Fixture ${moduleKind} objective`, + comparisonCriteria: [`${moduleKind} cost`, `${moduleKind} outcome`], + reconsiderationConditions: [`${moduleKind} new evidence`], + }); +} + +function putActivatedProfiles(store: JudgmentStore) { + const clotho = profile("clotho"); + const lachesis = profile("lachesis"); + const atropos = profile("atropos"); + const clothoRef = store.putObjectiveProfile(clotho); + const lachesisRef = store.putObjectiveProfile(lachesis); + const atroposRef = store.putObjectiveProfile(atropos); + expect(store.activateObjectiveProfile(AGENT, SCOPE, clothoRef)).toEqual({ + activationRevision: 1, + }); + expect(store.activateObjectiveProfile(AGENT, SCOPE, lachesisRef)).toEqual({ + activationRevision: 1, + }); + expect(store.activateObjectiveProfile(AGENT, SCOPE, atroposRef)).toEqual({ + activationRevision: 1, + }); + return { + clotho, + lachesis, + atropos, + refs: { clotho: clothoRef, lachesis: lachesisRef, atropos: atroposRef }, + }; +} + +function canonicalOptions() { + const adopt = buildCanonicalOption({ + kind: "intention.adopt", + actor, + targetId: "intention-1", + args: {}, + preconditions: { + kind: "intention.adopt", + intentionKind: "autonomous_goal", + sourceRef: "source-1", + }, + }); + const start = buildCanonicalOption({ + kind: "task.start", + actor, + targetId: "task-1", + args: {}, + preconditions: { + kind: "task.start", + authorityRef: "authority-1", + taskText: "Start fixture task", + intentionId: "intention-start", + }, + }); + const inquire = buildCanonicalOption({ + kind: "inquire", + actor, + targetId: "ask-1", + args: { topic: "what next" }, + preconditions: { kind: "inquire", authorityRef: "authority-1" }, + }); + const inquireVariant = buildCanonicalOption({ + kind: "Inquire" as PersonalOptionKind, + actor, + targetId: " ask-1 ", + args: { topic: " what next " }, + preconditions: { kind: "inquire", authorityRef: "authority-1" }, + }); + const noop = buildCanonicalOption({ + kind: "noop", + actor, + targetId: null, + args: {}, + preconditions: { kind: "noop", reason: "nothing needed" }, + }); + return { adopt, start, inquire, inquireVariant, noop }; +} + +function snapshotRef( + situation: Situation, + refs: ReturnType["refs"], + projection: ReturnType, +): JudgmentSnapshotRef { + return parseJudgmentSnapshotRef({ + schemaVersion: 1, + roundId: `round-${situation}`, + agentId: AGENT, + scopeId: SCOPE, + sourceRefs: [], + workingRevision: projection.workingRevision, + instructionRevision: projection.instructionRevision, + policyId: PERSONAL_POLICY_V1.policyId, + policyRevision: 1, + identityRevision: 1, + domainRevisions: {}, + intentionRevision: 0, + objectiveProfileRefs: refs, + observationRef: null, + frozenNeuralRef: null, + situation, + clockId: "clock-1", + sequence: 1, + bindingGeneration: 0, + }); +} + +function stanceFor( + moduleKind: ModuleKind, + option: CanonicalOption, +): { stance: "prefer" | "accept" | "oppose"; severity: string | null } { + if (moduleKind === "lachesis" && option.kind === "intention.adopt") + return { stance: "prefer", severity: null }; + if (moduleKind === "clotho" && option.kind === "task.start") + return { stance: "oppose", severity: "infeasible" }; + if (moduleKind === "atropos" && option.kind === "noop") + return { stance: "oppose", severity: "commitment_breach" }; + return { stance: "accept", severity: null }; +} + +function assessmentFor( + snapshot: JudgmentSnapshotRef, + moduleKind: ModuleKind, + options: CanonicalOption[], +): Assessment { + const digest = snapshotDigest(snapshot); + const objectiveRef = snapshot.objectiveProfileRefs[moduleKind]; + const objectiveAssessments = options.map((option) => { + const { stance, severity } = stanceFor(moduleKind, option); + return { + optionKey: option.optionKey, + stance, + severity, + unavailableReason: null, + gain: "fixture gain", + loss: "fixture loss", + uncertainty: "fixture uncertainty", + evidenceRefs: [], + ...(moduleKind === "atropos" && option.kind === "noop" + ? { breachedIntentionIds: ["protected-promise"] } + : {}), + }; + }); + return parseAssessment({ + schemaVersion: 1, + moduleKind, + snapshotId: snapshot.roundId, + snapshotDigest: digest, + inputDigest: assessmentInputDigest({ + snapshotDigest: digest, + objectiveRef, + mechanismRevision: 1, + }), + objectiveRef, + mechanismRevision: 1, + completeText: `Fixture ${moduleKind} assessment`, + evidenceRefs: [], + proposedOptionKeys: options.map((option) => option.optionKey), + objectiveAssessments, + recommendedOptionKeys: objectiveAssessments + .filter((row) => row.stance === "prefer") + .map((row) => row.optionKey), + detail: { + kind: { + clotho: "forecasts", + lachesis: "values", + atropos: "continuity", + }[moduleKind], + body: {}, + }, + diagnostics: {}, + }); +} + +function proposedIntention(): IntentionRecord { + return parseIntentionRecord({ + schemaVersion: 1, + intentionId: "intention-1", + agentId: AGENT, + scopeId: SCOPE, + revision: 0, + kind: "autonomous_goal", + purposeRef: "purpose-1", + text: "Adopt the fixture intention", + acceptance: { + sourceRef: "source-1", + acceptedBy: "host_autonomy", + policyRevision: 1, + acceptedAt: PROJECTED_AT, + }, + priority: 0, + deadline: null, + completionCondition: "Fixture completion", + abortConditions: [], + relatedIntentions: [], + status: "proposed", + history: [], + }); +} + +function p0(spec: SelectionSpec, optionKey: string): number | undefined { + return spec.candidates.find((row) => row.optionKey === optionKey)?.p0; +} + +function sortedAssessments(rows: Assessment[]): Assessment[] { + return [...rows].sort((a, b) => + a.moduleKind < b.moduleKind ? -1 : a.moduleKind > b.moduleKind ? 1 : 0, + ); +} + +const AUTONOMOUS_ORDER = ["lachesis", "clotho", "atropos"] as const; + +function playRound(store: JudgmentStore, situation: Situation) { + const projection = contextProjection(); + const profiles = putActivatedProfiles(store); + const options = canonicalOptions(); + const candidates = [ + options.adopt, + options.start, + options.inquire, + options.noop, + ]; + store.putIntention({ + ...proposedIntention(), + intentionId: "intention-start", + }); + const startIntention = store.transitionIntention( + "intention-start", + { + to: "adopted", + reason: "existing task intention", + evidenceRef: "source-1", + at: PROJECTED_AT, + }, + 0, + ); + const promise = proposedIntention(); + store.putIntention({ + ...promise, + intentionId: "protected-promise", + kind: "user_commitment", + acceptance: { ...promise.acceptance, acceptedBy: "user" }, + }); + const protectedIntention = store.transitionIntention( + "protected-promise", + { + to: "adopted", + reason: "accepted user promise", + evidenceRef: "source-1", + at: PROJECTED_AT, + }, + 0, + ); + const snapshot = { + ...snapshotRef(situation, profiles.refs, projection), + intentionRevision: store.intentionRevision(AGENT, SCOPE), + }; + const opened = store.openRound(snapshot); + expect(opened).toEqual({ + roundId: snapshot.roundId, + snapshotDigest: snapshotDigest(snapshot), + }); + const assessments = (["clotho", "lachesis", "atropos"] as const).map( + (moduleKind) => assessmentFor(snapshot, moduleKind, candidates), + ); + for (const assessment of assessments) store.putAssessment(assessment); + const set = store.assessmentSet(snapshot.roundId); + const closed = buildCandidateSet({ + roundId: snapshot.roundId, + snapshotDigest: snapshotDigest(snapshot), + options: candidates, + eligibility: candidates.map((option) => ({ + optionKey: option.optionKey, + eligible: option.kind !== "task.start", + reason: option.kind === "task.start" ? "infeasible precondition" : null, + })), + intentionRefs: [ + { + intentionId: protectedIntention.intentionId, + revision: protectedIntention.revision, + status: protectedIntention.status, + digest: intentionDigest(protectedIntention), + }, + { + intentionId: startIntention.intentionId, + revision: startIntention.revision, + status: startIntention.status, + digest: intentionDigest(startIntention), + }, + ], + }); + store.closeCandidateSet(closed); + const resolved = resolvePersonalRound({ + policy: PERSONAL_POLICY_V1, + snapshot, + options: candidates, + set, + eligibility: closed.eligibility, + bias: {}, + evidence: { + candidates: closed, + lookupIntention: (id) => store.getIntention(id), + }, + }); + store.recordResolution(snapshot.roundId, resolved.resolution, resolved.spec); + return { + projection, + profiles, + options, + candidates, + snapshot, + opened, + assessments, + resolved, + }; +} + +test("autonomous personal.v1 round persists SelectionSpec and adopted intention, with injected sampling in memory", () => { + const store = open(); + const { + projection, + profiles, + options, + candidates, + snapshot, + opened, + assessments, + resolved, + } = playRound(store, "autonomous"); + + expect(projection.workingRevision).toBe(7); + expect(projection.instructionRevision).toBe(2); + expect(projection.workingRevision !== projection.instructionRevision).toBe( + true, + ); + expect(snapshot.workingRevision !== snapshot.instructionRevision).toBe(true); + expect(snapshot.workingRevision).toBe(projection.workingRevision); + expect(snapshot.instructionRevision).toBe(projection.instructionRevision); + + expect(store.getObjectiveProfile("objective-clotho", 1)).toEqual( + profiles.clotho, + ); + expect(store.getObjectiveProfile("objective-lachesis", 1)).toEqual( + profiles.lachesis, + ); + expect(store.getObjectiveProfile("objective-atropos", 1)).toEqual( + profiles.atropos, + ); + expect(store.activeObjectiveProfiles(AGENT, SCOPE)).toEqual(profiles.refs); + + expect(options.adopt.catalogId).toBe(PERSONAL_CATALOG_ID); + expect(parseCanonicalOption(options.inquireVariant)).toEqual( + parseCanonicalOption(options.inquire), + ); + expect(options.inquireVariant.optionKey).toBe(options.inquire.optionKey); + expect(new Set(candidates.map((option) => option.optionKey)).size).toBe(4); + + expect(resolved.spec).not.toBeNull(); + if (resolved.spec === null) throw Error("expected resolved selection spec"); + const spec = resolved.spec; + const resolution = resolved.resolution; + expect(store.getSelectionSpec(snapshot.roundId)).toEqual(spec); + expect(store.getResolution(snapshot.roundId)).toEqual(resolution); + + expect(p0(spec, options.start.optionKey)).toBe(0); + expect(p0(spec, options.noop.optionKey)).toBe(0); + + expect(assessments.map((row) => row.moduleKind)).toEqual([ + "clotho", + "lachesis", + "atropos", + ]); + expect( + assessments + .find((row) => row.moduleKind === "lachesis") + ?.objectiveAssessments.find( + (row) => row.optionKey === options.adopt.optionKey, + )?.stance, + ).toBe("prefer"); + expect( + assessments + .find((row) => row.moduleKind === "clotho") + ?.objectiveAssessments.find( + (row) => row.optionKey === options.start.optionKey, + ), + ).toMatchObject({ stance: "oppose", severity: "infeasible" }); + expect( + assessments + .find((row) => row.moduleKind === "atropos") + ?.objectiveAssessments.find( + (row) => row.optionKey === options.noop.optionKey, + ), + ).toMatchObject({ stance: "oppose", severity: "commitment_breach" }); + expect( + resolution.ranking.find((row) => row.optionKey === options.adopt.optionKey) + ?.rank, + ).toBe(1); + expect( + Math.abs(spec.candidates.reduce((sum, row) => sum + row.p0, 0) - 1), + ).toBeLessThanOrEqual(1e-12); + expect(spec.lambda).toBe(1); + expect(spec.specDigest).toBe( + judgmentDigest({ ...spec, specDigest: undefined }), + ); + expect(resolution.order).toEqual([...AUTONOMOUS_ORDER]); + expect(resolution.excluded).toContainEqual({ + optionKey: options.start.optionKey, + stage: "host_eligibility", + byModule: null, + reason: "infeasible precondition", + }); + expect(resolution.excluded).toContainEqual({ + optionKey: options.noop.optionKey, + stage: "commitment_protection", + byModule: "atropos", + reason: "accepted commitment breach", + }); + + const high = sampleSelection(spec, 0.999); + const low = sampleSelection(spec, 0.0); + expect(p0(spec, high.optionKey)).toBeGreaterThan(0); + expect(p0(spec, low.optionKey)).toBeGreaterThan(0); + + const proposed = proposedIntention(); + store.putIntention(proposed); + const adoption = { + to: "adopted" as const, + reason: "selected by personal.v1 round", + evidenceRef: snapshot.roundId, + at: "2026-09-12T01:00:00.000Z", + }; + const adopted = transitionIntention(proposed, adoption); + expect(adopted.history[0]?.evidenceRef).toBe(snapshot.roundId); + expect(store.transitionIntention(proposed.intentionId, adoption, 0)).toEqual( + adopted, + ); + + close(store); + const reopened = open(); + expect(reopened.getObjectiveProfile("objective-clotho", 1)).toEqual( + profiles.clotho, + ); + expect(reopened.getObjectiveProfile("objective-lachesis", 1)).toEqual( + profiles.lachesis, + ); + expect(reopened.getObjectiveProfile("objective-atropos", 1)).toEqual( + profiles.atropos, + ); + expect(reopened.activeObjectiveProfiles(AGENT, SCOPE)).toEqual(profiles.refs); + const reopenedRound = reopened.getRound(snapshot.roundId); + expect(reopenedRound).toEqual({ + snapshot, + status: "resolved", + snapshotDigest: opened.snapshotDigest, + }); + expect(reopenedRound?.snapshot).toEqual(snapshot); + expect(reopenedRound?.snapshot.roundId).toBe(opened.roundId); + expect(reopenedRound?.snapshot.situation).toBe("autonomous"); + expect(reopenedRound?.snapshotDigest).toBe(snapshotDigest(snapshot)); + expect(reopenedRound?.status).toBe("resolved"); + expect( + sortedAssessments(reopened.assessmentSet(snapshot.roundId).assessments), + ).toEqual(sortedAssessments(assessments)); + expect(reopened.getResolution(snapshot.roundId)).toEqual(resolution); + expect(reopened.getResolution(snapshot.roundId, "action")?.order).toEqual([ + ...AUTONOMOUS_ORDER, + ]); + expect(reopened.getSelectionSpec(snapshot.roundId)).toEqual(spec); + expect(reopened.getIntention(proposed.intentionId)).toEqual(adopted); +}); + +test("user_request personal.v1 round sets lambda 0 and atropos-first order", () => { + const store = open(); + const { snapshot } = playRound(store, "user_request"); + const spec = store.getSelectionSpec(snapshot.roundId); + const resolution = store.getResolution(snapshot.roundId, "action"); + expect(spec).not.toBeNull(); + expect(resolution).not.toBeNull(); + if (spec === null || resolution === null) + throw Error("missing resolution artifacts"); + expect(spec.lambda).toBe(0); + expect(resolution.order[0]).toBe("atropos"); +}); diff --git a/packages/lina-core/test/judgment-store-digest-review.test.ts b/packages/lina-core/test/judgment-store-digest-review.test.ts new file mode 100644 index 0000000..30c9022 --- /dev/null +++ b/packages/lina-core/test/judgment-store-digest-review.test.ts @@ -0,0 +1,591 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { + type Assessment, + assessmentInputDigest, + buildCandidateSet, + buildCanonicalOption, + type IntentionRecord, + type IntentionTransition, + type JudgmentSnapshotRef, + JudgmentStore, + judgmentDigest, + MODULE_KINDS, + type ModuleKind, + type ObjectiveProfile, + parseAssessment, + parseAssessmentSet, + parseIntentionRecord, + parseJudgmentSnapshotRef, + parseObjectiveProfile, + parseResolutionRecord, + parseSelectionSpec, + type ResolutionRecord, + type SelectionSpec, + snapshotDigest, +} from "../src/agents/index.ts"; + +let dir: string; +let path: string; +let store: JudgmentStore; +let db: DatabaseSync; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "lina-judgment-digest-")); + path = join(dir, "judgment.sqlite"); + store = new JudgmentStore(path, { now: () => 1234 }); + db = new DatabaseSync(path); +}); +afterEach(() => { + db.close(); + store.close(); + rmSync(dir, { recursive: true, force: true }); + expect(existsSync(dir)).toBe(false); +}); +function reopen(): void { + store.close(); + store = new JudgmentStore(path, { now: () => 1234 }); +} +function profile( + moduleKind: ModuleKind = "clotho", + revision = 1, +): ObjectiveProfile { + return { + schemaVersion: 1, + objectiveId: `objective-${moduleKind}`, + moduleKind, + revision, + objective: "Compare fixture outcomes", + comparisonCriteria: ["cost", "outcome"], + reconsiderationConditions: ["new evidence"], + }; +} +function snapshot( + store: JudgmentStore, + roundId = "round-1", + sequence = 1, +): JudgmentSnapshotRef { + for (const module of MODULE_KINDS) { + const ref = store.putObjectiveProfile(profile(module)); + store.activateObjectiveProfile("agent-1", "scope-1", ref); + } + const refs = store.activeObjectiveProfiles("agent-1", "scope-1"); + if (!refs) throw Error("fixture profiles missing"); + return parseJudgmentSnapshotRef({ + schemaVersion: 1, + roundId, + agentId: "agent-1", + scopeId: "scope-1", + sourceRefs: [], + workingRevision: 2, + instructionRevision: 1, + policyId: "personal.v1", + policyRevision: 1, + identityRevision: 1, + domainRevisions: { life: 0 }, + intentionRevision: store.intentionRevision("agent-1", "scope-1"), + objectiveProfileRefs: refs, + observationRef: null, + frozenNeuralRef: null, + situation: "user_request", + clockId: "clock-1", + sequence, + bindingGeneration: 0, + }); +} +const optionA = buildCanonicalOption({ + kind: "noop", + actor: { agentId: "agent-1", scopeId: "scope-1" }, + targetId: null, + args: {}, + preconditions: { kind: "noop", reason: "fixture" }, +}); +const A = optionA.optionKey; +function closeCandidates(store: JudgmentStore, ref: JudgmentSnapshotRef) { + store.closeCandidateSet( + buildCandidateSet({ + roundId: ref.roundId, + snapshotDigest: snapshotDigest(ref), + options: [optionA], + eligibility: [{ optionKey: A, eligible: true, reason: null }], + intentionRefs: [], + }), + ); +} + +function assessment( + ref: JudgmentSnapshotRef, + moduleKind: ModuleKind, + digest = snapshotDigest(ref), +): Assessment { + const input = { + snapshotDigest: digest, + objectiveRef: ref.objectiveProfileRefs[moduleKind], + mechanismRevision: 1, + }; + return parseAssessment({ + schemaVersion: 1, + moduleKind, + snapshotId: ref.roundId, + ...input, + inputDigest: assessmentInputDigest(input), + completeText: "Fixture assessment", + evidenceRefs: [], + proposedOptionKeys: [A], + objectiveAssessments: [ + { + optionKey: A, + stance: "prefer", + severity: null, + unavailableReason: null, + gain: "gain", + loss: "loss", + uncertainty: "unknown", + evidenceRefs: [], + }, + ], + recommendedOptionKeys: [A], + detail: { + kind: { clotho: "forecasts", lachesis: "values", atropos: "continuity" }[ + moduleKind + ], + body: { z: [{ b: 2, a: 1 }], a: true }, + }, + diagnostics: {}, + }); +} +function resolution( + ref: JudgmentSnapshotRef, + status: ResolutionRecord["status"] = "resolved", +): ResolutionRecord { + return parseResolutionRecord({ + schemaVersion: 1, + roundId: ref.roundId, + policyId: "personal.v1", + policyRevision: 1, + situation: ref.situation, + order: ["atropos", "clotho", "lachesis"], + recommendations: { clotho: [A], lachesis: [A], atropos: [A] }, + conflicts: [], + excluded: [], + abstentions: [], + ranking: [{ optionKey: A, rank: 1 }], + conceded: [], + status, + holdReason: status === "resolved" ? null : "no eligible candidate", + }); +} +function spec( + ref: JudgmentSnapshotRef, + record = resolution(ref), +): SelectionSpec { + const body = { + schemaVersion: 1, + roundId: ref.roundId, + snapshotDigest: snapshotDigest(ref), + assessmentSetDigest: judgmentDigest( + parseAssessmentSet({ + schemaVersion: 1, + roundId: ref.roundId, + snapshotDigest: snapshotDigest(ref), + assessments: MODULE_KINDS.map((m) => assessment(ref, m)), + }), + ), + objectiveProfileRefs: ref.objectiveProfileRefs, + resolutionDigest: judgmentDigest(record), + policyId: record.policyId, + policyRevision: record.policyRevision, + situation: ref.situation, + lambda: 0, + candidates: [{ optionKey: A, p0: 1, b: null }], + eligibleDigest: judgmentDigest([A]), + }; + return parseSelectionSpec({ ...body, specDigest: judgmentDigest(body) }); +} +function intention(intentionId = "intention-1"): IntentionRecord { + return parseIntentionRecord({ + schemaVersion: 1, + intentionId, + agentId: "agent-1", + scopeId: "scope-1", + revision: 0, + kind: "user_commitment", + purposeRef: "purpose-1", + text: "Complete fixture task", + acceptance: { + sourceRef: "request-1", + acceptedBy: "user", + policyRevision: 1, + acceptedAt: "2026-09-11T00:00:00.000Z", + }, + priority: 0, + deadline: null, + completionCondition: "Outcome receipt", + abortConditions: [], + relatedIntentions: [], + status: "proposed", + history: [], + }); +} +function transition( + to: IntentionTransition["to"], +): Omit { + return { + to, + reason: "fixture transition", + evidenceRef: "request-1", + at: "2026-09-11T01:00:00.000Z", + }; +} + +function seed() { + store.putIntention(intention()); + const ref = snapshot(store); + store.openRound(ref); + closeCandidates(store, ref); + for (const module of MODULE_KINDS) + store.putAssessment(assessment(ref, module)); + return ref; +} +function replaceBody(table: string, value: unknown, where = "1 = 1"): void { + expect( + db + .prepare(`UPDATE ${table} SET body = ? WHERE ${where}`) + .run(JSON.stringify(value)).changes, + ).toBe(1); +} +function rehashSpec(selection: SelectionSpec): SelectionSpec { + return parseSelectionSpec({ + ...selection, + specDigest: judgmentDigest({ ...selection, specDigest: undefined }), + }); +} + +for (const tamper of ["body", "digest"] as const) { + for (const action of [ + "get", + "active", + "put", + "activate", + "openRound", + ] as const) { + test(`3995355426: objective ${tamper} mismatch rejects ${action} after reopen`, () => { + const ref = seed(); + const changed = parseObjectiveProfile({ + ...profile(), + objective: "Different objective", + }); + const expected = tamper === "body" ? changed : profile(); + if (tamper === "body") + replaceBody("objective_profiles", changed, "module_kind = 'clotho'"); + else + db.prepare( + "UPDATE objective_profiles SET digest = ? WHERE module_kind = 'clotho'", + ).run(judgmentDigest(changed)); + reopen(); + const objectiveRef = { + ...ref.objectiveProfileRefs.clotho, + digest: judgmentDigest(expected), + }; + const before = db + .prepare("SELECT * FROM objective_profile_active ORDER BY module_kind") + .all(); + const run = { + get: () => + store.getObjectiveProfile(expected.objectiveId, expected.revision), + active: () => store.activeObjectiveProfiles(ref.agentId, ref.scopeId), + put: () => store.putObjectiveProfile(expected), + activate: () => + store.activateObjectiveProfile( + ref.agentId, + ref.scopeId, + objectiveRef, + ), + openRound: () => + store.openRound({ + ...ref, + roundId: "round-2", + sequence: 2, + objectiveProfileRefs: { + ...ref.objectiveProfileRefs, + clotho: objectiveRef, + }, + }), + }; + expect(run[action]).toThrow("objective profile digest mismatch"); + expect( + db + .prepare( + "SELECT * FROM objective_profile_active ORDER BY module_kind", + ) + .all(), + ).toEqual(before); + expect(() => store.getRound("round-2")).toThrow( + "objective profile digest mismatch", + ); + expect( + db + .prepare("SELECT round_id FROM rounds WHERE round_id = 'round-2'") + .get(), + ).toBeUndefined(); + }); + } + for (const action of ["get", "list", "filtered", "transition"] as const) { + test(`3995355426: intention ${tamper} mismatch rejects ${action} after reopen`, () => { + seed(); + const changed = parseIntentionRecord({ + ...intention(), + text: "Different commitment", + }); + if (tamper === "body") replaceBody("intention_records", changed); + else + db.prepare("UPDATE intention_records SET digest = ?").run( + judgmentDigest(changed), + ); + reopen(); + const before = db.prepare("SELECT * FROM intention_records").all(); + const run = { + get: () => store.getIntention("intention-1"), + list: () => store.listIntentions("agent-1", "scope-1"), + filtered: () => store.listIntentions("agent-1", "scope-1", "proposed"), + transition: () => + store.transitionIntention("intention-1", transition("adopted"), 0), + }; + expect(run[action]).toThrow("intention digest mismatch"); + expect(db.prepare("SELECT * FROM intention_records").all()).toEqual( + before, + ); + expect( + db.prepare("SELECT count(*) AS n FROM intention_transitions").get(), + ).toEqual({ n: 0 }); + }); + } + for (const target of ["resolution", "selection"] as const) { + test(`3995355426: ${target} ${tamper} mismatch rejected after reopen`, () => { + const ref = seed(); + const record = resolution(ref); + const selection = spec(ref); + store.recordResolution(ref.roundId, record, selection); + const changedRecord = parseResolutionRecord({ + ...record, + ranking: [{ optionKey: A, rank: 2 }], + }); + // Keep the embedded selection digest valid: only its persisted anchor is stale. + const changedSelection = rehashSpec({ ...selection, lambda: 1 }); + if (target === "resolution") { + if (tamper === "body") replaceBody("resolution_records", changedRecord); + else + db.prepare("UPDATE resolution_records SET digest = ?").run( + judgmentDigest(changedRecord), + ); + } else { + if (tamper === "body") replaceBody("selection_specs", changedSelection); + else + db.prepare("UPDATE selection_specs SET spec_digest = ?").run( + changedSelection.specDigest, + ); + } + reopen(); + expect(() => + target === "resolution" + ? store.getResolution(ref.roundId) + : store.getSelectionSpec(ref.roundId), + ).toThrow( + `${target === "resolution" ? "resolution" : "selection spec"} digest mismatch`, + ); + }); + } +} + +for (const tamper of [ + "body", + "digest", + "input_digest", + "snapshot_digest", +] as const) { + for (const action of ["assessmentSet", "recordResolution"] as const) { + test(`3995355426: assessment ${tamper} mismatch rejects ${action} after reopen`, () => { + const ref = seed(); + const original = assessment(ref, "clotho"); + const changed = parseAssessment({ + ...original, + completeText: "Different assessment", + }); + if (tamper === "body") + replaceBody("assessments", changed, "module_kind = 'clotho'"); + else + db.prepare( + `UPDATE assessments SET ${tamper} = ? WHERE module_kind = 'clotho'`, + ).run(judgmentDigest(changed)); + // A matching downstream digest must not bless corruption in its source rows. + const expected = parseAssessmentSet({ + schemaVersion: 1, + roundId: ref.roundId, + snapshotDigest: snapshotDigest(ref), + assessments: MODULE_KINDS.map((module) => + module === "clotho" && tamper === "body" + ? changed + : assessment(ref, module), + ), + }); + const selection = rehashSpec({ + ...spec(ref), + assessmentSetDigest: judgmentDigest(expected), + }); + reopen(); + expect(() => + action === "assessmentSet" + ? store.assessmentSet(ref.roundId) + : store.recordResolution(ref.roundId, resolution(ref), selection), + ).toThrow( + tamper === "input_digest" + ? "assessment input digest mismatch" + : tamper === "snapshot_digest" + ? "assessment snapshot digest mismatch" + : "assessment digest mismatch", + ); + // A corrupt ledger fails closed even for queries that would return no rows. + expect(() => store.getResolution(ref.roundId)).toThrow( + /assessment .*mismatch/, + ); + expect(db.prepare("SELECT status FROM rounds").get()).toEqual({ + status: "open", + }); + expect( + db.prepare("SELECT count(*) AS n FROM resolution_records").get(), + ).toEqual({ n: 0 }); + expect( + db.prepare("SELECT count(*) AS n FROM selection_specs").get(), + ).toEqual({ n: 0 }); + }); + } +} + +for (const field of ["acceptedAt", "deadline", "at"] as const) { + test(`3995355457 persisted noncanonical ${field} rejects intact even with recomputed digests`, () => { + store.putIntention(intention()); + const original = + field === "at" + ? store.transitionIntention("intention-1", transition("adopted"), 0) + : intention(); + const changed = structuredClone(original); + const noncanonical = "2026-02-30T00:00:00.000Z"; + if (field === "deadline") changed.deadline = noncanonical; + else if (field === "acceptedAt") + changed.acceptance.acceptedAt = noncanonical; + else { + const first = changed.history[0]; + if (!first) throw Error("missing transition"); + first.at = noncanonical; + db.prepare("UPDATE intention_transitions SET at = ?").run(noncanonical); + } + const body = JSON.stringify(changed); + db.prepare("UPDATE intention_records SET body = ?, digest = ?").run( + body, + judgmentDigest(changed), + ); + reopen(); + expect(() => store.getIntention("intention-1")).toThrow( + /invalid (accepted at|intention deadline|transition at)/, + ); + expect(db.prepare("SELECT body FROM intention_records").get()).toEqual({ + body, + }); + }); +} + +test("3995355426: parser-normalized digests and valid lifecycle survive reopen", () => { + const ref = seed(); + const record = resolution(ref); + const selection = spec(ref); + store.recordResolution(ref.roundId, record, selection); + const initial = parseIntentionRecord({ + ...intention("intention-2"), + abortConditions: ["a", "b"], + }); + store.putIntention(initial); + const changed = { + ...profile(), + comparisonCriteria: [...profile().comparisonCriteria].reverse(), + }; + expect(judgmentDigest(changed)).not.toBe( + judgmentDigest(parseObjectiveProfile(changed)), + ); + replaceBody("objective_profiles", changed, "module_kind = 'clotho'"); + const originalAssessment = parseAssessment({ + ...assessment(ref, "clotho"), + evidenceRefs: ["a", "b"], + }); + // Update both anchor and body to a valid canonical assessment, then reorder its set-like list. + db.prepare( + "UPDATE assessments SET digest = ?, body = ? WHERE module_kind = 'clotho'", + ).run( + judgmentDigest(originalAssessment), + JSON.stringify({ ...originalAssessment, evidenceRefs: ["b", "a"] }), + ); + const originalResolution = record; + db.prepare("UPDATE resolution_records SET digest = ?, body = ?").run( + judgmentDigest(originalResolution), + JSON.stringify( + Object.fromEntries(Object.entries(originalResolution).reverse()), + ), + ); + replaceBody( + "intention_records", + { ...initial, abortConditions: ["b", "a"] }, + "intention_id = 'intention-2'", + ); + // Selection has no sortable set-like list; JSON key order/whitespace are not digest input. + // Keep the downstream anchor consistent with the changed assessment evidence. + const normalizedSelection = rehashSpec({ + ...selection, + assessmentSetDigest: judgmentDigest( + parseAssessmentSet({ + schemaVersion: 1, + roundId: ref.roundId, + snapshotDigest: snapshotDigest(ref), + assessments: MODULE_KINDS.map((module) => + module === "clotho" ? originalAssessment : assessment(ref, module), + ), + }), + ), + }); + db.prepare("UPDATE selection_specs SET body = ?, spec_digest = ?").run( + JSON.stringify( + Object.fromEntries(Object.entries(normalizedSelection).reverse()), + ), + normalizedSelection.specDigest, + ); + reopen(); + expect(store.getObjectiveProfile("objective-clotho", 1)).toEqual(profile()); + expect(store.activeObjectiveProfiles(ref.agentId, ref.scopeId)).toEqual( + ref.objectiveProfileRefs, + ); + expect(store.assessmentSet(ref.roundId).assessments[0]).toEqual( + originalAssessment, + ); + expect(store.getResolution(ref.roundId)).toEqual(originalResolution); + expect(store.getSelectionSpec(ref.roundId)).toEqual(normalizedSelection); + expect(store.getIntention(initial.intentionId)).toEqual(initial); + expect(store.listIntentions(ref.agentId, ref.scopeId, "proposed")).toEqual([ + intention(), + initial, + ]); + const adopted = store.transitionIntention( + initial.intentionId, + transition("adopted"), + 0, + ); + reopen(); + expect(store.getIntention(initial.intentionId)).toEqual(adopted); + expect(store.listIntentions(ref.agentId, ref.scopeId, "adopted")).toEqual([ + adopted, + ]); + expect(store.getObjectiveProfile("missing", 1)).toBeNull(); + expect(store.getResolution("missing")).toBeNull(); + expect(store.getSelectionSpec("missing")).toBeNull(); + expect(store.getIntention("missing")).toBeNull(); + expect(store.listIntentions("missing", ref.scopeId)).toEqual([]); +}); diff --git a/packages/lina-core/test/judgment-store.test.ts b/packages/lina-core/test/judgment-store.test.ts new file mode 100644 index 0000000..44cd522 --- /dev/null +++ b/packages/lina-core/test/judgment-store.test.ts @@ -0,0 +1,1732 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { mkdirSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { + type Assessment, + assessmentInputDigest, + buildCandidateSet, + buildCanonicalOption, + type CanonicalOption, + type IntentionRecord, + type IntentionTransition, + JUDGMENT_SCHEMA_VERSION, + type JudgmentSnapshotRef, + JudgmentStore, + judgmentDigest, + MODULE_KINDS, + type ModuleKind, + type ObjectiveProfile, + parseAssessment, + parseAssessmentSet, + parseIntentionRecord, + parseJudgmentSnapshotRef, + parseObjectiveProfile, + parseResolutionRecord, + parseSelectionSpec, + type ResolutionRecord, + type SelectionSpec, + snapshotDigest, + transitionIntention, +} from "../src/agents/index.ts"; +import { Fixture } from "./fixture.ts"; + +let fixture: Fixture; +let path: string; +const stores = new Set(); +beforeEach(() => { + fixture = new Fixture(); + path = join(fixture.dir, "judgment.sqlite"); +}); +afterEach(() => { + for (const store of stores) store.close(); + stores.clear(); + fixture.close(); +}); +function open(file = path): JudgmentStore { + const store = new JudgmentStore(file, { now: () => 1234 }); + stores.add(store); + return store; +} +function close(store: JudgmentStore): void { + store.close(); + stores.delete(store); +} +function database(file = path): DatabaseSync { + return fixture.keep(new DatabaseSync(file)); +} +function ddl(db: DatabaseSync) { + return db + .prepare( + "SELECT name,sql FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*' ORDER BY name", + ) + .all(); +} +function profile( + moduleKind: ModuleKind = "clotho", + revision = 1, +): ObjectiveProfile { + return { + schemaVersion: 1, + objectiveId: `objective-${moduleKind}`, + moduleKind, + revision, + objective: "Compare fixture outcomes", + comparisonCriteria: ["cost", "outcome"], + reconsiderationConditions: ["new evidence"], + }; +} +function snapshot( + store: JudgmentStore, + roundId = "round-1", + sequence = 1, +): JudgmentSnapshotRef { + for (const module of MODULE_KINDS) { + const ref = store.putObjectiveProfile(profile(module)); + store.activateObjectiveProfile("agent-1", "scope-1", ref); + } + const refs = store.activeObjectiveProfiles("agent-1", "scope-1"); + if (!refs) throw Error("fixture profiles missing"); + return parseJudgmentSnapshotRef({ + schemaVersion: 1, + roundId, + agentId: "agent-1", + scopeId: "scope-1", + sourceRefs: [], + workingRevision: 2, + instructionRevision: 1, + policyId: "personal.v1", + policyRevision: 1, + identityRevision: 1, + domainRevisions: { life: 0 }, + intentionRevision: store.intentionRevision("agent-1", "scope-1"), + objectiveProfileRefs: refs, + observationRef: null, + frozenNeuralRef: null, + situation: "user_request", + clockId: "clock-1", + sequence, + bindingGeneration: 0, + }); +} +const fixtureOptions = ["a", "b", "excluded"] + .map((reason) => + buildCanonicalOption({ + kind: "noop", + actor: { agentId: "agent-1", scopeId: "scope-1" }, + targetId: null, + args: {}, + preconditions: { kind: "noop", reason }, + }), + ) + .sort((a, b) => (a.optionKey < b.optionKey ? -1 : 1)); +const [optionA, optionB, optionExcluded] = fixtureOptions; +if (!optionA || !optionB || !optionExcluded) + throw Error("missing fixture options"); +const A = optionA.optionKey, + B = optionB.optionKey, + EXCLUDED = optionExcluded.optionKey; +function closeCandidates( + store: JudgmentStore, + ref: JudgmentSnapshotRef, + options: CanonicalOption[] = fixtureOptions.slice(0, 1), +) { + store.closeCandidateSet( + buildCandidateSet({ + roundId: ref.roundId, + snapshotDigest: snapshotDigest(ref), + options, + eligibility: options.map((o) => ({ + optionKey: o.optionKey, + eligible: o.optionKey !== EXCLUDED, + reason: o.optionKey === EXCLUDED ? "not eligible" : null, + })), + intentionRefs: [], + }), + ); +} + +function assessment( + ref: JudgmentSnapshotRef, + moduleKind: ModuleKind, + digest = snapshotDigest(ref), +): Assessment { + const input = { + snapshotDigest: digest, + objectiveRef: ref.objectiveProfileRefs[moduleKind], + mechanismRevision: 1, + }; + return parseAssessment({ + schemaVersion: 1, + moduleKind, + snapshotId: ref.roundId, + ...input, + inputDigest: assessmentInputDigest(input), + completeText: "Fixture assessment", + evidenceRefs: [], + proposedOptionKeys: [A], + objectiveAssessments: [ + { + optionKey: A, + stance: "prefer", + severity: null, + unavailableReason: null, + gain: "gain", + loss: "loss", + uncertainty: "unknown", + evidenceRefs: [], + }, + ], + recommendedOptionKeys: [A], + detail: { + kind: { clotho: "forecasts", lachesis: "values", atropos: "continuity" }[ + moduleKind + ], + body: { z: [{ b: 2, a: 1 }], a: true }, + }, + diagnostics: {}, + }); +} +function resolution( + ref: JudgmentSnapshotRef, + status: ResolutionRecord["status"] = "resolved", +): ResolutionRecord { + return parseResolutionRecord({ + schemaVersion: 1, + roundId: ref.roundId, + policyId: "personal.v1", + policyRevision: 1, + situation: ref.situation, + order: ["atropos", "clotho", "lachesis"], + recommendations: { clotho: [A], lachesis: [A], atropos: [A] }, + conflicts: [], + excluded: [], + abstentions: [], + ranking: [{ optionKey: A, rank: 1 }], + conceded: [], + status, + holdReason: status === "resolved" ? null : "no eligible candidate", + }); +} +function spec( + ref: JudgmentSnapshotRef, + record = resolution(ref), +): SelectionSpec { + const body = { + schemaVersion: 1, + roundId: ref.roundId, + snapshotDigest: snapshotDigest(ref), + assessmentSetDigest: judgmentDigest( + parseAssessmentSet({ + schemaVersion: 1, + roundId: ref.roundId, + snapshotDigest: snapshotDigest(ref), + assessments: MODULE_KINDS.map((m) => assessment(ref, m)), + }), + ), + objectiveProfileRefs: ref.objectiveProfileRefs, + resolutionDigest: judgmentDigest(record), + policyId: record.policyId, + policyRevision: record.policyRevision, + situation: ref.situation, + lambda: 0, + candidates: [{ optionKey: A, p0: 1, b: null }], + eligibleDigest: judgmentDigest([A]), + }; + return parseSelectionSpec({ ...body, specDigest: judgmentDigest(body) }); +} +function intention(intentionId = "intention-1"): IntentionRecord { + return parseIntentionRecord({ + schemaVersion: 1, + intentionId, + agentId: "agent-1", + scopeId: "scope-1", + revision: 0, + kind: "user_commitment", + purposeRef: "purpose-1", + text: "Complete fixture task", + acceptance: { + sourceRef: "request-1", + acceptedBy: "user", + policyRevision: 1, + acceptedAt: "2026-09-11T00:00:00.000Z", + }, + priority: 0, + deadline: null, + completionCondition: "Outcome receipt", + abortConditions: [], + relatedIntentions: [], + status: "proposed", + history: [], + }); +} +function transition( + to: IntentionTransition["to"], +): Omit { + return { + to, + reason: "fixture transition", + evidenceRef: "request-1", + at: "2026-09-11T01:00:00.000Z", + }; +} +function canonical(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonical); + if (value !== null && typeof value === "object") + return Object.fromEntries( + Object.entries(value) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([k, v]) => [k, canonical(v)]), + ); + return value; +} + +for (const stage of [ + "infeasible", + "commitment_protection", + "host_eligibility", +] as const) { + test(`3996172412: recomputed forged ${stage} exclusion is rejected by the existing store API`, () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + closeCandidates(store, ref, [optionA, optionB]); + for (const module of MODULE_KINDS) { + const original = assessment(ref, module); + store.putAssessment( + parseAssessment({ + ...original, + objectiveAssessments: [A, B].map((optionKey) => ({ + ...original.objectiveAssessments[0], + optionKey, + stance: "accept", + })), + }), + ); + } + const record = parseResolutionRecord({ + ...resolution(ref), + excluded: [ + { + optionKey: B, + stage, + byModule: + stage === "infeasible" + ? "clotho" + : stage === "commitment_protection" + ? "atropos" + : null, + reason: "forged exclusion", + }, + ], + }); + const selection = rehashSelection({ + ...spec(ref, record), + assessmentSetDigest: judgmentDigest(store.assessmentSet(ref.roundId)), + candidates: [ + { optionKey: A, p0: 1, b: null }, + { optionKey: B, p0: 0, b: null }, + ], + }); + expect(() => + store.recordResolution(ref.roundId, record, selection), + ).toThrow(); + expect(store.getRound(ref.roundId)?.status).toBe("open"); + expect(store.getResolution(ref.roundId)).toBeNull(); + }); +} + +test("canonical JSON bytes are shared with judgment digests", () => { + const value = { + z: [ + { d: 1, c: 2 }, + { b: null, a: "\u65e5\u672c\u8a9e" }, + ], + a: null, + missing: undefined, + text: "caf\u00e9", + }; + expect(judgmentDigest(value)).toBe( + createHash("sha256") + .update(JSON.stringify(canonical(value))) + .digest("hex"), + ); + const store = open(); + const saved = profile(); + store.putObjectiveProfile(saved); + const db = fixture.keep(new DatabaseSync(path, { readOnly: true })); + expect(db.prepare("SELECT body FROM objective_profiles").get()).toEqual({ + body: JSON.stringify(canonical(saved)), + }); +}); + +test("fresh schema has exactly ten STRICT tables, metadata, WAL and unchanged DDL on reopen", () => { + let store = open(); + const db = database(); + const original = ddl(db); + expect(JUDGMENT_SCHEMA_VERSION).toBe(1); + expect(db.prepare("PRAGMA user_version").get()).toEqual({ user_version: 1 }); + expect(db.prepare("PRAGMA journal_mode").get()).toEqual({ + journal_mode: "wal", + }); + expect(original.map(({ name }) => name)).toEqual([ + "assessments", + "candidate_sets", + "intention_records", + "intention_transitions", + "judgment_meta", + "objective_profile_active", + "objective_profiles", + "resolution_records", + "rounds", + "selection_specs", + ]); + for (const { sql } of original) expect(sql).toContain("STRICT"); + expect( + db.prepare("SELECT key,value FROM judgment_meta ORDER BY key").all(), + ).toEqual([ + { key: "schema_version", value: "1" }, + { key: "store", value: "judgment" }, + ]); + close(store); + store = open(); + expect(ddl(db)).toEqual(original); + expect(store.getRound("missing")).toBeNull(); +}); + +for (const change of [ + "CREATE TABLE foreign_table (id INTEGER)", + "PRAGMA user_version = 99", + "CREATE TABLE judgment_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT; INSERT INTO judgment_meta VALUES ('store','judgment'),('schema_version','1'); PRAGMA user_version=1", +]) + test(`foreign schema rejected: ${change}`, () => { + const db = database(); + db.exec(change); + const before = ddl(db); + expect(() => open()).toThrow("unknown judgment store schema"); + expect(ddl(db)).toEqual(before); + }); + +for (const change of [ + "ALTER TABLE rounds ADD COLUMN extra TEXT", + "DROP TABLE candidate_sets", + "UPDATE judgment_meta SET value='other' WHERE key='store'", + "UPDATE judgment_meta SET value='2' WHERE key='schema_version'", + "DELETE FROM judgment_meta WHERE key='store'", + "CREATE INDEX extra ON rounds(status)", + "PRAGMA user_version=2", +]) + test(`tampered judgment schema rejected: ${change}`, () => { + close(open()); + const db = database(); + db.exec(change); + expect(() => open()).toThrow("unknown judgment store schema"); + }); + +test("nonfresh empty SQLite schema is rejected", () => { + const db = database(); + db.exec("VACUUM"); + expect(statSync(path).size).toBeGreaterThan(0); + expect(() => open()).toThrow("unknown judgment store schema"); +}); + +test("objective revisions are append-only and activation changes only for a new reference", () => { + const store = open(); + const ref = store.putObjectiveProfile(profile()); + expect(store.putObjectiveProfile(profile())).toEqual(ref); + expect(store.getObjectiveProfile(ref.objectiveId, 1)).toEqual(profile()); + expect(store.getObjectiveProfile("missing", 1)).toBeNull(); + expect(() => + store.putObjectiveProfile({ ...profile(), objective: "different" }), + ).toThrow("objective profile revision already exists"); + expect(store.activeObjectiveProfiles("agent-1", "scope-1")).toBeNull(); + expect(store.activateObjectiveProfile("agent-1", "scope-1", ref)).toEqual({ + activationRevision: 1, + }); + expect(store.activateObjectiveProfile("agent-1", "scope-1", ref)).toEqual({ + activationRevision: 1, + }); + const next = store.putObjectiveProfile(profile("clotho", 2)); + expect(store.activateObjectiveProfile("agent-1", "scope-1", next)).toEqual({ + activationRevision: 2, + }); + expect(store.getObjectiveProfile(ref.objectiveId, 1)).toEqual(profile()); + for (const module of ["lachesis", "atropos"] as const) + store.activateObjectiveProfile( + "agent-1", + "scope-1", + store.putObjectiveProfile(profile(module)), + ); + expect(store.activeObjectiveProfiles("agent-1", "scope-1")?.clotho).toEqual( + next, + ); + expect(store.activeObjectiveProfiles("agent-1", "other")).toBeNull(); + for (const invalid of [ + { ...ref, digest: "wrong" }, + { ...ref, revision: 99 }, + { ...ref, extra: true }, + ]) + expect(() => + store.activateObjectiveProfile("agent-1", "scope-1", invalid), + ).toThrow(); + expect(store.activeObjectiveProfiles("agent-1", "scope-1")?.clotho).toEqual( + next, + ); +}); + +test("rounds freeze active references and reject duplicate ids or scope sequences", () => { + const store = open(); + const ref = snapshot(store); + const stale = { + ...ref, + objectiveProfileRefs: { + ...ref.objectiveProfileRefs, + clotho: { ...ref.objectiveProfileRefs.clotho, digest: "stale" }, + }, + }; + expect(() => store.openRound(stale)).toThrow("stale objective profile refs"); + expect(store.openRound(ref)).toEqual({ + roundId: ref.roundId, + snapshotDigest: snapshotDigest(ref), + }); + expect(() => store.openRound(ref)).toThrow(); + expect(() => store.openRound({ ...ref, roundId: "round-2" })).toThrow(); + store.activateObjectiveProfile( + ref.agentId, + ref.scopeId, + store.putObjectiveProfile(profile("clotho", 2)), + ); + expect(store.getRound(ref.roundId)).toEqual({ + snapshot: ref, + status: "open", + snapshotDigest: snapshotDigest(ref), + }); + expect(() => + store.openRound({ ...ref, roundId: "round-3", sequence: 3 }), + ).toThrow("stale objective profile refs"); +}); + +for (const tamper of ["snapshot", "snapshot_digest"] as const) { + test(`3995117504: ${tamper} tampering is rejected on read and writes after reopen`, () => { + let store = open(); + const ref = snapshot(store); + store.openRound(ref); + closeCandidates(store, ref); + close(store); + const db = database(); + db.prepare(`UPDATE rounds SET ${tamper} = ? WHERE round_id = ?`).run( + tamper === "snapshot" + ? JSON.stringify({ ...ref, clockId: "tampered-clock" }) + : "tampered-digest", + ref.roundId, + ); + store = open(); + expect(() => store.getRound(ref.roundId)).toThrow( + "snapshot digest mismatch", + ); + expect(() => store.putAssessment(assessment(ref, "clotho"))).toThrow( + "snapshot digest mismatch", + ); + expect(() => + store.recordResolution(ref.roundId, resolution(ref, "held"), null), + ).toThrow("snapshot digest mismatch"); + expect(db.prepare("SELECT count(*) AS n FROM assessments").get()).toEqual({ + n: 0, + }); + expect(() => store.getResolution(ref.roundId)).toThrow( + "snapshot digest mismatch", + ); + expect( + db.prepare("SELECT count(*) AS n FROM resolution_records").get(), + ).toEqual({ n: 0 }); + }); +} + +test("3995117504: snapshot digest is checked after parser normalization", () => { + const store = open(); + const ref = snapshot(store); + ref.sourceRefs = [ + { kind: "request", id: "a", revision: 1 }, + { kind: "request", id: "b", revision: 1 }, + ]; + store.openRound(ref); + closeCandidates(store, ref); + database() + .prepare("UPDATE rounds SET snapshot = ? WHERE round_id = ?") + .run( + JSON.stringify({ ...ref, sourceRefs: [...ref.sourceRefs].reverse() }), + ref.roundId, + ); + expect(store.getRound(ref.roundId)).toEqual({ + snapshot: ref, + status: "open", + snapshotDigest: snapshotDigest(ref), + }); +}); + +test("assessments require an open matching snapshot and exactly one of each module", () => { + const store = open(); + const ref = snapshot(store); + expect(() => store.putAssessment(assessment(ref, "clotho"))).toThrow(); + store.openRound(ref); + expect(() => store.putAssessment(assessment(ref, "clotho", "wrong"))).toThrow( + "assessment snapshot mismatch", + ); + store.putAssessment(assessment(ref, "clotho")); + expect(() => store.putAssessment(assessment(ref, "clotho"))).toThrow( + "duplicate assessment", + ); + store.putAssessment(assessment(ref, "lachesis")); + expect(() => store.assessmentSet(ref.roundId)).toThrow( + "incomplete assessment set", + ); + store.putAssessment(assessment(ref, "atropos")); + const set = store.assessmentSet(ref.roundId); + expect(set).toEqual( + parseAssessmentSet({ + schemaVersion: 1, + roundId: ref.roundId, + snapshotDigest: snapshotDigest(ref), + assessments: MODULE_KINDS.map((m) => assessment(ref, m)), + }), + ); + store.recordResolution(ref.roundId, resolution(ref, "held"), null); + expect(() => store.putAssessment(assessment(ref, "clotho"))).toThrow(); + expect(store.assessmentSet(ref.roundId)).toEqual(set); + expect(() => store.assessmentSet("missing")).toThrow( + "incomplete assessment set", + ); +}); + +for (const change of [ + { objectiveId: "unregistered-objective" }, + { revision: 999 }, + { digest: "wrong-objective-digest" }, +]) { + test(`assessment must match frozen objective ${Object.keys(change)[0]}`, () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + closeCandidates(store, ref); + const original = assessment(ref, "clotho"); + const changed = { + ...original, + objectiveRef: { ...original.objectiveRef, ...change }, + }; + const invalid = parseAssessment({ + ...changed, + inputDigest: assessmentInputDigest(changed), + }); + expect(() => store.putAssessment(invalid)).toThrow( + "assessment objective mismatch", + ); + expect( + database().prepare("SELECT count(*) AS n FROM assessments").get(), + ).toEqual({ n: 0 }); + store.putAssessment(original); + }); +} + +for (const status of ["resolved", "held", "deferred"] as const) { + for (const change of [ + { situation: "autonomous" as const }, + { policyId: "other-policy" }, + { policyRevision: 999 }, + ]) { + test(`${status} resolution must match snapshot ${Object.keys(change)[0]}`, () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + closeCandidates(store, ref); + for (const m of MODULE_KINDS) store.putAssessment(assessment(ref, m)); + const record = { ...resolution(ref, status), ...change }; + expect(() => + store.recordResolution( + ref.roundId, + record, + status === "resolved" ? spec(ref, record) : null, + ), + ).toThrow("resolution snapshot mismatch"); + expect(store.getRound(ref.roundId)?.status).toBe("open"); + expect(store.getResolution(ref.roundId)).toBeNull(); + expect(store.getSelectionSpec(ref.roundId)).toBeNull(); + }); + } +} + +test("resolved round requires all three persisted assessments", () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + closeCandidates(store, ref); + store.putAssessment(assessment(ref, "clotho")); + expect(() => + store.recordResolution(ref.roundId, resolution(ref), spec(ref)), + ).toThrow("incomplete assessment set"); + expect(store.getRound(ref.roundId)?.status).toBe("open"); + expect(store.getResolution(ref.roundId)).toBeNull(); + expect(store.getSelectionSpec(ref.roundId)).toBeNull(); +}); + +const selectionMismatches: Array<{ + label: string; + change: (selection: SelectionSpec) => Partial; + error: string; +}> = [ + { + label: "assessment set digest", + change: () => ({ assessmentSetDigest: "nonexistent-set" }), + error: "selection assessment set mismatch", + }, + { + label: "resolution digest", + change: () => ({ resolutionDigest: "wrong-resolution" }), + error: "selection resolution digest mismatch", + }, + { + label: "policy id", + change: () => ({ policyId: "other-policy" }), + error: "selection policy mismatch", + }, + { + label: "policy revision", + change: () => ({ policyRevision: 999 }), + error: "selection policy mismatch", + }, + { + label: "situation", + change: () => ({ situation: "autonomous" }), + error: "selection policy mismatch", + }, + ...[ + { objectiveId: "other-objective" }, + { revision: 999 }, + { digest: "wrong-objective" }, + ].map((change) => ({ + label: `objective ${Object.keys(change)[0]}`, + change: (selection: SelectionSpec) => ({ + objectiveProfileRefs: { + ...selection.objectiveProfileRefs, + clotho: { ...selection.objectiveProfileRefs.clotho, ...change }, + }, + }), + error: "selection objective refs mismatch", + })), +]; +for (const { label, change, error } of selectionMismatches) { + test(`selection rejects inconsistent ${label} atomically`, () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + closeCandidates(store, ref); + for (const m of MODULE_KINDS) store.putAssessment(assessment(ref, m)); + const selection = spec(ref); + const changed = { ...selection, ...change(selection) }; + const invalid = parseSelectionSpec({ + ...changed, + specDigest: judgmentDigest({ ...changed, specDigest: undefined }), + }); + expect(() => + store.recordResolution(ref.roundId, resolution(ref), invalid), + ).toThrow(error); + expect(store.getRound(ref.roundId)?.status).toBe("open"); + expect(store.getResolution(ref.roundId)).toBeNull(); + expect(store.getSelectionSpec(ref.roundId)).toBeNull(); + store.recordResolution(ref.roundId, resolution(ref), selection); + expect(store.getRound(ref.roundId)?.status).toBe("resolved"); + expect(store.getResolution(ref.roundId)).toEqual(resolution(ref)); + expect(store.getSelectionSpec(ref.roundId)).toEqual(selection); + }); +} + +test("3998853057 assessments retain frozen refs but stale objective resolution rejects atomically", () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + closeCandidates(store, ref); + const next = store.putObjectiveProfile(profile("clotho", 2)); + store.activateObjectiveProfile(ref.agentId, ref.scopeId, next); + for (const m of MODULE_KINDS) store.putAssessment(assessment(ref, m)); + const selection = spec(ref); + expect(selection.assessmentSetDigest).toBe( + judgmentDigest(store.assessmentSet(ref.roundId)), + ); + expect(() => + store.recordResolution(ref.roundId, resolution(ref), selection), + ).toThrow("stale objective profile refs"); + expect(store.assessmentSet(ref.roundId).assessments).toHaveLength(3); + expect(store.getResolution(ref.roundId)).toBeNull(); + expect(store.getSelectionSpec(ref.roundId)).toBeNull(); + expect(store.getRound(ref.roundId)?.status).toBe("open"); +}); + +test("3998853057 resolved action remains historical after activation and intention changes", () => { + let store = open(); + const ref = snapshot(store); + store.openRound(ref); + closeCandidates(store, ref); + for (const m of MODULE_KINDS) store.putAssessment(assessment(ref, m)); + store.activateObjectiveProfile( + ref.agentId, + ref.scopeId, + ref.objectiveProfileRefs.clotho, + ); + store.activateObjectiveProfile( + "other-agent", + ref.scopeId, + store.putObjectiveProfile(profile("clotho", 2)), + ); + store.activateObjectiveProfile( + ref.agentId, + "other-scope", + store.putObjectiveProfile(profile("clotho", 2)), + ); + store.recordResolution(ref.roundId, resolution(ref), spec(ref)); + store.activateObjectiveProfile( + ref.agentId, + ref.scopeId, + store.putObjectiveProfile(profile("clotho", 2)), + ); + store.putIntention(intention()); + store.transitionIntention("intention-1", transition("adopted"), 0); + close(store); + store = open(); + expect(store.getResolution(ref.roundId)).toEqual(resolution(ref)); + expect(store.getSelectionSpec(ref.roundId)).toEqual(spec(ref)); +}); + +test("3998853070 scoped mutation sum counts creates/transitions and survives reopen", () => { + let store = open(); + expect(store.intentionRevision("agent-1", "scope-1")).toBe(0); + store.putIntention(intention()); + expect(store.intentionRevision("agent-1", "scope-1")).toBe(1); + store.putIntention(intention("second")); + expect(store.intentionRevision("agent-1", "scope-1")).toBe(2); + store.transitionIntention("intention-1", transition("adopted"), 0); + expect(() => store.putIntention(intention())).toThrow("duplicate intention"); + expect(() => + store.transitionIntention("intention-1", transition("active"), 0), + ).toThrow("stale intention revision"); + expect(() => + store.transitionIntention("intention-1", transition("completed"), 1), + ).toThrow(); + expect(store.intentionRevision("agent-1", "scope-1")).toBe(3); + expect(store.intentionRevision("agent-1", "other")).toBe(0); + expect(store.intentionRevision("other", "scope-1")).toBe(0); + close(store); + store = open(); + expect(store.intentionRevision("agent-1", "scope-1")).toBe(3); +}); + +for (const mutation of ["create", "transition"] as const) { + test(`3998853070 ${mutation} invalidates open and resolution even without candidate intention refs`, () => { + const store = open(); + store.putIntention(intention()); + const ref = { ...snapshot(store), intentionRevision: 1 }; + store.openRound(ref); + closeCandidates(store, ref); + for (const m of MODULE_KINDS) store.putAssessment(assessment(ref, m)); + if (mutation === "create") store.putIntention(intention("second")); + else store.transitionIntention("intention-1", transition("adopted"), 0); + expect(() => + store.openRound({ ...ref, roundId: "stale", sequence: 2 }), + ).toThrow("stale intention revision"); + expect(() => + store.recordResolution(ref.roundId, resolution(ref), spec(ref)), + ).toThrow("stale intention revision"); + expect(store.getRound(ref.roundId)?.status).toBe("open"); + expect(store.getResolution(ref.roundId)).toBeNull(); + expect(store.getSelectionSpec(ref.roundId)).toBeNull(); + const fresh = { + ...ref, + roundId: "fresh", + sequence: 3, + intentionRevision: store.intentionRevision(ref.agentId, ref.scopeId), + }; + store.openRound(fresh); + closeCandidates(store, fresh); + for (const m of MODULE_KINDS) store.putAssessment(assessment(fresh, m)); + store.recordResolution(fresh.roundId, resolution(fresh), spec(fresh)); + expect(store.getRound(fresh.roundId)?.status).toBe("resolved"); + }); +} + +test("3998853070 unsafe scoped counter prevents both mutation paths atomically", () => { + const store = open(); + store.putIntention(intention()); + // The owner counter boundary is the only synthetic value; all mutations use real SQLite. + const counter = spyOn(store, "intentionRevision").mockReturnValue( + Number.MAX_SAFE_INTEGER, + ); + try { + expect(() => store.putIntention(intention("overflow"))).toThrow( + "invalid judgment revision", + ); + expect(() => + store.transitionIntention("intention-1", transition("adopted"), 0), + ).toThrow("invalid judgment revision"); + expect(store.getIntention("overflow")).toBeNull(); + expect(store.getIntention("intention-1")).toEqual(intention()); + expect( + database() + .prepare("SELECT count(*) AS n FROM intention_transitions") + .get(), + ).toEqual({ n: 0 }); + } finally { + counter.mockRestore(); + } + expect(store.intentionRevision("agent-1", "scope-1")).toBe(1); +}); + +test("3998853089 scoped sequence increases across handles and reopen with gaps allowed", () => { + let store = open(); + const ref = snapshot(store, "two", 2); + const second = open(); + store.openRound(ref); + for (const sequence of [1, 2]) + expect(() => + second.openRound({ ...ref, roundId: `rejected-${sequence}`, sequence }), + ).toThrow(); + second.openRound({ ...ref, roundId: "four", sequence: 4 }); + close(store); + store = open(); + expect(() => + store.openRound({ ...ref, roundId: "three", sequence: 3 }), + ).toThrow("stale round sequence"); + store.openRound({ ...ref, roundId: "five", sequence: 5 }); + for (const change of [{ agentId: "other" }, { scopeId: "other" }]) { + const independent = { + ...ref, + ...change, + roundId: JSON.stringify(change), + sequence: 1, + }; + for (const m of MODULE_KINDS) + store.activateObjectiveProfile( + independent.agentId, + independent.scopeId, + ref.objectiveProfileRefs[m], + ); + store.openRound(independent); + } + expect(database().prepare("SELECT count(*) AS n FROM rounds").get()).toEqual({ + n: 5, + }); +}); + +for (const field of ["acceptedAt", "deadline", "at"] as const) { + test(`3995355457 store rejects noncanonical ${field} before mutation`, () => { + const store = open(); + const value = "2026-02-30T00:00:00.000Z"; + if (field === "at") { + store.putIntention(intention()); + expect(() => + store.transitionIntention( + "intention-1", + { ...transition("adopted"), at: value }, + 0, + ), + ).toThrow(); + expect(store.getIntention("intention-1")).toEqual(intention()); + } else { + const record = intention(); + if (field === "deadline") record.deadline = value; + else record.acceptance.acceptedAt = value; + expect(() => store.putIntention(record)).toThrow(); + expect(store.getIntention(record.intentionId)).toBeNull(); + } + expect( + database() + .prepare("SELECT count(*) AS n FROM intention_transitions") + .get(), + ).toEqual({ n: 0 }); + }); +} + +test("resolution and selection persist atomically and survive reopen with canonical JSON", () => { + let store = open(); + store.putIntention(intention()); + const ref = snapshot(store); + store.openRound(ref); + closeCandidates(store, ref); + for (const m of MODULE_KINDS) store.putAssessment(assessment(ref, m)); + const record = resolution(ref); + const selection = spec(ref); + store.recordResolution(ref.roundId, record, selection); + expect(store.getRound(ref.roundId)?.status).toBe("resolved"); + expect(() => + store.recordResolution(ref.roundId, record, selection), + ).toThrow(); + close(store); + store = open(); + expect(store.getResolution(ref.roundId)).toEqual(record); + const saved = store.getSelectionSpec(ref.roundId); + expect(saved).toEqual(selection); + if (!saved) throw Error("missing selection"); + expect(saved.specDigest).toBe( + judgmentDigest({ ...saved, specDigest: undefined }), + ); + expect(store.getIntention("intention-1")).toEqual(intention()); + const db = database(); + for (const [table, column] of [ + ["objective_profiles", "body"], + ["rounds", "snapshot"], + ["assessments", "body"], + ["candidate_sets", "body"], + ["resolution_records", "body"], + ["selection_specs", "body"], + ["intention_records", "body"], + ] as const) { + for (const { body: json } of db + .prepare(`SELECT ${column} AS body FROM ${table}`) + .all()) { + const body = String(json); + expect(body).toBe(JSON.stringify(canonical(JSON.parse(body)))); + } + } + expect(db.prepare("SELECT created_at FROM rounds").get()).toEqual({ + created_at: 1234, + }); +}); + +test("resolution rejects mismatched selection presence, round id and snapshot without partial rows", () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + closeCandidates(store, ref); + const other = { ...ref, roundId: "other" }; + const db = database(); + for (const [record, selection] of [ + [resolution(ref, "deferred"), spec(ref)], + [resolution(ref, "held"), spec(ref)], + [resolution(ref), null], + [resolution(ref), spec(other)], + [resolution(ref), spec({ ...ref, workingRevision: 99 })], + ] as const) { + expect(() => + store.recordResolution(ref.roundId, record, selection), + ).toThrow("selection spec mismatch"); + expect(store.getRound(ref.roundId)?.status).toBe("open"); + expect( + db.prepare("SELECT count(*) AS n FROM resolution_records").get(), + ).toEqual({ n: 0 }); + expect( + db.prepare("SELECT count(*) AS n FROM selection_specs").get(), + ).toEqual({ n: 0 }); + } + expect(() => + store.recordResolution(ref.roundId, resolution(other), spec(ref)), + ).toThrow(); + store.recordResolution(ref.roundId, resolution(ref, "held"), null); + expect(store.getRound(ref.roundId)?.status).toBe("held"); + expect(store.getSelectionSpec(ref.roundId)).toBeNull(); + expect(store.getResolution("missing")).toBeNull(); +}); + +test("SQLite failure during resolution rolls back the already inserted resolution", () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + closeCandidates(store, ref); + for (const m of MODULE_KINDS) store.putAssessment(assessment(ref, m)); + const db = database(); + db.exec( + "CREATE TRIGGER fail_spec BEFORE INSERT ON selection_specs BEGIN SELECT RAISE(ABORT, 'fixture spec failure'); END", + ); + expect(() => + store.recordResolution(ref.roundId, resolution(ref), spec(ref)), + ).toThrow("fixture spec failure"); + expect(store.getResolution(ref.roundId)).toBeNull(); + expect(store.getRound(ref.roundId)?.status).toBe("open"); + db.exec("DROP TRIGGER fail_spec"); + store.recordResolution(ref.roundId, resolution(ref), spec(ref)); +}); + +test("intention transitions match the pure function, persist, filter and reject stale/invalid changes atomically", () => { + let store = open(); + const initial = intention(); + store.putIntention(initial); + expect(() => store.putIntention(initial)).toThrow("duplicate intention"); + const adopted = transitionIntention(initial, transition("adopted")); + expect(() => + store.putIntention({ ...adopted, intentionId: "invalid-new" }), + ).toThrow(); + expect( + store.transitionIntention(initial.intentionId, transition("adopted"), 0), + ).toEqual(adopted); + const active = transitionIntention(adopted, transition("active")); + expect( + store.transitionIntention(initial.intentionId, transition("active"), 1), + ).toEqual(active); + expect(active.revision).toBe(2); + const db = database(); + const rows = () => + db.prepare("SELECT * FROM intention_transitions ORDER BY revision").all(); + expect(rows()).toEqual( + active.history.map((t, i) => ({ + intention_id: initial.intentionId, + revision: i + 1, + from_status: t.from, + to_status: t.to, + reason: t.reason, + evidence_ref: t.evidenceRef, + at: t.at, + })), + ); + expect(() => + store.transitionIntention(initial.intentionId, transition("suspended"), 1), + ).toThrow("stale intention revision"); + expect(() => + store.transitionIntention(initial.intentionId, transition("proposed"), 2), + ).toThrow("invalid intention transition: active -> proposed"); + expect(store.getIntention(initial.intentionId)).toEqual(active); + expect(rows()).toHaveLength(2); + store.putIntention(intention("aaa")); + store.putIntention({ ...intention("elsewhere"), scopeId: "other" }); + expect( + store.listIntentions("agent-1", "scope-1").map((r) => r.intentionId), + ).toEqual(["aaa", "intention-1"]); + expect(store.listIntentions("agent-1", "scope-1", "active")).toEqual([ + active, + ]); + close(store); + store = open(); + expect(store.getIntention(initial.intentionId)).toEqual(active); + expect(store.getIntention("missing")).toBeNull(); +}); + +test("SQLite transition insert failure rolls back updated intention", () => { + const store = open(); + store.putIntention(intention()); + const db = database(); + db.exec( + "CREATE TRIGGER fail_transition BEFORE INSERT ON intention_transitions BEGIN SELECT RAISE(ABORT, 'fixture transition failure'); END", + ); + expect(() => + store.transitionIntention("intention-1", transition("adopted"), 0), + ).toThrow("fixture transition failure"); + expect(store.getIntention("intention-1")).toEqual(intention()); + expect( + db.prepare("SELECT count(*) AS n FROM intention_transitions").get(), + ).toEqual({ n: 0 }); + db.exec("DROP TRIGGER fail_transition"); + expect( + store.transitionIntention("intention-1", transition("adopted"), 0).revision, + ).toBe(1); +}); + +test("external records are reparsed and malformed persisted bodies are rejected on read", () => { + const store = open(); + store.putIntention(intention()); + const ref = snapshot(store); + store.openRound(ref); + closeCandidates(store, ref); + for (const action of [ + () => + store.putObjectiveProfile({ + ...profile(), + extra: true, + } as ObjectiveProfile), + () => + store.openRound({ + ...ref, + schemaVersion: 2, + } as unknown as JudgmentSnapshotRef), + () => + store.putAssessment({ + ...assessment(ref, "clotho"), + inputDigest: "invalid", + }), + () => + store.recordResolution( + ref.roundId, + { ...resolution(ref), extra: true } as ResolutionRecord, + spec(ref), + ), + () => + store.recordResolution(ref.roundId, resolution(ref), { + ...spec(ref), + specDigest: "invalid", + }), + () => + store.putIntention({ ...intention(), extra: true } as IntentionRecord), + ]) + expect(action).toThrow(); + const db = database(); + for (const m of MODULE_KINDS) store.putAssessment(assessment(ref, m)); + store.recordResolution(ref.roundId, resolution(ref), spec(ref)); + for (const [table, column, read] of [ + [ + "objective_profiles", + "body", + () => store.getObjectiveProfile("objective-clotho", 1), + ], + ["rounds", "snapshot", () => store.getRound(ref.roundId)], + ["resolution_records", "body", () => store.getResolution(ref.roundId)], + ["selection_specs", "body", () => store.getSelectionSpec(ref.roundId)], + ["intention_records", "body", () => store.getIntention("intention-1")], + ] as const) { + db.prepare(`UPDATE ${table} SET ${column} = ?`).run('{"schemaVersion":2}'); + expect(read).toThrow(/Unsupported .* schema version/); + } + expect(parseObjectiveProfile(profile())).toEqual(profile()); +}); + +test("every public method rejects use after close", () => { + const store = open(); + const ref = snapshot(store); + close(store); + for (const action of [ + () => store.close(), + () => store.putObjectiveProfile(profile()), + () => store.getObjectiveProfile("objective-clotho", 1), + () => + store.activateObjectiveProfile( + "agent-1", + "scope-1", + ref.objectiveProfileRefs.clotho, + ), + () => store.activeObjectiveProfiles("agent-1", "scope-1"), + () => store.openRound(ref), + () => store.getRound(ref.roundId), + () => store.candidateSet(ref.roundId), + () => closeCandidates(store, ref), + () => store.putAssessment(assessment(ref, "clotho")), + () => store.assessmentSet(ref.roundId), + () => store.recordResolution(ref.roundId, resolution(ref), spec(ref)), + () => store.getResolution(ref.roundId), + () => store.getSelectionSpec(ref.roundId), + () => store.putIntention(intention()), + () => store.getIntention("intention-1"), + () => store.transitionIntention("intention-1", transition("adopted"), 0), + () => store.listIntentions("agent-1", "scope-1"), + ]) + expect(action).toThrow(/closed/); +}); + +for (const reopen of [false, true]) { + for (const [table, column, value] of [ + ["intention_records", "intention_id", "hidden"], + ["intention_records", "agent_id", "hidden"], + ["intention_records", "scope_id", "hidden"], + ["intention_records", "status", "completed"], + ["intention_records", "revision", 99], + ["objective_profiles", "objective_id", "hidden"], + ["objective_profiles", "revision", 99], + ["objective_profiles", "module_kind", "lachesis"], + ["objective_profile_active", "module_kind", "hidden"], + ["rounds", "round_id", "hidden"], + ["rounds", "agent_id", "hidden"], + ["rounds", "scope_id", "hidden"], + ["rounds", "situation", "autonomous"], + ["rounds", "sequence", 99], + ["rounds", "status", "open"], + ["assessments", "round_id", "hidden"], + ["assessments", "module_kind", "hidden"], + ["resolution_records", "round_id", "hidden"], + ["selection_specs", "round_id", "hidden"], + ] as const) { + test(`3995956579/3995958859: ${table}.${column} cannot hide a row (reopen=${reopen})`, () => { + let store = open(); + const ref = snapshot(store); + store.openRound(ref); + closeCandidates(store, ref); + for (const m of MODULE_KINDS) store.putAssessment(assessment(ref, m)); + store.recordResolution(ref.roundId, resolution(ref), spec(ref)); + store.putIntention(intention()); + expect( + store.listIntentions("agent-1", "scope-1", "proposed"), + ).toHaveLength(1); + const db = database(); + db.exec("PRAGMA foreign_keys = OFF"); + db.prepare( + `UPDATE ${table} SET ${column} = ? WHERE rowid = (SELECT min(rowid) FROM ${table})`, + ).run(value); + if (reopen) { + close(store); + store = open(); + } + expect(() => + store.listIntentions("agent-1", "scope-1", "proposed"), + ).toThrow(/metadata mismatch/); + expect(() => store.putIntention(intention("new"))).toThrow( + /metadata mismatch/, + ); + expect( + db.prepare("SELECT count(*) AS n FROM intention_records").get(), + ).toEqual({ n: 1 }); + }); + } + for (const sql of [ + "UPDATE intention_transitions SET intention_id = 'hidden'", + "UPDATE intention_transitions SET revision = 99", + "UPDATE intention_transitions SET from_status = 'active'", + "UPDATE intention_transitions SET to_status = 'cancelled'", + "UPDATE intention_transitions SET reason = 'changed'", + "UPDATE intention_transitions SET evidence_ref = NULL", + "UPDATE intention_transitions SET at = '2026-09-12T00:00:00.000Z'", + "DELETE FROM intention_transitions", + "INSERT INTO intention_transitions SELECT intention_id, 2, from_status, to_status, reason, evidence_ref, at FROM intention_transitions", + ]) { + test(`3995958859: transition ledger agrees with history (reopen=${reopen}): ${sql}`, () => { + let store = open(); + store.putIntention(intention()); + store.transitionIntention("intention-1", transition("adopted"), 0); + database().exec(`PRAGMA foreign_keys = OFF; ${sql}`); + if (reopen) { + close(store); + store = open(); + } + expect(() => store.getIntention("intention-1")).toThrow( + "intention transition metadata mismatch", + ); + expect(() => + store.transitionIntention("intention-1", transition("active"), 1), + ).toThrow("intention transition metadata mismatch"); + }); + } +} + +function rehashSelection(selection: SelectionSpec): SelectionSpec { + return parseSelectionSpec({ + ...selection, + eligibleDigest: judgmentDigest( + selection.candidates.filter((c) => c.p0 > 0).map((c) => c.optionKey), + ), + specDigest: judgmentDigest({ + ...selection, + specDigest: undefined, + eligibleDigest: judgmentDigest( + selection.candidates.filter((c) => c.p0 > 0).map((c) => c.optionKey), + ), + }), + }); +} + +test("3995958855: valid hashes cannot substitute a candidate", () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + closeCandidates(store, ref); + for (const m of MODULE_KINDS) store.putAssessment(assessment(ref, m)); + const record = resolution(ref); + const original = spec(ref); + for (const candidates of [ + [{ optionKey: "zz-forged", p0: 1, b: null }], + [ + { optionKey: A, p0: 1, b: null }, + { optionKey: "zz-forged", p0: 0, b: null }, + ], + ]) { + const forged = rehashSelection({ ...original, candidates }); + expect(() => store.recordResolution(ref.roundId, record, forged)).toThrow( + "selection policy replay mismatch", + ); + expect(store.getResolution(ref.roundId)).toBeNull(); + expect(store.getRound(ref.roundId)?.status).toBe("open"); + } + const forgedRecord = parseResolutionRecord({ + ...record, + ranking: [{ optionKey: "zz-forged", rank: 1 }], + }); + const forged = rehashSelection({ + ...original, + resolutionDigest: judgmentDigest(forgedRecord), + candidates: [{ optionKey: "zz-forged", p0: 1, b: null }], + }); + expect(() => + store.recordResolution(ref.roundId, forgedRecord, forged), + ).toThrow("resolution policy replay mismatch"); + store.recordResolution(ref.roundId, record, original); +}); + +test("3995958855: baseline uses declared ratio and retains unassessed host exclusions", () => { + let store = open(); + const ref = snapshot(store); + store.openRound(ref); + closeCandidates(store, ref, [optionA, optionB, optionExcluded]); + for (const m of MODULE_KINDS) { + const original = assessment(ref, m); + store.putAssessment( + parseAssessment({ + ...original, + objectiveAssessments: [ + ...original.objectiveAssessments, + { + ...original.objectiveAssessments[0], + optionKey: B, + stance: "accept", + }, + ], + }), + ); + } + const record = parseResolutionRecord({ + ...resolution(ref), + ranking: [ + { optionKey: A, rank: 1 }, + { optionKey: B, rank: 2 }, + ], + excluded: [ + { + optionKey: EXCLUDED, + stage: "host_eligibility", + byModule: null, + reason: "not eligible", + }, + ], + }); + const candidates = [ + { optionKey: A, p0: 2 / 3, b: null }, + { optionKey: B, p0: 1 / 3, b: null }, + { optionKey: EXCLUDED, p0: 0, b: null }, + ]; + const selection = rehashSelection({ + ...spec(ref, record), + assessmentSetDigest: judgmentDigest(store.assessmentSet(ref.roundId)), + candidates, + }); + for (const altered of [ + { + candidates: candidates.map((c) => ({ + ...c, + p0: c.optionKey === EXCLUDED ? 0 : 0.5, + })), + }, + { + candidates: candidates.map((c) => ({ + ...c, + p0: c.optionKey === EXCLUDED ? 0.1 : 0.45, + })), + }, + { candidates: candidates.filter((c) => c.optionKey !== EXCLUDED) }, + { lambda: 1 }, + ]) { + const invalid = rehashSelection({ ...selection, ...altered }); + expect(() => store.recordResolution(ref.roundId, record, invalid)).toThrow( + "selection policy replay mismatch", + ); + expect(store.getResolution(ref.roundId)).toBeNull(); + } + store.recordResolution(ref.roundId, record, selection); + close(store); + store = open(); + expect(store.getSelectionSpec(ref.roundId)).toEqual(selection); + expect(store.assessmentSet(ref.roundId).assessments).toHaveLength(3); +}); + +test("3995956579: owner writes reuse validation; reopen and external commits invalidate it", () => { + let store = open(); + const prepare = spyOn(DatabaseSync.prototype, "prepare"); + const scans = () => + prepare.mock.calls.filter( + ([sql]) => sql === "SELECT * FROM intention_records", + ).length; + try { + for (let i = 0; i < 12; i += 1) { + const id = `intention-${i}`; + store.putIntention(intention(id)); + store.transitionIntention(id, transition("adopted"), 0); + expect(store.getIntention(id)?.status).toBe("adopted"); + } + expect(scans()).toBe(1); + close(store); + store = open(); + expect(store.listIntentions("agent-1", "scope-1", "adopted")).toHaveLength( + 12, + ); + expect(scans()).toBe(2); + database().exec("UPDATE intention_records SET created_at = created_at + 1"); + expect(store.listIntentions("agent-1", "scope-1", "adopted")).toHaveLength( + 12, + ); + expect(scans()).toBe(3); + expect(store.getIntention("intention-0")?.revision).toBe(1); + expect(scans()).toBe(3); + } finally { + prepare.mockRestore(); + } +}); + +test("3995956579: validation and filtered read share one SQLite snapshot", () => { + const store = open(); + store.putIntention(intention()); + const db = database(); + const original = DatabaseSync.prototype.prepare; + let changed = false; + const prepare = spyOn(DatabaseSync.prototype, "prepare").mockImplementation( + function (this: DatabaseSync, sql: string) { + if (!changed && sql.includes("FROM intention_records WHERE agent_id")) { + changed = true; + db.exec("UPDATE intention_records SET status = 'completed'"); + } + return original.call(this, sql); + }, + ); + try { + expect(store.listIntentions("agent-1", "scope-1", "proposed")).toEqual([ + intention(), + ]); + expect(changed).toBe(true); + expect(() => + store.listIntentions("agent-1", "scope-1", "proposed"), + ).toThrow("intention metadata mismatch"); + } finally { + prepare.mockRestore(); + } +}); + +test("3995958855: rehashed ranking must reflect persisted stances", () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + closeCandidates(store, ref, [optionA, optionB]); + for (const module of MODULE_KINDS) { + const original = assessment(ref, module); + store.putAssessment( + parseAssessment({ + ...original, + objectiveAssessments: [ + ...original.objectiveAssessments, + { + ...original.objectiveAssessments[0], + optionKey: B, + stance: "accept", + }, + ], + }), + ); + } + for (const ranking of [ + [ + { optionKey: B, rank: 1 }, + { optionKey: A, rank: 2 }, + ], + [ + { optionKey: A, rank: 1 }, + { optionKey: B, rank: 1 }, + ], + [ + { optionKey: A, rank: 2 }, + { optionKey: B, rank: 3 }, + ], + ]) { + const record = parseResolutionRecord({ ...resolution(ref), ranking }); + const selection = rehashSelection({ + ...spec(ref, record), + assessmentSetDigest: judgmentDigest(store.assessmentSet(ref.roundId)), + candidates: [ + { optionKey: A, p0: 0.5, b: null }, + { optionKey: B, p0: 0.5, b: null }, + ], + }); + expect(() => + store.recordResolution(ref.roundId, record, selection), + ).toThrow("resolution policy replay mismatch"); + expect(store.getRound(ref.roundId)?.status).toBe("open"); + } +}); + +test("3995958855: missing ranked assessments reject without inventing excluded coverage", () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + closeCandidates(store, ref, [optionA, optionB]); + for (const module of MODULE_KINDS) + store.putAssessment(assessment(ref, module)); + const record = parseResolutionRecord({ + ...resolution(ref), + ranking: [ + { optionKey: A, rank: 1 }, + { optionKey: B, rank: 2 }, + ], + }); + const selection = rehashSelection({ + ...spec(ref, record), + candidates: [ + { optionKey: A, p0: 2 / 3, b: null }, + { optionKey: B, p0: 1 / 3, b: null }, + ], + }); + expect(() => store.recordResolution(ref.roundId, record, selection)).toThrow( + "resolution policy replay mismatch", + ); + expect(store.getSelectionSpec(ref.roundId)).toBeNull(); +}); + +for (const reopen of [false, true]) { + test(`3995958855: persisted rehashed candidate remains bound to assessments (reopen=${reopen})`, () => { + let store = open(); + const ref = snapshot(store); + store.openRound(ref); + closeCandidates(store, ref); + for (const module of MODULE_KINDS) + store.putAssessment(assessment(ref, module)); + const record = resolution(ref); + const selection = spec(ref); + store.recordResolution(ref.roundId, record, selection); + const forged = rehashSelection({ + ...selection, + candidates: [{ optionKey: "zz-forged", p0: 1, b: null }], + }); + database() + .prepare("UPDATE selection_specs SET body = ?, spec_digest = ?") + .run(JSON.stringify(forged), forged.specDigest); + if (reopen) { + close(store); + store = open(); + } + expect(() => store.getSelectionSpec(ref.roundId)).toThrow( + "selection policy replay mismatch", + ); + }); +} + +test("3995958855: no baseline is guessed for undeclared policy revisions", () => { + const store = open(); + const ref = { ...snapshot(store), policyRevision: 2 }; + store.openRound(ref); + closeCandidates(store, ref); + for (const module of MODULE_KINDS) + store.putAssessment(assessment(ref, module)); + const record = parseResolutionRecord({ + ...resolution(ref), + policyRevision: 2, + }); + const selection = rehashSelection({ + ...spec(ref, record), + policyRevision: 2, + }); + expect(() => store.recordResolution(ref.roundId, record, selection)).toThrow( + "policy snapshot mismatch", + ); + expect(() => + store.recordResolution( + ref.roundId, + { ...record, status: "held", holdReason: "no policy declaration" }, + null, + ), + ).toThrow("policy snapshot mismatch"); + expect(store.getRound(ref.roundId)?.status).toBe("open"); + expect(store.getResolution(ref.roundId)).toBeNull(); +}); + +test("missing parent is private; directory and database symlinks are rejected", () => { + const parent = join(fixture.dir, "private"); + open(join(parent, "judgment.sqlite")); + expect(statSync(parent).mode & 0o777).toBe(0o700); + const target = join(fixture.dir, "target"); + mkdirSync(target); + const link = join(fixture.dir, "link"); + symlinkSync(target, link); + expect(() => open(join(link, "judgment.sqlite"))).toThrow(/unsafe directory/); + const file = join(target, "judgment.sqlite"); + writeFileSync(file, ""); + symlinkSync(file, path); + expect(() => open()).toThrow(/unsafe regular file/); +}); + +test("ledger audit rejects a resolved round whose selection was deleted", () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + closeCandidates(store, ref); + for (const module of MODULE_KINDS) + store.putAssessment(assessment(ref, module)); + store.recordResolution(ref.roundId, resolution(ref), spec(ref)); + database().exec("DELETE FROM selection_specs"); + expect(() => store.getSelectionSpec(ref.roundId)).toThrow( + "selection spec metadata mismatch", + ); +}); + +test("ledger audit rejects orphaned assessments before unrelated reads", () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + closeCandidates(store, ref); + store.putAssessment(assessment(ref, "clotho")); + const db = database(); + db.exec("PRAGMA foreign_keys = OFF; DELETE FROM rounds"); + expect(() => store.getIntention("missing")).toThrow( + "judgment foreign key mismatch", + ); +}); + +for (const change of [ + { policyId: "other-policy" }, + { policyRevision: 2 }, + { situation: "autonomous" as const }, +]) { + test(`ledger audit binds rehashed resolution ${Object.keys(change)[0]} to its snapshot`, () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + closeCandidates(store, ref); + store.recordResolution(ref.roundId, resolution(ref, "held"), null); + const changed = { ...resolution(ref, "held"), ...change }; + database() + .prepare("UPDATE resolution_records SET body = ?, digest = ?") + .run(JSON.stringify(changed), judgmentDigest(changed)); + expect(() => store.getResolution(ref.roundId)).toThrow( + "resolution snapshot mismatch", + ); + }); +} + +test("ledger audit retains the historical objective referenced by a round", () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + closeCandidates(store, ref); + const next = store.putObjectiveProfile(profile("clotho", 2)); + store.activateObjectiveProfile(ref.agentId, ref.scopeId, next); + database().exec( + "DELETE FROM objective_profiles WHERE module_kind = 'clotho' AND revision = 1", + ); + expect(() => store.getRound(ref.roundId)).toThrow( + "snapshot objective profile mismatch", + ); +}); + +test("ledger audit binds rehashed assessment evidence to its frozen snapshot", () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + closeCandidates(store, ref); + store.putAssessment(assessment(ref, "clotho")); + const changed = assessment(ref, "clotho", "b".repeat(64)); + database() + .prepare( + "UPDATE assessments SET body = ?, digest = ?, snapshot_digest = ?, input_digest = ?", + ) + .run( + JSON.stringify(changed), + judgmentDigest(changed), + changed.snapshotDigest, + changed.inputDigest, + ); + expect(() => store.getRound(ref.roundId)).toThrow( + "assessment snapshot mismatch", + ); +}); + +test("ledger audit binds rehashed assessment objectives to the frozen module", () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + closeCandidates(store, ref); + store.putAssessment(assessment(ref, "clotho")); + const changed = { + ...assessment(ref, "clotho"), + objectiveRef: ref.objectiveProfileRefs.atropos, + }; + changed.inputDigest = assessmentInputDigest(changed); + database() + .prepare("UPDATE assessments SET body = ?, digest = ?, input_digest = ?") + .run(JSON.stringify(changed), judgmentDigest(changed), changed.inputDigest); + expect(() => store.getRound(ref.roundId)).toThrow( + "assessment objective mismatch", + ); +}); diff --git a/packages/lina-core/test/persona-schema.test.ts b/packages/lina-core/test/persona-schema.test.ts new file mode 100644 index 0000000..e591a4e --- /dev/null +++ b/packages/lina-core/test/persona-schema.test.ts @@ -0,0 +1,614 @@ +import { expect, test } from "bun:test"; +import type { + DimensionSource, + NeuralProjectionRef, + PersonaDimension, + PersonaDimensionKind, + PersonaDimensionRef, + PersonaSchema, +} from "../src/agents/index.ts"; +import { + behaviorFingerprint, + DIMENSION_SOURCES, + parsePersonaSchema, + personaSchemaDigest, + personaSchemaFromLifeDefinition, +} from "../src/agents/index.ts"; +import type { IdentityPolicySnapshot } from "../src/world/life-types.ts"; +import { parseIdentityPolicy } from "../src/world/life-validation.ts"; + +const HASH = "a".repeat(64); +const PINNED_BEHAVIOR_FINGERPRINT = + "4525f17c17ca4c725ba9e4a8490afe2ed3e2952164cdbf9962e6e46b3e785ecd"; +const GOLDEN_PERSONA_SCHEMA_DIGEST = + "fbb3dddb51803bb05996cabe7f2182b305ceaed0127f95f7d19809686b447a37"; +const GOLDEN_PERSONA_SCHEMA_JSON = `{"schemaVersion":1,"agentId":"lina","revision":1,"sourceDefinition":{"worldId":"world","definitionRevision":1,"definitionDigest":"${HASH}"},"sourceIdentity":{"profileRevision":1},"dimensions":[{"id":"warmth","kind":"trait","label":"Warmth","source":"reflection","range":{"min":0,"max":10},"initial":5,"locked":false,"originAxisId":"warmth"},{"id":"tea","kind":"habit","label":"Drinks tea","source":"reflection","range":null,"initial":true,"locked":false,"originAxisId":"tea"},{"id":"trust","kind":"attitude","label":"Trust","source":"reflection","range":{"min":0,"max":5},"initial":2,"locked":true,"originAxisId":"trust"}],"digest":"${GOLDEN_PERSONA_SCHEMA_DIGEST}"}`; + +const definition = { + version: 1 as const, + worldId: "world", + revision: 3, + participants: ["lina"], + traits: [ + { id: "warmth", label: "Warmth", min: 0, max: 10, initial: 5 }, + { id: "curiosity", label: "Curiosity", min: -1, max: 1, initial: 0 }, + ], + habits: [{ id: "tea", label: "Drinks tea", initial: true }], + attitudes: [{ id: "trust", label: "Trust", min: 0, max: 5, initial: 2 }], + projection: { + revision: 1, + sharedTraitIds: ["warmth", "curiosity"], + sharedHabitIds: ["tea"], + sharedAttitudeIds: ["trust"], + disclosures: [], + }, +}; + +const lockProfile = { + agentId: "lina", + profileRevision: 4, + evolution: "adaptive" as const, + lockedTraitIds: ["warmth"], + lockedHabitIds: [] as string[], + lockedAttitudeIds: [] as string[], +}; + +const identityV1 = { version: 1 as const, profiles: [lockProfile] }; +const identityV2 = { + version: 2 as const, + profiles: [ + { + ...lockProfile, + personalBehavior: null, + sourceStamp: null, + }, + ], +}; + +function derive( + identity: IdentityPolicySnapshot | null = identityV2, +): PersonaSchema { + return personaSchemaFromLifeDefinition({ + agentId: "lina", + revision: 1, + definition, + identity, + }); +} + +function withDigest(schema: PersonaSchema): PersonaSchema { + const body = { + schemaVersion: schema.schemaVersion, + agentId: schema.agentId, + revision: schema.revision, + sourceDefinition: schema.sourceDefinition, + sourceIdentity: schema.sourceIdentity, + dimensions: schema.dimensions, + }; + return { ...body, digest: personaSchemaDigest(body) }; +} + +test("derivation maps two traits, one habit and one attitude in kind then id order with locked reflection sources", () => { + const schema = derive(); + expect(schema.schemaVersion).toBe(1); + expect(schema.agentId).toBe("lina"); + expect(schema.revision).toBe(1); + expect(schema.dimensions).toHaveLength(4); + expect(schema.dimensions.map((row) => row.kind)).toEqual([ + "trait", + "trait", + "habit", + "attitude", + ]); + expect(schema.dimensions.map((row) => row.id)).toEqual([ + "curiosity", + "warmth", + "tea", + "trust", + ]); + expect(schema.dimensions.map((row) => row.range)).toEqual([ + { min: -1, max: 1 }, + { min: 0, max: 10 }, + null, + { min: 0, max: 5 }, + ]); + expect(schema.dimensions.map((row) => row.initial)).toEqual([0, 5, true, 2]); + expect(schema.dimensions.map((row) => row.locked)).toEqual([ + false, + true, + false, + false, + ]); + expect(schema.dimensions.every((row) => row.source === "reflection")).toBe( + true, + ); + expect(schema.dimensions.every((row) => row.originAxisId === row.id)).toBe( + true, + ); + expect(schema.dimensions.map((row) => row.label)).toEqual([ + "Curiosity", + "Warmth", + "Drinks tea", + "Trust", + ]); + const source = schema.sourceDefinition; + expect(source).not.toBeNull(); + if (source === null) throw Error("expected sourceDefinition"); + expect(source.worldId).toBe("world"); + expect(source.definitionRevision).toBe(3); + expect(source.definitionDigest).toMatch(/^[a-f0-9]{64}$/); + expect(schema.sourceIdentity).toEqual({ profileRevision: 4 }); +}); + +test.each([ + { + sharedTraitIds: ["warmth"], + sharedHabitIds: ["tea"], + sharedAttitudeIds: [], + expected: ["warmth", "tea"], + }, + { + sharedTraitIds: [], + sharedHabitIds: [], + sharedAttitudeIds: ["trust"], + expected: ["trust"], + }, + { + sharedTraitIds: [], + sharedHabitIds: [], + sharedAttitudeIds: [], + expected: [], + }, +])("derivation preserves projection allowlists: %j", (projection) => { + const schema = personaSchemaFromLifeDefinition({ + agentId: "lina", + revision: 1, + definition: { + ...definition, + projection: { + ...definition.projection, + sharedTraitIds: [...projection.sharedTraitIds], + sharedHabitIds: [...projection.sharedHabitIds], + sharedAttitudeIds: [...projection.sharedAttitudeIds], + }, + }, + identity: identityV2, + }); + expect(schema.dimensions.map((dimension) => dimension.id)).toEqual([ + ...projection.expected, + ]); + expect(parsePersonaSchema(schema)).toEqual(schema); +}); + +test.each([1, 2] as const)( + "manual v%i identity locks every projected kind without per-axis locks", + (version) => { + const profile = { + ...lockProfile, + evolution: "manual" as const, + lockedTraitIds: [], + }; + const schema = personaSchemaFromLifeDefinition({ + agentId: "lina", + revision: 1, + definition: { + ...definition, + projection: { + ...definition.projection, + sharedTraitIds: ["warmth"], + }, + }, + identity: + version === 1 + ? { version, profiles: [profile] } + : { + version, + profiles: [ + { ...profile, personalBehavior: null, sourceStamp: null }, + ], + }, + }); + expect(schema.dimensions.map((row) => [row.id, row.locked])).toEqual([ + ["warmth", true], + ["tea", true], + ["trust", true], + ]); + expect(schema.sourceIdentity).toEqual({ profileRevision: 4 }); + expect(parsePersonaSchema(schema)).toEqual(schema); + }, +); + +test.each([null, identityV1, identityV2])( + "derivation rejects nonparticipants regardless of identity: %j", + (identity) => { + expect(() => + personaSchemaFromLifeDefinition({ + agentId: "lina", + revision: 1, + definition: { ...definition, participants: ["mira"] }, + identity, + }), + ).toThrow("Unknown world agent"); + }, +); + +test("derivation digest is idempotent and ignores v2-only identity fields", () => { + const first = derive(); + const second = derive(); + expect(first.digest).toBe(second.digest); + expect(first.digest).toBe( + personaSchemaDigest({ + schemaVersion: first.schemaVersion, + agentId: first.agentId, + revision: first.revision, + sourceDefinition: first.sourceDefinition, + sourceIdentity: first.sourceIdentity, + dimensions: first.dimensions, + }), + ); + + const unlocked = derive(null); + expect(unlocked.sourceIdentity).toBeNull(); + expect(unlocked.dimensions.every((row) => row.locked === false)).toBe(true); + expect(unlocked.digest).not.toBe(first.digest); + + const fromV1 = derive(identityV1); + const fromV2 = derive(identityV2); + expect(fromV1.digest).toBe(fromV2.digest); + expect(fromV1.digest).toBe(first.digest); + + const missing = personaSchemaFromLifeDefinition({ + agentId: "lina", + revision: 1, + definition, + identity: { + version: 2 as const, + profiles: [ + { + ...lockProfile, + agentId: "mira", + evolution: "manual", + personalBehavior: null, + sourceStamp: null, + }, + ], + }, + }); + expect(missing.sourceIdentity).toBeNull(); + expect(missing.dimensions.every((row) => row.locked === false)).toBe(true); + expect(missing.digest).toBe(unlocked.digest); +}); + +test.each([identityV1, identityV2])( + "derivation rejects duplicate profiles: %j", + (identity) => { + const profile = identity.profiles[0]; + if (!profile) throw Error("expected profile"); + const manual = { ...profile, evolution: "manual" as const }; + for (const profiles of [ + [profile, manual], + [manual, profile], + ]) { + const invalid = { ...identity, profiles } as IdentityPolicySnapshot; + expect(() => parseIdentityPolicy(invalid)).toThrow(); + expect(() => derive(invalid)).toThrow(); + } + }, +); + +test.each([ + { ...identityV1, version: 3 }, + { ...identityV1, extra: true as const }, + { profiles: identityV1.profiles }, + { version: 1, profiles: {} }, +])("derivation rejects malformed identity snapshots: %j", (identity) => { + expect(() => parseIdentityPolicy(identity)).toThrow(); + expect(() => derive(identity as IdentityPolicySnapshot)).toThrow(); +}); + +test.each([ + { evolution: "automatic" }, + { profileRevision: 0 }, + { extra: true }, + { lockedTraitIds: "warmth" }, + { lockedTraitIds: ["warmth", "warmth"] }, + { lockedHabitIds: [1] }, + { lockedAttitudeIds: null }, + { personalBehavior: {} }, + { personalBehavior: { traits: [], habits: [] } }, + { sourceStamp: {} }, + { + personalBehavior: { traits: [], habits: [] }, + sourceStamp: { + digest: HASH, + receiptRevision: 1, + profileRevision: 5, + definitionRevision: 1, + projectionRevision: 1, + }, + }, +])( + "derivation validates every identity profile before selection: %j", + (patch) => { + const valid = { ...lockProfile, personalBehavior: null, sourceStamp: null }; + const invalid = { ...valid, ...patch }; + const unrelated = { ...invalid, agentId: "mira" }; + for (const profiles of [[invalid], [valid, unrelated], [unrelated]]) { + const identity = { version: 2, profiles } as IdentityPolicySnapshot; + expect(() => parseIdentityPolicy(identity)).toThrow(); + expect(() => derive(identity)).toThrow(); + } + }, +); + +test("valid v2 personal values and source anchors do not affect derivation", () => { + const identity: IdentityPolicySnapshot = { + version: 2, + profiles: [ + { + ...lockProfile, + personalBehavior: { + traits: [{ axisId: "other-world-axis", value: 42 }], + habits: [{ habitId: "other-world-habit", value: false }], + }, + sourceStamp: { + digest: HASH, + receiptRevision: 1, + profileRevision: lockProfile.profileRevision, + definitionRevision: 2, + projectionRevision: 2, + }, + }, + ], + }; + expect(parseIdentityPolicy(identity)).toEqual(identity); + expect(derive(identity)).toEqual(derive(identityV1)); +}); + +test("parsePersonaSchema round-trips a derived schema", () => { + const schema = derive(); + const parsed = parsePersonaSchema(schema); + expect(parsed).toEqual(schema); + expect(JSON.stringify(parsed)).toBe(JSON.stringify(schema)); +}); + +test("labels at the LIFE text ceiling round-trip verbatim; one past it is rejected on both sides", () => { + const atCeiling = "x".repeat(32_768); + const pastCeiling = "x".repeat(32_769); + const withLabel = (label: string) => ({ + ...definition, + traits: [{ id: "t", label, min: 0, max: 1, initial: 0 }], + habits: [{ id: "h", label, initial: false }], + attitudes: [{ id: "a", label, min: 0, max: 1, initial: 1 }], + projection: { + revision: 1, + sharedTraitIds: ["t"], + sharedHabitIds: ["h"], + sharedAttitudeIds: ["a"], + disclosures: [], + }, + }); + const schema = personaSchemaFromLifeDefinition({ + agentId: "lina", + revision: 1, + definition: withLabel(atCeiling), + identity: null, + }); + expect(schema.dimensions.map((row) => row.label)).toEqual([ + atCeiling, + atCeiling, + atCeiling, + ]); + expect(parsePersonaSchema(schema)).toEqual(schema); + expect(() => + personaSchemaFromLifeDefinition({ + agentId: "lina", + revision: 1, + definition: withLabel(pastCeiling), + identity: null, + }), + ).toThrow(); + const tampered = withDigest({ + ...schema, + dimensions: schema.dimensions.map((row) => + row.kind === "trait" ? { ...row, label: pastCeiling } : row, + ), + }); + expect(() => parsePersonaSchema(tampered)).toThrow( + "invalid persona dimension label", + ); + expect(() => + parsePersonaSchema( + withDigest({ + ...schema, + dimensions: schema.dimensions.map((row) => + row.kind === "habit" ? { ...row, label: " " } : row, + ), + }), + ), + ).toThrow("invalid persona dimension label"); +}); + +test("golden parses byte-identically", () => { + expect( + JSON.stringify(parsePersonaSchema(JSON.parse(GOLDEN_PERSONA_SCHEMA_JSON))), + ).toBe(GOLDEN_PERSONA_SCHEMA_JSON); +}); + +test("parsePersonaSchema rejects duplicate ids, unknown source, future version, tampered digest, and kind/range/initial mismatches", () => { + const schema = derive(); + const duplicate = structuredClone(schema); + const first = duplicate.dimensions[0]; + if (!first) throw Error("expected dimension"); + duplicate.dimensions.push({ ...first }); + expect(() => parsePersonaSchema(withDigest(duplicate))).toThrow( + "duplicate persona dimension id", + ); + + const other = structuredClone(schema); + const otherDimension = other.dimensions[0]; + if (!otherDimension) throw Error("expected dimension"); + (otherDimension as { source: string }).source = "other"; + expect(() => parsePersonaSchema(withDigest(other))).toThrow( + "invalid persona dimension source", + ); + + expect(() => parsePersonaSchema({ ...schema, schemaVersion: 2 })).toThrow( + /Unsupported persona schema version/, + ); + + const tampered = structuredClone(schema); + tampered.digest = "b".repeat(64); + expect(() => parsePersonaSchema(tampered)).toThrow(); + + const traitRange = structuredClone(schema); + const trait = traitRange.dimensions.find((row) => row.kind === "trait"); + if (!trait) throw Error("expected trait"); + trait.range = null; + expect(() => parsePersonaSchema(withDigest(traitRange))).toThrow( + "invalid persona dimension range", + ); + + const habitInitial = structuredClone(schema); + const habit = habitInitial.dimensions.find((row) => row.kind === "habit"); + if (!habit) throw Error("expected habit"); + habit.initial = 1; + expect(() => parsePersonaSchema(withDigest(habitInitial))).toThrow( + "invalid persona dimension initial", + ); +}); + +test("parsePersonaSchema rejects unsorted dimensions on a correctly digested body", () => { + const z: PersonaDimension = { + id: "z", + kind: "trait", + label: "Z", + source: "reflection", + range: { min: 0, max: 1 }, + initial: 0, + locked: false, + originAxisId: "z", + }; + const a: PersonaDimension = { + id: "a", + kind: "trait", + label: "A", + source: "reflection", + range: { min: 0, max: 1 }, + initial: 0, + locked: false, + originAxisId: "a", + }; + const body = { + schemaVersion: 1 as const, + agentId: "lina", + revision: 1, + sourceDefinition: { + worldId: "world", + definitionRevision: 1, + definitionDigest: HASH, + }, + sourceIdentity: { profileRevision: 1 }, + dimensions: [z, a], + }; + expect(() => + parsePersonaSchema({ ...body, digest: personaSchemaDigest(body) }), + ).toThrow("unsorted persona dimensions"); +}); + +test("personaSchemaFromLifeDefinition rejects cross-kind duplicate dimension ids", () => { + expect(() => + personaSchemaFromLifeDefinition({ + agentId: "lina", + revision: 1, + definition: { + version: 1, + worldId: "world", + revision: 1, + participants: ["lina"], + traits: [{ id: "same", label: "Trait", min: 0, max: 1, initial: 0 }], + habits: [{ id: "same", label: "Habit", initial: true }], + attitudes: [], + projection: { + revision: 1, + sharedTraitIds: [], + sharedHabitIds: [], + sharedAttitudeIds: [], + disclosures: [], + }, + }, + identity: null, + }), + ).toThrow("duplicate persona dimension id"); +}); + +test("pinned BehaviorJobInput fingerprint is unchanged", () => { + expect( + behaviorFingerprint({ + version: 1, + agentId: "lina", + worldId: "world", + profileRevision: 1, + definitionRevision: 1, + projectionRevision: 1, + policyRevision: 0, + modelSettingsRevision: 0, + definitionDigest: HASH, + projectionDigest: HASH, + promptDigest: HASH, + maxAttempts: 2, + records: [ + { + recordId: "rec-1", + revision: 3, + contentHash: HASH, + proofDigest: + "d425a65ef71512bbc143700c9be99888d38a5f0c2176ae20753b8312792cde70", + proofs: [ + { + entryId: "source", + policyRevision: 1, + policyDigest: HASH, + }, + ], + }, + ], + selectors: { + traits: [{ axisId: "warmth", min: 0, max: 10 }], + habits: [{ habitId: "tea" }], + }, + }), + ).toBe(PINNED_BEHAVIOR_FINGERPRINT); +}); + +test("agents index re-exports persona schema types and dimension sources", () => { + expect(DIMENSION_SOURCES).toEqual(["reflection", "neural"]); + const kind: PersonaDimensionKind = "trait"; + const source: DimensionSource = "reflection"; + const dimension: PersonaDimension = { + id: "warmth", + kind, + label: "Warmth", + source, + range: { min: 0, max: 10 }, + initial: 5, + locked: false, + originAxisId: "warmth", + }; + const ref: PersonaDimensionRef = { + schemaRevision: 1, + dimensionId: dimension.id, + source, + }; + const neural: NeuralProjectionRef = { + schemaVersion: 1, + agentId: "lina", + scopeId: "personal", + schemaRevision: 1, + observationRef: "obs-1", + projectionDigest: HASH, + }; + expect(ref.source).toBe("reflection"); + expect(neural.schemaVersion).toBe(1); + expect(dimension.kind).toBe("trait"); +}); diff --git a/packages/lina-runtime/test/image-store-v2.test.ts b/packages/lina-runtime/test/image-store-v2.test.ts index f597658..68ac093 100644 --- a/packages/lina-runtime/test/image-store-v2.test.ts +++ b/packages/lina-runtime/test/image-store-v2.test.ts @@ -11,6 +11,8 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { atomicJson } from "../../lina-core/src/attachments/filesystem.ts"; +import { ImageArchives } from "../src/images/image-store-archive.ts"; import { ImageJobStore } from "../src/images/store.ts"; const roots: string[] = []; @@ -236,21 +238,48 @@ test("LIFE dedupe freezes real origin and reference identity without conversatio test("257 LIFE identities survive terminal archival, reopen and replay without using the conversation cap", () => { const f = fixture(); let store = new ImageJobStore(f.root, owner, limits); - const ids: string[] = []; - for (let n = 0; n < 257; n++) { - const job = store.create({ - ...input, - origin: { ...input.origin, attemptId: `attempt-${n}` }, - }); - ids.push(job.id); - store.update(job.id, { state: "cancelled" }); - store.update(job.id, { - delivery: { kind: "life", receiptId: `receipt-${n}` }, - }); - store.archive(job.id); - } + const seed = store.create({ + ...input, + origin: { ...input.origin, attemptId: "attempt-0" }, + }); + store.update(seed.id, { state: "cancelled" }); + const terminal = store.update(seed.id, { + delivery: { kind: "life", receiptId: "receipt-0" }, + }); + // Persist the starting population once; the regression is crossing the + // conversation ceiling, then reopening and replaying every LIFE identity. + const prefix = Array.from({ length: 256 }, (_, n) => ({ + ...terminal, + id: n === 0 ? terminal.id : randomUUID(), + origin: { ...input.origin, attemptId: `attempt-${n}` }, + delivery: { kind: "life" as const, receiptId: `receipt-${n}` }, + })); + const archives = new ImageArchives( + join(f.root, "images"), + owner, + limits.maxArchiveBytes, + ); + atomicJson(f.file, { + version: 2, + owner, + jobs: [], + archives: prefix.map((job) => archives.write(job)), + }); + store = new ImageJobStore(f.root, owner, limits); + expect(store.list()).toEqual(prefix); + const job = store.create({ + ...input, + origin: { ...input.origin, attemptId: "attempt-256" }, + }); + store.update(job.id, { state: "cancelled" }); + store.update(job.id, { + delivery: { kind: "life", receiptId: "receipt-256" }, + }); + const last = store.archive(job.id); + const ids = [...prefix.map((item) => item.id), job.id]; store = new ImageJobStore(f.root, owner, limits); expect(store.list()).toHaveLength(257); + expect(store.list()).toEqual([...prefix, last]); for (let n = 0; n < 257; n++) expect( store.create({ diff --git a/packages/lina-runtime/test/life-author-session.test.ts b/packages/lina-runtime/test/life-author-session.test.ts index 66bc13e..ec068d1 100644 --- a/packages/lina-runtime/test/life-author-session.test.ts +++ b/packages/lina-runtime/test/life-author-session.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, test } from "bun:test"; +import { afterEach, expect, spyOn, test } from "bun:test"; import { existsSync, mkdirSync, @@ -17,6 +17,7 @@ import { acquireTranscriptLease, } from "../../lina-core/src/index.ts"; import type { WorldAuthorGrant } from "../../lina-core/src/world/authoring-types.ts"; +import { WorldStore } from "../../lina-core/src/world/store.ts"; import type { ContextServices } from "../src/context/port.ts"; import { AgentFleet } from "../src/fleet/manager.ts"; import { startFleetServer } from "../src/fleet/server.ts"; @@ -188,6 +189,9 @@ async function fixture() { forbiddenCalls: () => forbiddenCalls, async reopen() { await server.stop(); + // Emulate process exit: finalize closed SQLite statements before the + // new fleet stamps/copies LIFE storage, not during its validation. + Bun.gc(true); fleet = open(); server = await startFleetServer(fleet, 0, process.cwd(), "lina", { lazy: true, @@ -279,9 +283,32 @@ test("dedicated HTTP grant/input/read/approval uses real runtime and isolated au ); const sessionId = author.binding.sessionId; await f.reopen(); - expect( - (await f.send(`/author-sessions/${grant.id}/open`, "POST", {})).status, - ).toBe(200); + // Force the CI collection point instead of relying on heap pressure. + // The real private-copy audit and close still run before collection. + const close = WorldStore.prototype.close; + const collect = spyOn(WorldStore.prototype, "close").mockImplementation( + function (this: WorldStore) { + close.call(this); + Bun.gc(true); + }, + ); + try { + const response = await f.send( + `/author-sessions/${grant.id}/open`, + "POST", + {}, + ); + expect({ + status: response.status, + body: await response.json(), + }).toMatchObject({ + status: 200, + body: { grant }, + }); + expect(collect).toHaveBeenCalledTimes(1); + } finally { + collect.mockRestore(); + } const reopened = await f.fleet().openWorldAuthor(grant.id); expect(reopened.binding.sessionId).toBe(sessionId); expect( diff --git a/packages/lina-runtime/test/life-binding-routes-fixture.test.ts b/packages/lina-runtime/test/life-binding-routes-fixture.test.ts index 786400b..6322054 100644 --- a/packages/lina-runtime/test/life-binding-routes-fixture.test.ts +++ b/packages/lina-runtime/test/life-binding-routes-fixture.test.ts @@ -57,6 +57,8 @@ export async function bindingFixture() { }, async reopen() { await server.stop(); + // Model process-exit finalization before the replacement's file audit. + Bun.gc(true); fleet = open(); server = await startFleetServer(fleet, 0, process.cwd(), "lina", { lazy: true, @@ -64,6 +66,7 @@ export async function bindingFixture() { }, async close() { await server.stop(); + Bun.gc(true); rmSync(root, { recursive: true, force: true }); }, }; diff --git a/packages/lina-runtime/test/life-binding-routes.test.ts b/packages/lina-runtime/test/life-binding-routes.test.ts index 698da8f..6346c29 100644 --- a/packages/lina-runtime/test/life-binding-routes.test.ts +++ b/packages/lina-runtime/test/life-binding-routes.test.ts @@ -1,4 +1,5 @@ -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; +import { WorldStore } from "../../lina-core/src/world/store.ts"; import { activateSocialPack } from "../../lina-core/test/life-social-store-fixture.ts"; import { authorPack } from "./life-authoring-fixture.ts"; import { @@ -24,7 +25,24 @@ test("protected binding HTTP persists explicit V2, checks CAS and reopens withou (await putBinding(f.url, { expectedRevision: 0, selection })).status, ).toBe(409); await f.reopen(); - expect(await (await fetch(f.url)).json()).toEqual(expected); + // Collect at the private-copy audit boundary, not at a lucky heap threshold. + const close = WorldStore.prototype.close; + const collect = spyOn(WorldStore.prototype, "close").mockImplementation( + function (this: WorldStore) { + close.call(this); + Bun.gc(true); + }, + ); + try { + const response = await fetch(f.url); + expect({ status: response.status, body: await response.json() }).toEqual({ + status: 200, + body: expected, + }); + expect(collect).toHaveBeenCalled(); + } finally { + collect.mockRestore(); + } const growthOnly = { ...selection, conversationRecipientId: null }; expect( await ( diff --git a/packages/lina-runtime/test/life-runtime-fleet-fixture.ts b/packages/lina-runtime/test/life-runtime-fleet-fixture.ts index f8bf6a3..1cad848 100644 --- a/packages/lina-runtime/test/life-runtime-fleet-fixture.ts +++ b/packages/lina-runtime/test/life-runtime-fleet-fixture.ts @@ -30,6 +30,7 @@ export async function fleetLifeFixture( | "createApp" | "createImageClient" | "enginePolicy" + | "resourceRoot" > = {}, respond?: (request: Request) => Response | Promise, ) { diff --git a/packages/lina-runtime/test/persona-world-cycle.test.ts b/packages/lina-runtime/test/persona-world-cycle.test.ts index cd4ef77..b90db5f 100644 --- a/packages/lina-runtime/test/persona-world-cycle.test.ts +++ b/packages/lina-runtime/test/persona-world-cycle.test.ts @@ -1,5 +1,13 @@ -import { expect, test } from "bun:test"; -import { renameSync } from "node:fs"; +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + copyFileSync, + mkdirSync, + mkdtempSync, + readdirSync, + renameSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { captureSourceProofs } from "../../lina-core/src/source-policy.ts"; import { WorldStore } from "../../lina-core/src/world/store.ts"; @@ -11,10 +19,26 @@ import { testSessionEngine } from "./fake-session-engine.ts"; import { nativeEpisode } from "./helpers/native-memory-source.ts"; import { fleetLifeFixture } from "./life-runtime-fleet-fixture.ts"; +let resourceRoot: string; +beforeEach(() => { + resourceRoot = mkdtempSync(join(tmpdir(), "lina-persona-resources-")); + mkdirSync(join(resourceRoot, "data", "personas"), { recursive: true }); + // Preserve the real prompt/profiles, not unrelated multi-megabyte avatar assets. + for (const file of ["app-system-prompt.md", "personas/presets.json"]) + copyFileSync( + join(process.cwd(), "data", file), + join(resourceRoot, "data", file), + ); +}); +afterEach(() => { + if (resourceRoot) rmSync(resourceRoot, { recursive: true, force: true }); +}); + test("Fleet uses persisted personal growth after restart without opening ordinary conversation", async () => { let enabled = true; let miraSource: Parameters[0]["world"]; const f = await fleetLifeFixture({ + resourceRoot, enginePolicy: () => ({ ...defaultEnginePolicy(), memory: { ...defaultEnginePolicy().memory, enabled }, @@ -29,6 +53,7 @@ test("Fleet uses persisted personal growth after restart without opening ordinar }, }); try { + expect(readdirSync(join(f.root, "state", "avatars"))).toEqual([]); f.setup(false); const app = await f.app.fleet.app("lina"); if (!(app.memory instanceof CompanionMemory)) @@ -142,6 +167,9 @@ test("Fleet uses persisted personal growth after restart without opening ordinar new AbortController().signal, ), ).rejects.toThrow(); + expect(f.app.fleet.opened("lina")).toBeUndefined(); + expect(f.providerCalls).toBe(0); + expect(readdirSync(join(f.root, "state", "avatars"))).toEqual([]); } finally { await f.close(); }