- {numbers.has(line.ref) ? `[${numbers.get(line.ref)}]` : `선택 근거 ${line.index + 1}`}
+ {numbers.has(line.ref) ? `[${numbers.get(line.ref)}]` : `선택 근거 ${line.index + 1}`}
+ {line.splits && <> 가설을 가름 >}
{line.cells.map((cell, index) => {relationLabel[cell]}
{review && rows[index].id && !openRequest(review.requests, rows[index].id) && { if (root) await act(async () => root!.unmount()); root = undefined; node?.remove(); });
+
+// The first hypothesis has two tests, the second has one without an id, the third has none.
+const result = () => {
+ const copy = structuredClone(excerpt) as unknown as { answer: string; portfolio: { hypotheses: Record[] }; selected_evidence_refs: unknown[] };
+ copy.portfolio.hypotheses[0].discriminating_tests = [
+ { test_id: "t1", procedure_candidate: "비 조건과 맑은 조건을 비교한다", expected_if_true: "비 조건이 나쁘다", expected_if_alternative: "두 조건이 같다" },
+ { test_id: "t2", procedure_candidate: "센서 설정을 바꿔 다시 잰다", expected_if_true: "개선된다", expected_if_alternative: "그대로다" },
+ ];
+ copy.portfolio.hypotheses[1].discriminating_tests = [{ procedure_candidate: "id가 없는 시험", expected_if_true: "a", expected_if_alternative: "b" }];
+ return copy as unknown as Record;
+};
+async function mount(canDraft: boolean) {
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+ node = document.createElement("div"); document.body.append(node); root = createRoot(node);
+ const client = new QueryClient();
+ await act(async () => root!.render( ));
+}
+const drafts = () => [...node.querySelectorAll("button")].filter(item => item.textContent?.includes("행동 요청 준비"));
+
+it("puts the button on each test that has an id, and only when asked to", async () => {
+ await mount(true);
+ expect(drafts()).toHaveLength(2);
+ const first = node.querySelectorAll(".hypothesis-card")[0];
+ expect(first.querySelectorAll("button").length).toBeGreaterThanOrEqual(2);
+ expect(node.querySelectorAll(".hypothesis-card")[1].textContent).not.toContain("행동 요청 준비");
+ await act(async () => root!.unmount()); node.remove(); root = undefined;
+ await mount(false);
+ expect(drafts()).toHaveLength(0);
+});
diff --git a/apps/web/src/components/HypothesisCompareLive.tsx b/apps/web/src/components/HypothesisCompareLive.tsx
index 3d86dd8..004200e 100644
--- a/apps/web/src/components/HypothesisCompareLive.tsx
+++ b/apps/web/src/components/HypothesisCompareLive.tsx
@@ -6,6 +6,8 @@ import { rpc } from "../api/rpcClient";
import { HypothesisCompare } from "./HypothesisCompare";
import { parseRequests, type ReviewControls, type ReviewTarget } from "./judgmentReview";
import { ReviewDialog } from "./ReviewDialog";
+import { useHypothesisLinks } from "./useHypothesisLinks";
+import { useDiscrimination, useLessons, useSameHypotheses } from "./useJudgmentRecords";
type SendInput = { hypothesisId: string; evidenceRef: string | null; reasons: string[]; note: string; digest?: string };
@@ -18,6 +20,10 @@ export function HypothesisCompareLive({ result, projectId, threadId }: { result:
refetchInterval: query => parseRequests(query.state.data?.value.requests).some(item => item.status === "REVIEWING") ? 3000 : false });
const requests = parseRequests(list.data?.value.requests);
const [target, setTarget] = useState(null);
+ const linked = useHypothesisLinks(projectId);
+ const discrimination = useDiscrimination(projectId);
+ const lessons = useLessons(projectId);
+ const same = useSameHypotheses(projectId);
const send = useMutation({
mutationFn: async (input: SendInput) => {
const digest = input.digest ?? textValue(objectValue((await rpc<{ hypothesis: unknown }>("hypothesis/read",
@@ -39,7 +45,7 @@ export function HypothesisCompareLive({ result, projectId, threadId }: { result:
return <>
{list.error && 재검토 요청 상태를 읽지 못했습니다. }
{!target && send.error && 재검토 요청을 보내지 못했습니다. {send.error.message} }
-
+
setTarget(null)}
onSubmit={(reasons, note) => target && send.mutate({ hypothesisId: target.hypothesisId, evidenceRef: target.evidenceRef, reasons, note })}/>
>;
diff --git a/apps/web/src/components/HypothesisContractSwitch.test.tsx b/apps/web/src/components/HypothesisContractSwitch.test.tsx
new file mode 100644
index 0000000..809de8f
--- /dev/null
+++ b/apps/web/src/components/HypothesisContractSwitch.test.tsx
@@ -0,0 +1,54 @@
+// @vitest-environment jsdom
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { HypothesisContractSwitch } from "./HypothesisContractSwitch";
+
+const state = vi.hoisted(() => ({ calls: [] as { method: string; input: Record }[], on: false, fail: false }));
+vi.mock("../api/rpcClient", async importOriginal => ({ ...(await importOriginal()),
+ rpc: async (method: string, input: Record) => {
+ state.calls.push({ method, input });
+ if (method === "model/callSettings/update") { if (state.fail) throw new Error("no"); state.on = input.hypothesis_contract_v3 === true; }
+ return { value: { auto_retry_interrupted_model_call: false, hypothesis_contract_v3: state.on, settings_digest: "d".repeat(64) }, state: "SUCCEEDED", operation_id: "op" };
+ } }));
+
+let root: Root | undefined; let node: HTMLDivElement;
+beforeEach(() => { state.calls = []; state.on = false; state.fail = false; });
+afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; node?.remove(); });
+const settle = async () => { for (let i = 0; i < 6; i++) await act(async () => { await new Promise(done => setTimeout(done, 5)); }); };
+async function mount() {
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+ node = document.createElement("div"); document.body.append(node); root = createRoot(node);
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
+ await act(async () => root!.render( ));
+ await settle();
+}
+const input = () => node.querySelector("input[type=checkbox]")!;
+
+it("shows the switch off, says in easy words what it does and that it is off unless turned on", async () => {
+ await mount();
+ expect(input().checked).toBe(false);
+ expect(node.textContent).toContain("가설 만들 때 예상 결과와 기각 조건도 함께 받기");
+ expect(node.textContent).toContain("기본은 꺼짐");
+ expect(node.textContent).toContain("모델을 더 부르지는 않습니다");
+ expect(node.textContent).not.toMatch(/v3|hypothesis_portfolio|expected_by_hypothesis/);
+});
+
+it("sends only its own switch and the digest it read, and shows what the server says afterwards", async () => {
+ await mount();
+ await act(async () => input().click());
+ await settle();
+ const update = state.calls.find(call => call.method === "model/callSettings/update")!;
+ expect(update.input).toEqual({ project_id: "p", hypothesis_contract_v3: true, expected_digest: "d".repeat(64) });
+ expect(input().checked).toBe(true);
+});
+
+it("says so when the setting could not be changed", async () => {
+ state.fail = true;
+ await mount();
+ await act(async () => input().click());
+ await settle();
+ expect(node.textContent).toContain("설정을 바꾸지 못했습니다");
+ expect(input().checked).toBe(false);
+});
diff --git a/apps/web/src/components/HypothesisContractSwitch.tsx b/apps/web/src/components/HypothesisContractSwitch.tsx
new file mode 100644
index 0000000..043fae8
--- /dev/null
+++ b/apps/web/src/components/HypothesisContractSwitch.tsx
@@ -0,0 +1,31 @@
+import { Switch } from "@blueprintjs/core";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { rpc } from "../api/rpcClient";
+
+type CallSettings = { auto_retry_interrupted_model_call: boolean; hypothesis_contract_v3: boolean; settings_digest: string | null };
+
+export const hypothesisContractHelp = "켜면 가설을 만드는 같은 호출에서 각 시험이 가설마다 어떤 결과를 낼지와 이 가설을 버릴 조건도 함께 받아, 시험 순서를 더 정확히 셉니다. 모델을 더 부르지는 않습니다. 답이 조금 길어질 수 있고, 모델이 낸 것은 미확정 제안으로만 보입니다. 기본은 꺼짐입니다.";
+
+/** A project's switch for asking the generator for expected results and refutation conditions (off unless turned on). It shows what the server says. */
+export function HypothesisContractSwitch({ projectId }: { projectId: string }) {
+ const client = useQueryClient();
+ const key = ["model-call-settings", projectId];
+ const query = useQuery({
+ queryKey: key,
+ queryFn: ({ signal }) => rpc("model/callSettings/read", { project_id: projectId }, crypto.randomUUID(), signal),
+ });
+ const change = useMutation({
+ mutationFn: (enabled: boolean) => rpc("model/callSettings/update", {
+ project_id: projectId, hypothesis_contract_v3: enabled, expected_digest: query.data?.value.settings_digest ?? null,
+ }, crypto.randomUUID()),
+ onSettled: () => client.invalidateQueries({ queryKey: key }),
+ });
+ if (query.isPending) return null;
+ if (query.error || !query.data) return 가설 생성 설정을 읽지 못했습니다.
;
+ return
+ change.mutate(event.currentTarget.checked)}/>
+ {hypothesisContractHelp}
+ {change.error && 설정을 바꾸지 못했습니다. 화면을 다시 읽었으니 다시 시도해 주세요. }
+
;
+}
diff --git a/apps/web/src/components/HypothesisFields.tsx b/apps/web/src/components/HypothesisFields.tsx
new file mode 100644
index 0000000..d11f51c
--- /dev/null
+++ b/apps/web/src/components/HypothesisFields.tsx
@@ -0,0 +1,31 @@
+import { ReadableList } from "./ReadableList";
+import { hypothesisStatusWord, reviewWord, type ReviewView } from "./hypothesisReview";
+import type { HypothesisRow } from "./hypothesisView";
+
+export const CAUSE_UNCONFIRMED_TEXT = "원인 미확인 — 지지된 가설이 없습니다(원인이 없다는 뜻이 아닙니다)";
+
+/** Shown above the hypotheses when the review found none that holds. */
+export function CauseUnconfirmed() {
+ return {CAUSE_UNCONFIRMED_TEXT}
;
+}
+
+function ReviewLines({ review }: { review: ReviewView }) {
+ return
검토 결과: {reviewWord(review.relation)}
+ {review.explanation && }
+ {review.gaps.length > 0 && <>남은 빈틈 >}
+ ;
+}
+
+/** What the hypothesis records beyond its statement. A value an old record does not have is hidden, except the observation. */
+export function HypothesisFields({ row }: { row: HypothesisRow }) {
+ const status = row.status ? hypothesisStatusWord(row.status) : null;
+ return <>
+ 관찰 {row.observed || "기록 없음"}
+ 추론(가설) {row.statement}
+ {status && 가설 상태: {status} }
+ {row.assumptions.length > 0 && <>가정 >}
+ {row.predicted.length > 0 && <>맞다면 보일 관찰 >}
+ {row.missing.length > 0 && <>모자란 근거 >}
+ {row.review && }
+ >;
+}
diff --git a/apps/web/src/components/HypothesisLinkNotice.test.tsx b/apps/web/src/components/HypothesisLinkNotice.test.tsx
new file mode 100644
index 0000000..e023f0b
--- /dev/null
+++ b/apps/web/src/components/HypothesisLinkNotice.test.tsx
@@ -0,0 +1,133 @@
+// @vitest-environment jsdom
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import type { HypothesisLink } from "../api/hypothesisLink";
+import { HypothesisLinkNotice } from "./HypothesisLinkNotice";
+
+const calls = vi.hoisted(() => [] as { method: string; input: Record }[]);
+vi.mock("../api/rpcClient", async importOriginal => ({ ...(await importOriginal()),
+ rpc: async (method: string, input: Record) => {
+ calls.push({ method, input });
+ return { value: { events: [], links: [], reason_distribution: { total: 0, flipped: 0, by_reason: {} }, trace_digest: null }, state: "SUCCEEDED", operation_id: "op" };
+ } }));
+
+const BANNED = /재시험 필수|원인은|기준을 낮추|통과 가능|승인됨|종결|해결|면제|AI 추천/;
+const link = (patch: Partial = {}): HypothesisLink => ({
+ hypothesis_id: "h1", hypothesis_revision_digest: "d".repeat(64), statement: "RFP 문구를 옮겨 읽었을 수 있다", subject_kind: "CRITERION", subject_id: "C-1",
+ subject_title: "비 조건 탐지율", state: "STALE", change: "CONTENT_CHANGED", link_state: "HOLD_NO_RESULT", current_state: "FAIL_COMPUTED",
+ current_verdict_revision: "r".repeat(64), recheck: null, ...patch,
+});
+let root: Root | undefined; let container: HTMLDivElement; let client: QueryClient;
+beforeEach(() => { calls.length = 0; });
+afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; container?.remove(); document.body.innerHTML = ""; });
+async function mount(item: HypothesisLink | undefined, all: HypothesisLink[] = item ? [item] : []) {
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+ container = document.createElement("div"); document.body.append(container); root = createRoot(container);
+ client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
+ await act(async () => root!.render( ));
+}
+const dialog = () => document.body.querySelector('[role="dialog"]');
+const button = (label: string, scope: ParentNode = document.body) => [...scope.querySelectorAll("button")].find(item => item.textContent?.trim() === label) as HTMLButtonElement | undefined;
+const click = async (element: Element | undefined | null) => { expect(element).toBeTruthy(); await act(async () => (element as HTMLElement).click()); };
+const choose = async (label: string) => click([...dialog()!.querySelectorAll("label")].find(item => item.textContent?.includes(label))!.querySelector("input"));
+const write = async (text: string) => {
+ const area = dialog()!.querySelector("textarea")!;
+ await act(async () => { Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(area, text); area.dispatchEvent(new Event("input", { bubbles: true })); });
+};
+const visible = () => container.textContent! + (dialog()?.textContent ?? "");
+
+it("shows nothing for a current hypothesis or one without a link", async () => {
+ await mount(link({ state: "CURRENT", change: null }));
+ expect(container.textContent).toBe("");
+ await act(async () => root!.unmount()); container.remove(); root = undefined;
+ await mount(undefined);
+ expect(container.textContent).toBe("");
+});
+
+it("marks a hypothesis on a changed verdict as the old basis, with what it was and what it is, and says it is not used for approval", async () => {
+ await mount(link());
+ const text = container.textContent!;
+ expect(text).toContain("이전 근거 기준 — 판정이 바뀌었습니다(이전: 보류 · 결과 없음 → 지금: 기준 미달)");
+ expect(text).toContain("「비 조건 탐지율」 기준");
+ expect(text).toContain("행동 승인과 실행에 쓰이지 않습니다");
+ expect(button("다시 확인")).toBeTruthy();
+ expect(text).not.toMatch(BANNED); expect(text).not.toMatch(/CONTENT_CHANGED|HOLD_|FAIL_|STALE|verdict/); expect(text).not.toMatch(/\d\s*%|확률/);
+});
+
+it("needs a reason, needs words for 'other', and sends the reason, the words and the verdict the person saw", async () => {
+ await mount(link());
+ await click(button("다시 확인"));
+ expect(dialog()).toBeTruthy();
+ await click(button("확인 기록 남기기", dialog()!));
+ expect(dialog()!.textContent).toContain("이유를 하나 고르세요.");
+ await choose("기타");
+ await click(button("확인 기록 남기기", dialog()!));
+ expect(dialog()!.textContent).toContain("이 이유는 글로 적어야 합니다.");
+ expect(calls).toHaveLength(0);
+ await write("다른 장비로 잰 결과입니다");
+ await click(button("확인 기록 남기기", dialog()!));
+ expect(calls).toEqual([{ method: "hypothesis/link/recheck", input: { project_id: "p", hypothesis_ids: ["h1"], reason_code: "OTHER", note: "다른 장비로 잰 결과입니다", current_verdict_revision: "r".repeat(64) } }]);
+});
+
+it("does not ask for words for an ordinary change, but does for keeping a link across met and failed, and never for a research request", async () => {
+ await mount(link());
+ await click(button("다시 확인"));
+ await choose("판정이 바뀌었지만 이 가설과 관계없음");
+ await click(button("확인 기록 남기기", dialog()!));
+ expect(calls[0].input).toMatchObject({ reason_code: "UNRELATED", note: "" });
+ await act(async () => root!.unmount()); container.remove(); root = undefined; document.body.innerHTML = ""; calls.length = 0;
+ await mount(link({ change: "FLIPPED", link_state: "PASS_COMPUTED" }));
+ await click(button("다시 확인"));
+ expect(visible()).toContain("충족과 미달이 뒤바뀌었습니다");
+ await choose("새 결과도 이 가설과 맞음");
+ await click(button("확인 기록 남기기", dialog()!));
+ expect(dialog()!.textContent).toContain("이 이유는 글로 적어야 합니다.");
+ expect(calls).toHaveLength(0);
+ await choose("다시 조사가 필요함");
+ await click(button("확인 기록 남기기", dialog()!));
+ expect(calls[0].input).toMatchObject({ reason_code: "NEEDS_RESEARCH", note: "" });
+});
+
+it("covers the other old hypotheses of the same change in one decision, unless the person leaves them out", async () => {
+ const first = link(), second = link({ hypothesis_id: "h2" }), elsewhere = link({ hypothesis_id: "h3", subject_id: "C-2" });
+ await mount(first, [first, second, elsewhere]);
+ await click(button("다시 확인"));
+ expect(dialog()!.textContent).toContain("다른 가설 1개도 함께 확인");
+ await choose("판정이 바뀌었지만 이 가설과 관계없음");
+ await click(button("확인 기록 남기기", dialog()!));
+ expect(calls[0].input.hypothesis_ids).toEqual(["h1", "h2"]);
+ await act(async () => root!.unmount()); container.remove(); root = undefined; document.body.innerHTML = ""; calls.length = 0;
+ await mount(first, [first, second]);
+ await click(button("다시 확인"));
+ await click(dialog()!.querySelector('input[type="checkbox"]'));
+ await choose("판정이 바뀌었지만 이 가설과 관계없음");
+ await click(button("확인 기록 남기기", dialog()!));
+ expect(calls[0].input.hypothesis_ids).toEqual(["h1"]);
+});
+
+it("shows a kept hypothesis as rechecked with who and why, and a research request as still old", async () => {
+ const event = { reason_code: "STILL_MATCHES" as const, note: "원자료로 확인", actor_id: "human:local-user", created_at: "2026-10-06T01:02:03+00:00", flipped: false };
+ await mount(link({ state: "RECHECKED", recheck: event }));
+ expect(container.textContent).toContain("다시 확인됨 — 새 결과도 이 가설과 맞음(근거 확인함)");
+ expect(container.textContent).toContain("원자료로 확인");
+ expect(container.textContent).not.toContain("이전 근거 기준");
+ expect(button("다시 확인")).toBeUndefined();
+ await act(async () => root!.unmount()); container.remove(); root = undefined;
+ await mount(link({ recheck: { ...event, reason_code: "NEEDS_RESEARCH", note: "" } }));
+ expect(container.textContent).toContain("이전 근거 기준 — 판정이 바뀌었습니다");
+ expect(container.textContent).toContain("이유 기록: 다시 조사가 필요함");
+ expect(button("다시 확인")).toBeTruthy();
+});
+
+it("says in the dialog that only the basis changed when the verdict state is the same, and not for any other change", async () => {
+ await mount(link({ change: "EVIDENCE_ONLY", link_state: "PASS_COMPUTED", current_state: "PASS_COMPUTED" }));
+ await click(button("다시 확인"));
+ expect(dialog()!.textContent).toContain("판정 상태는 그대로이고, 판정이 기댄 결과나 근거 위치만 바뀌었습니다");
+ expect(dialog()!.textContent).not.toMatch(BANNED);
+ await act(async () => root!.unmount()); container.remove(); document.body.innerHTML = ""; root = undefined;
+ await mount(link());
+ await click(button("다시 확인"));
+ expect(dialog()!.textContent).not.toContain("판정 상태는 그대로이고");
+});
diff --git a/apps/web/src/components/HypothesisLinkNotice.tsx b/apps/web/src/components/HypothesisLinkNotice.tsx
new file mode 100644
index 0000000..0039217
--- /dev/null
+++ b/apps/web/src/components/HypothesisLinkNotice.tsx
@@ -0,0 +1,38 @@
+import { Button, Callout } from "@blueprintjs/core";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useState } from "react";
+import { hypothesisLinksKey, recheckHypothesisLinks, type ReasonDistribution } from "../api/hypothesisLink";
+import { LinkRecheckDialog } from "./LinkRecheckDialog";
+import { linkChangeLine, linkSiblings, recheckLine, type HypothesisLink } from "./hypothesisLinkText";
+
+function RecheckWork({ projectId, link, siblings, distribution, onClose }: {
+ projectId: string; link: HypothesisLink; siblings: HypothesisLink[]; distribution?: ReasonDistribution; onClose: () => void;
+}) {
+ const client = useQueryClient();
+ const send = useMutation({
+ mutationFn: (input: { ids: string[]; reason: Parameters[2]; note: string; revision: string | null }) =>
+ recheckHypothesisLinks(projectId, input.ids, input.reason, input.note, input.revision),
+ onSuccess: async () => { await client.invalidateQueries({ queryKey: hypothesisLinksKey(projectId) }); onClose(); },
+ });
+ return send.mutate({ ids, reason, note, revision })}/>;
+}
+
+/** What a hypothesis from a trace row says about its verdict: nothing while current; the old basis, or the person's re-check. */
+export function HypothesisLinkNotice({ projectId, link, all = [], distribution }: {
+ projectId: string; link: HypothesisLink | undefined; all?: HypothesisLink[]; distribution?: ReasonDistribution;
+}) {
+ const [open, setOpen] = useState(false);
+ if (!link || link.state === "CURRENT") return null;
+ const change = linkChangeLine(link);
+ const checked = recheckLine(link);
+ return
+ {change && {change}
}
+ {link.subject_title && 대상: 「{link.subject_title}」 {link.subject_kind === "CRITERION" ? "기준" : "요구사항"} }
+ {checked && {checked}
}
+ {link.state === "STALE" && setOpen(true)}>다시 확인
+ 이전 근거 기준인 가설은 행동 승인과 실행에 쓰이지 않습니다.
}
+ {open && setOpen(false)}/>}
+ ;
+}
diff --git a/apps/web/src/components/HypothesisLinkScreens.test.tsx b/apps/web/src/components/HypothesisLinkScreens.test.tsx
new file mode 100644
index 0000000..eaaab92
--- /dev/null
+++ b/apps/web/src/components/HypothesisLinkScreens.test.tsx
@@ -0,0 +1,51 @@
+// @vitest-environment jsdom
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, expect, it } from "vitest";
+import excerpt from "../api/fixtures/iris-judgment-excerpt.json";
+import type { HypothesisLink } from "../api/hypothesisLink";
+import { HypothesisCompare } from "./HypothesisCompare";
+import { ResearchResultCard } from "./ResearchResultCard";
+
+let root: Root | undefined;
+let node: HTMLDivElement;
+afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; node?.remove(); });
+
+const ids = excerpt.portfolio.hypotheses.map(h => h.hypothesis_id);
+const link = (index: number, patch: Partial = {}): HypothesisLink => ({
+ hypothesis_id: ids[index], hypothesis_revision_digest: "d".repeat(64), statement: "s", subject_kind: "CRITERION", subject_id: "C-1", subject_title: "비 조건 탐지율",
+ state: "STALE", change: "CONTENT_CHANGED", link_state: "HOLD_NO_RESULT", current_state: "FAIL_COMPUTED", current_verdict_revision: "r".repeat(64), recheck: null, ...patch,
+});
+const result = { answer: excerpt.answer, portfolio: { hypotheses: structuredClone(excerpt.portfolio.hypotheses) }, selected_evidence_refs: [] } as Record;
+async function mount(element: React.ReactElement) {
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+ node = document.createElement("div"); document.body.append(node); root = createRoot(node);
+ await act(async () => root!.render(element));
+}
+const NOTICE = "이전 근거 기준 — 판정이 바뀌었습니다";
+
+it("marks only the hypothesis whose row changed, on the comparison cards", async () => {
+ await mount( );
+ const cards = [...node.querySelectorAll(".hypothesis-card")];
+ expect(cards[0].textContent).toContain(NOTICE);
+ expect(cards[1].textContent).not.toContain(NOTICE); // its row did not change
+ expect(cards[2].textContent).not.toContain(NOTICE); // no link at all: an old or ordinary hypothesis
+});
+
+it("shows no mark when there are no links or no project to ask", async () => {
+ await mount( );
+ expect(node.textContent).not.toContain(NOTICE);
+ await act(async () => root!.unmount()); node.remove(); root = undefined;
+ await mount();
+ expect(node.textContent).not.toContain(NOTICE);
+});
+
+it("marks the same hypothesis in the result card summary", async () => {
+ await mount( undefined} projectId="p" hypothesisLinks={[link(2)]}/>);
+ const summary = node.querySelector(".conversation-findings")!;
+ const rows = [...summary.querySelectorAll(":scope > div")];
+ expect(rows[2].textContent).toContain(NOTICE);
+ expect(rows[0].textContent).not.toContain(NOTICE);
+ expect(summary.querySelectorAll(".hypothesis-link")).toHaveLength(1);
+ expect(summary.textContent).toContain("다시 확인");
+});
diff --git a/apps/web/src/components/HypothesisScreen.test.tsx b/apps/web/src/components/HypothesisScreen.test.tsx
new file mode 100644
index 0000000..6dc0848
--- /dev/null
+++ b/apps/web/src/components/HypothesisScreen.test.tsx
@@ -0,0 +1,157 @@
+// @vitest-environment jsdom
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, expect, it } from "vitest";
+import excerpt from "../api/fixtures/iris-judgment-excerpt.json";
+import { HypothesisCompare } from "./HypothesisCompare";
+import { causeUnconfirmed } from "./hypothesisReview";
+import { hypothesisRows, relationOf } from "./hypothesisView";
+import { matrixLines } from "./hypothesisMatrix";
+import { ResearchResultCard } from "./ResearchResultCard";
+
+let root: Root | undefined;
+let node: HTMLDivElement;
+afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; node?.remove(); });
+
+type Decision = { hypothesis_id: string; relation: string; evidence_refs?: string[]; explanation?: string; gaps?: string[] };
+const ids = excerpt.portfolio.hypotheses.map(h => h.hypothesis_id);
+const supportUnion = Array.from(new Set(excerpt.portfolio.hypotheses.flatMap(h => h.support_evidence_refs)));
+const build = (decisions: Decision[] | null, patch: (hyps: Record[]) => void = () => {}) => {
+ const hyps = structuredClone(excerpt.portfolio.hypotheses) as Record[];
+ patch(hyps);
+ const value: Record = { answer: excerpt.answer, portfolio: { hypotheses: hyps }, selected_evidence_refs: supportUnion };
+ if (decisions) value.hypothesis_review = { decisions, alternatives_considered: [], next_checks: [], uncertainty_reserve: "" };
+ return value;
+};
+const review = (relations: string[]): Decision[] => relations.map((relation, index) => ({ hypothesis_id: ids[index], relation, explanation: "검토한 설명입니다.", gaps: ["더 필요한 자료가 있습니다."] }));
+async function mount(value: Record) {
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+ node = document.createElement("div"); document.body.append(node); root = createRoot(node);
+ await act(async () => root!.render());
+}
+const NOTICE = "원인 미확인 — 지지된 가설이 없습니다(원인이 없다는 뜻이 아닙니다)";
+/** The IRIS fixture carries model-written sentences and its own missing-evidence lists; strip them to leave only what the screen words itself. */
+const plain = (hyps: Record[]) => {
+ for (const hyp of hyps) {
+ Object.assign(hyp, { statement: "가설 문장입니다.", uncertainty: "아직 불확실합니다.", missing_evidence: [], counterevidence_queries: [],
+ discriminating_tests: [{ procedure_candidate: "시험 절차입니다.", expected_if_true: "참일 때 보입니다.", expected_if_alternative: "다른 설명일 때 보입니다." }] });
+ }
+};
+
+it("splits the observation from the inference and shows prediction, missing evidence and the dropping signal in words", async () => {
+ await mount(build(review(["SUPPORTED", "QUALIFIED", "INCONCLUSIVE"]), hyps => {
+ hyps[0].observed_problem = "비 오는 날 제동 거리가 길어집니다.";
+ hyps[0].assumptions = ["노면 상태가 같았습니다."];
+ hyps[0].predicted_observations = ["젖은 노면에서만 거리가 늘어납니다."];
+ hyps[0].missing_evidence = ["타이어 마모 기록"];
+ hyps[0].status = "TESTABLE";
+ }));
+ const first = node.querySelector(".hypothesis-card")!;
+ expect(first.querySelector(".hypothesis-observed")!.textContent).toContain("비 오는 날 제동 거리가 길어집니다.");
+ expect(first.querySelector(".hypothesis-inferred")!.textContent).toContain(String(hyps0Statement()));
+ expect(first.textContent).toContain("노면 상태가 같았습니다.");
+ expect(first.textContent).toContain("젖은 노면에서만 거리가 늘어납니다.");
+ expect(first.textContent).toContain("타이어 마모 기록");
+ expect(first.textContent).toContain("시험할 수 있음");
+ expect(first.textContent).toContain("이 가설을 버릴 신호");
+ const words = Array.from(node.querySelectorAll(".hypothesis-card")).map(card => card.querySelector(".hypothesis-review")?.textContent ?? "");
+ expect(words[0]).toContain("지지");
+ expect(words[1]).toContain("조건부 지지");
+ expect(words[2]).toContain("결론 불가");
+ expect(words[0]).toContain("검토한 설명입니다.");
+});
+const hyps0Statement = () => excerpt.portfolio.hypotheses[0].statement;
+
+it("shows the cause-unconfirmed line only when a review exists and none of its decisions supports a hypothesis", async () => {
+ await mount(build(review(["UNSUPPORTED", "INCONCLUSIVE", "INCONCLUSIVE"])));
+ expect(node.textContent).toContain(NOTICE);
+ expect(node.textContent!.indexOf(NOTICE)).toBeLessThan(node.textContent!.indexOf(hyps0Statement()));
+ expect(causeUnconfirmed(build(review(["UNSUPPORTED", "INCONCLUSIVE"])))).toBe(true);
+ expect(causeUnconfirmed(build(review(["UNSUPPORTED", "QUALIFIED"])))).toBe(false); // one conditional support is enough
+ expect(causeUnconfirmed(build(review(["SUPPORTED", "INCONCLUSIVE"])))).toBe(false);
+ expect(causeUnconfirmed(build(null))).toBe(false); // no review recorded: say nothing instead of guessing
+ expect(causeUnconfirmed(build([]))).toBe(false);
+});
+
+it("does not show the cause-unconfirmed line for a supported or an unreviewed old record", async () => {
+ await mount(build(review(["INCONCLUSIVE", "SUPPORTED", "UNSUPPORTED"])));
+ expect(node.textContent).not.toContain("원인 미확인");
+ await act(async () => root!.unmount()); node.remove();
+ await mount(build(null));
+ expect(node.textContent).not.toContain("원인 미확인");
+});
+
+it("also shows the cause-unconfirmed line in the result card summary by the same rule", async () => {
+ const render = async (value: Record) => {
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+ node = document.createElement("div"); document.body.append(node); root = createRoot(node);
+ await act(async () => root!.render( undefined}/>));
+ };
+ await render(build(review(["INCONCLUSIVE", "UNSUPPORTED", "INCONCLUSIVE"])));
+ expect(node.querySelector(".conversation-findings")!.textContent).toContain(NOTICE);
+ await act(async () => root!.unmount()); node.remove();
+ await render(build(review(["QUALIFIED", "UNSUPPORTED", "INCONCLUSIVE"])));
+ expect(node.querySelector(".conversation-findings")!.textContent).not.toContain("원인 미확인");
+});
+
+it("writes unevaluated cells as text, marks a row that tells hypotheses apart, and never scores", async () => {
+ // hypothesis 1 supports the shared source, hypothesis 2 counters it, hypothesis 3 has not looked at it
+ const shared = "span:shared-0001";
+ const value = build(null, hyps => {
+ hyps[0].support_evidence_refs = [shared]; hyps[1].support_evidence_refs = []; hyps[1].counterevidence_refs = [shared]; hyps[2].support_evidence_refs = [];
+ });
+ value.selected_evidence_refs = [shared, "span:other-0001"];
+ const lines = matrixLines(hypothesisRows(value), value.selected_evidence_refs as string[]);
+ expect(lines[0].cells).toEqual(["SUPPORT", "COUNTER", "UNEVALUATED"]);
+ expect(lines[0].splits).toBe(true);
+ expect(lines[1].cells).toEqual(["UNEVALUATED", "UNEVALUATED", "UNEVALUATED"]);
+ expect(lines[1].splits).toBe(false); // nothing was evaluated, so nothing was told apart
+ await mount(value);
+ const rows = Array.from(node.querySelectorAll("table.relation-table tbody tr"));
+ expect(rows[0].textContent).toContain("가설을 가름");
+ expect(rows[0].textContent).toContain("미평가"); // written as a word, not an empty cell
+ expect(rows[1].textContent).not.toContain("가설을 가름");
+ expect(Array.from(rows[1].querySelectorAll("td")).slice(1).every(cell => cell.textContent!.includes("미평가"))).toBe(true);
+});
+
+it("does not mark a row as telling hypotheses apart when only one is evaluated and the rest are not", () => {
+ const value = build(null, hyps => { hyps[0].support_evidence_refs = ["span:a"]; hyps[1].support_evidence_refs = []; hyps[2].support_evidence_refs = []; });
+ const lines = matrixLines(hypothesisRows(value), ["span:a"]);
+ expect(lines[0].splits).toBe(false);
+});
+
+it("treats a source the review cited and judged unsupportive as not applicable, not as supportive", () => {
+ const value = build([{ hypothesis_id: ids[1], relation: "UNSUPPORTED", evidence_refs: ["span:a"], explanation: "관계가 없습니다." }], hyps => {
+ hyps[0].support_evidence_refs = ["span:a"]; hyps[1].support_evidence_refs = []; hyps[2].support_evidence_refs = [];
+ });
+ const rows = hypothesisRows(value);
+ expect(relationOf(rows[1], "span:a")).toBe("NOT_APPLICABLE");
+ expect(matrixLines(rows, ["span:a"])[0].cells).toEqual(["SUPPORT", "NOT_APPLICABLE", "UNEVALUATED"]);
+});
+
+it("says no counter-evidence was found when a finished search found none, without counting it as support", async () => {
+ await mount(build(null, hyps => { for (const h of hyps) { h.critical_review = { terminal: "UNRESOLVED_NO_RESULTS", reasons: [] }; h.counterevidence_refs = []; } }));
+ expect(node.textContent).toContain("반박 근거를 찾지 못함");
+ expect(node.textContent).toContain("지지된다는 뜻은 아닙니다");
+ const card = node.querySelector(".hypothesis-card")!.textContent!;
+ expect(card).toContain("뒷받침 3");
+});
+
+it("renders an old record without the new fields without breaking, and hides what it does not have", async () => {
+ await mount(build(null, plain));
+ const first = node.querySelector(".hypothesis-card")!;
+ expect(first.querySelector(".hypothesis-observed")!.textContent).toContain("기록 없음");
+ expect(first.querySelector(".hypothesis-review")).toBeNull();
+ expect(first.textContent).not.toContain("가정");
+ expect(first.textContent).not.toContain("모자란 근거");
+ expect(node.textContent).not.toMatch(/undefined|null|NaN/);
+});
+
+it("uses plain words only: no internal code, no English field name, no probability number, no forbidden claim", async () => {
+ await mount(build(review(["UNSUPPORTED", "INCONCLUSIVE", "QUALIFIED"]), hyps => { plain(hyps); hyps[0].status = "SOMETHING_NEW"; hyps[0].observed_problem = "문제가 보입니다."; }));
+ const clone = node.cloneNode(true) as HTMLElement; clone.querySelectorAll("details").forEach(item => item.remove());
+ const text = clone.textContent!;
+ expect(text).not.toMatch(/SUPPORTED|QUALIFIED|UNSUPPORTED|INCONCLUSIVE|SOMETHING_NEW|NOT_APPLICABLE|UNEVALUATED|observed_problem|hypothesis_review/);
+ expect(text).not.toMatch(/\d+(\.\d+)?\s*%|확률|가능성이 \d/);
+ expect(text).not.toMatch(/원인은|확정|해결됨|통과 가능|승인됨/);
+});
diff --git a/apps/web/src/components/LessonRecall.test.tsx b/apps/web/src/components/LessonRecall.test.tsx
new file mode 100644
index 0000000..5a30261
--- /dev/null
+++ b/apps/web/src/components/LessonRecall.test.tsx
@@ -0,0 +1,93 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { expect, it } from "vitest";
+import excerpt from "../api/fixtures/iris-judgment-excerpt.json";
+import type { HypothesisLink } from "../api/hypothesisLink";
+import type { LessonItem } from "../api/lessons";
+import { HypothesisCompare } from "./HypothesisCompare";
+import { LessonRecall } from "./LessonRecall";
+
+const BANNED = /통과|해결|승인 완료|검증 완료|확률|순위|\d\s*%/;
+const LEAK = /TEST_RESULT|ELIMINATION|EFFECT_CONFIRMED|HUMAN_CLOSURE|SAME_CONDITION|STALE|REFUTED|ALTERNATIVE|THIS_HYPOTHESIS|WAIVER|HUMAN_CLOSED|undefined|\bnull\b/;
+
+const link = (hypothesisId: string, subject = "SYN-C-DET-RAIN"): HypothesisLink => ({
+ hypothesis_id: hypothesisId, hypothesis_revision_digest: "d".repeat(64), statement: "s", subject_kind: "CRITERION", subject_id: subject, subject_title: "비 조건 탐지율",
+ state: "CURRENT", change: null, link_state: "HOLD_NO_RESULT", current_state: "HOLD_NO_RESULT", current_verdict_revision: "r".repeat(64), recheck: null,
+});
+const lesson = (id: string, patch: Partial = {}): LessonItem => ({
+ lesson_id: id, kind: "TEST_RESULT", outcome: "ALTERNATIVE", state: "SAME_CONDITION", subject_kind: "CRITERION", subject_id: "SYN-C-DET-RAIN", hypothesis_id: "old-h", test_id: "t1",
+ actor_id: "human:local-user", created_at: "2026-10-05T01:02:03+00:00",
+ detail: { observations: [{ test_id: "t1", matched: "ALTERNATIVE", observation: "비 조건 결과가 맑은 조건과 같았다", evidence_refs: ["doc://run-7"] }] }, ...patch,
+});
+const render = (lessons: LessonItem[] | undefined, links: HypothesisLink[] | undefined = [link("now-h")], shown = ["now-h"]) =>
+ renderToStaticMarkup();
+
+it("shows an earlier recorded result of the same row, read back from its record, with who and when", () => {
+ const html = render([lesson("a")]);
+ expect(html).toContain("같은 조건의 이전 결과");
+ expect(html).toContain("이전 시험 결과: 다른 설명과 맞음");
+ expect(html).toContain("비 조건 결과가 맑은 조건과 같았다");
+ expect(html).toContain("doc://run-7");
+ expect(html).not.toMatch(BANNED);
+ expect(html).not.toMatch(LEAK);
+});
+
+it("says plainly that it is a reference and not used for the order, and does not promise the same result", () => {
+ const html = render([lesson("a")]);
+ expect(html).toContain("순서에 쓰이지 않습니다");
+ expect(html).toContain("같은 결과가 나온다는 뜻이 아닙니다");
+ expect(html).toContain("같은 기준");
+});
+
+it("leaves out the lessons of the hypotheses on screen, of another row, and shows nothing without links or lessons", () => {
+ const html = render([lesson("own", { hypothesis_id: "now-h", detail: { observations: [{ test_id: "t1", matched: "ALTERNATIVE", observation: "화면에 이미 있는 결과", evidence_refs: [] }] } }),
+ lesson("other", { subject_id: "SYN-C-DET-FOG", detail: { observations: [{ test_id: "t1", matched: "ALTERNATIVE", observation: "안개 줄의 결과", evidence_refs: [] }] } })]);
+ expect(html).toBe("");
+ expect(render(undefined)).toBe("");
+ expect(renderToStaticMarkup()).toBe("");
+ expect(render([lesson("a")], [])).toBe("");
+});
+
+it("counts the lessons it left out for being stale or refuted, without listing them", () => {
+ const html = render([lesson("fresh"), lesson("old", { state: "STALE", detail: { observations: [{ test_id: "t1", matched: "ALTERNATIVE", observation: "낡은 결과 문장", evidence_refs: [] }] } }),
+ lesson("against", { state: "REFUTED", detail: { observations: [{ test_id: "t1", matched: "ALTERNATIVE", observation: "반박된 결과 문장", evidence_refs: [] }] } })]);
+ expect(html).toContain("낡음 1개");
+ expect(html).toContain("반박됨 1개");
+ expect(html).toContain("기록은 남아 있습니다");
+ expect(html).not.toContain("낡은 결과 문장");
+ expect(html).not.toContain("반박된 결과 문장");
+});
+
+it("tells apart a recorded closure, a confirmed effect and an elimination, with the original verdict and the document", () => {
+ const html = render([
+ lesson("c", { kind: "HUMAN_CLOSURE", outcome: "WAIVER_RECORDED", hypothesis_id: null, test_id: null, detail: { closure_kind: "WAIVER_RECORDED", basis_ref: "DEV-4", note: "", scope: null, original_state: "FAIL_COMPUTED" } }),
+ lesson("e", { kind: "EFFECT_CONFIRMED", outcome: "CONFIRMED", hypothesis_id: null, test_id: null, actor_id: "system:lesson-rule", detail: { closure_kind: "FIX_APPLIED", basis_ref: "ECN-12", note: "", scope: null, original_state: "FAIL_COMPUTED" } }),
+ lesson("x", { kind: "ELIMINATION", outcome: "REPEATED", test_id: null }),
+ ]);
+ expect(html).toContain("편차·면제 승인(원 기준 미충족 유지)");
+ expect(html).toContain("근거 문서: DEV-4");
+ expect(html).toContain("규칙이 계산");
+ expect(html).toContain("ECN-12");
+ expect(html).toContain("배제(반복 확인)");
+ expect(html).toContain("원래 판정: 기준 미달");
+ expect(html).not.toMatch(BANNED);
+ expect(html).not.toMatch(LEAK);
+});
+
+it("lists the newest first and never ranks or counts the recalled ones", () => {
+ const html = render([lesson("old", { created_at: "2026-10-01T00:00:00+00:00", detail: { observations: [{ test_id: "t1", matched: "NEITHER", observation: "먼저 기록한 결과", evidence_refs: [] }] } }),
+ lesson("new", { created_at: "2026-10-05T00:00:00+00:00", detail: { observations: [{ test_id: "t1", matched: "NEITHER", observation: "나중에 기록한 결과", evidence_refs: [] }] } })]);
+ expect(html.indexOf("나중에 기록한 결과")).toBeLessThan(html.indexOf("먼저 기록한 결과"));
+ expect(html).not.toMatch(/\d+\s*(번|회|건) (확인|나온)/);
+});
+
+const result = () => structuredClone(excerpt) as unknown as Record;
+const hypothesisIds = () => (excerpt as unknown as { portfolio: { hypotheses: { hypothesis_id: string }[] } }).portfolio.hypotheses.map(item => item.hypothesis_id);
+it("does not change the order of the tests: the ranking never reads a lesson", () => {
+ const links = hypothesisIds().map(id => link(id));
+ const plain = renderToStaticMarkup();
+ const withLessons = renderToStaticMarkup( );
+ const section = (html: string) => html.match(//)![0];
+ expect(withLessons).toContain("같은 조건의 이전 결과");
+ expect(plain).not.toContain("같은 조건의 이전 결과");
+ expect(section(withLessons)).toBe(section(plain));
+});
diff --git a/apps/web/src/components/LessonRecall.tsx b/apps/web/src/components/LessonRecall.tsx
new file mode 100644
index 0000000..c97a5a9
--- /dev/null
+++ b/apps/web/src/components/LessonRecall.tsx
@@ -0,0 +1,28 @@
+import type { HypothesisLink } from "../api/hypothesisLink";
+import type { LessonItem } from "../api/lessons";
+import { Tag } from "@blueprintjs/core";
+import { LESSON_NOTE, lessonDisplay, withheldLine } from "./lessonText";
+import { SAME_TAG } from "./sameHypothesisText";
+
+/**
+ * Earlier results of the same row in the same condition: the criterion, the unit, the condition and the rule revision all match.
+ * Shown as a reference only. The lessons of the hypotheses already on screen are not repeated, and nothing here changes an order.
+ */
+export function LessonRecall({ links, lessons, shownIds }: { links: HypothesisLink[] | undefined; lessons: LessonItem[] | undefined; shownIds: string[] }) {
+ const rows = (links ?? []).filter(item => shownIds.includes(item.hypothesis_id));
+ const mine = (lessons ?? []).filter(item => rows.some(row => row.subject_kind === item.subject_kind && row.subject_id === item.subject_id)
+ && !(item.hypothesis_id !== null && shownIds.includes(item.hypothesis_id)));
+ const recalled = mine.filter(item => item.state === "SAME_CONDITION").sort((a, b) => b.created_at.localeCompare(a.created_at));
+ const withheld = withheldLine(mine.filter(item => item.state === "STALE").length, mine.filter(item => item.state === "REFUTED").length);
+ if (recalled.length === 0 && !withheld) return null;
+ return
+ 같은 조건의 이전 결과
+ {LESSON_NOTE}
+ {recalled.length > 0 && {recalled.map(item => {
+ const shown = lessonDisplay(item);
+ const sameAsShown = (item.same_hypothesis_ids ?? []).some(id => shownIds.includes(id));
+ return {sameAsShown && <>{SAME_TAG} {" "}>}{shown.title} {shown.lines.map((line, index) => {line}
)} ;
+ })} }
+ {withheld && {withheld}
}
+ ;
+}
diff --git a/apps/web/src/components/LinkRecheckDialog.tsx b/apps/web/src/components/LinkRecheckDialog.tsx
new file mode 100644
index 0000000..4d8e859
--- /dev/null
+++ b/apps/web/src/components/LinkRecheckDialog.tsx
@@ -0,0 +1,49 @@
+import { Button, Callout, Checkbox, Classes, Dialog, Radio, RadioGroup, TextArea } from "@blueprintjs/core";
+import { useState } from "react";
+import type { ReasonDistribution } from "../api/hypothesisLink";
+import { noteRequired, RECHECK_REASONS, type HypothesisLink, type RecheckReasonCode } from "./hypothesisLinkText";
+
+/** Collects the reason a person gives for looking again at a changed verdict. Sending is the caller's job. */
+export function LinkRecheckDialog({ target, siblings, distribution, pending, error, onClose, onSubmit }: {
+ target: HypothesisLink | null; siblings: HypothesisLink[]; distribution?: ReasonDistribution; pending: boolean; error: string | null;
+ onClose: () => void; onSubmit: (hypothesisIds: string[], reason: RecheckReasonCode, note: string, currentVerdictRevision: string | null) => void;
+}) {
+ const [reason, setReason] = useState("");
+ const [note, setNote] = useState("");
+ const [withOthers, setWithOthers] = useState(true);
+ const [message, setMessage] = useState("");
+ const close = () => { setReason(""); setNote(""); setMessage(""); setWithOthers(true); onClose(); };
+ const covered = target ? [target, ...(withOthers ? siblings : [])] : [];
+ const flipped = covered.some(item => item.change === "FLIPPED");
+ const needWords = reason !== "" && noteRequired(reason, flipped);
+ const submit = () => {
+ if (!target) return;
+ if (reason === "") { setMessage("이유를 하나 고르세요."); return; }
+ if (needWords && !note.trim()) { setMessage("이 이유는 글로 적어야 합니다."); return; }
+ setMessage("");
+ onSubmit(covered.map(item => item.hypothesis_id), reason, note.trim(), target.current_verdict_revision);
+ };
+ const by = distribution?.by_reason ?? {};
+ return
+
+ {target &&
{target.statement}
}
+
사람이 바뀐 판정을 보고 이 가설을 어떻게 볼지 적어 두는 기록입니다. 가설도 판정도 바뀌지 않고, 누가 언제 왜 확인했는지만 남습니다. 기록은 지워지지 않습니다.
+ {covered.length > 0 && covered.every(item => item.change === "EVIDENCE_ONLY") &&
판정 상태는 그대로이고, 판정이 기댄 결과나 근거 위치만 바뀌었습니다. 바뀐 근거를 직접 본 뒤 이유를 고르세요.
}
+
setReason(event.currentTarget.value as RecheckReasonCode)}>
+ {RECHECK_REASONS.map(item => )}
+
+ {reason !== "" &&
{RECHECK_REASONS.find(item => item.code === reason)?.hint}
}
+
{needWords ? "이유를 글로 적어 주세요 (필수)" : "메모 (선택)"}
+
+ {flipped &&
충족과 미달이 뒤바뀐 변경이라, 가설을 그대로 두려면 이유를 글로 적어야 합니다.
}
+ {siblings.length > 0 &&
setWithOthers(value => !value)}/>}
+ {distribution && distribution.total > 0 && 이 프로젝트에서 지금까지 다시 확인한 이유: 관계없음 {by.UNRELATED ?? 0} · 맞음 {by.STILL_MATCHES ?? 0} · 다시 조사 {by.NEEDS_RESEARCH ?? 0} · 기타 {by.OTHER ?? 0} (그중 충족과 미달이 뒤바뀐 경우 {distribution.flipped}건)
}
+ {message && {message} }
+ {error && {error} }
+
+
+ ;
+}
diff --git a/apps/web/src/components/LiveProjectWorkspace.tsx b/apps/web/src/components/LiveProjectWorkspace.tsx
index fdec848..870dd6c 100644
--- a/apps/web/src/components/LiveProjectWorkspace.tsx
+++ b/apps/web/src/components/LiveProjectWorkspace.tsx
@@ -5,7 +5,9 @@ import { rpc } from "../api/rpcClient";
import { captureProjectCreation } from "../api/projectCreation";
import { readWorkspaceContext, saveWorkspaceContext } from "../api/research";
import type { WorkspaceReady } from "../api/research";
-import { inspectLocalContext, validWorkspaceId, type BrowserScope } from "../api/localWorkspacePersistence";
+import { inspectLocalContext, validWorkspaceId, type BrowserScope, type PendingOrigin } from "../api/localWorkspacePersistence";
+import { readDraft, writeDraft } from "../api/conversation";
+import { fillQuestion, threadForRow, type RowInvestigation } from "./traceInvestigation";
import type { ConnectedArtifact, ProjectSummary, WorkThread } from "../types";
import { CapabilityWorkbench } from "./CapabilityWorkbench";
import { DomainRecords } from "./DomainRecords";
@@ -17,6 +19,7 @@ import { cutoffLabel, projectCutoffText } from "../api/sourceTime";
import { FirstRunSetup } from "./FirstRunSetup";
import { workspaceSetupIssue, workspaceSetupIssueText, type WorkspaceSetupIssue } from "./workspaceSetupIssue";
import { AutoRetrySwitch } from "./AutoRetrySwitch";
+import { HypothesisContractSwitch } from "./HypothesisContractSwitch";
import { ModelCredentialPanel } from "./ModelCredentialPanel";
import { ModelSettings } from "./ModelSettings";
import { ProjectWebSetup } from "./ProjectWebSetup";
@@ -28,6 +31,7 @@ import { WorkspaceErrorBoundary } from "./WorkspaceErrorBoundary";
import { ConversationTimeline } from "./ConversationTimeline";
import { ResearchComposer } from "./ResearchComposer";
import { ResearchSidePanel, type SidePanelTab } from "./ResearchSidePanel";
+import { TracePage } from "./TracePage";
import type { ResearchDetail } from "./ResearchResultCard";
import {
isTimelineExampleRequested,
@@ -35,7 +39,7 @@ import {
timelineExampleStatus,
} from "../api/timelineExample";
-type Page = "research" | "resources" | "records" | "raw-records" | "settings" | "capabilities";
+type Page = "research" | "resources" | "records" | "trace" | "raw-records" | "settings" | "capabilities";
function TimelineExamplePane() {
return (
@@ -90,12 +94,20 @@ function ProjectForm({ onCreated }: { onCreated: (projectId: string) => void })
;
}
-function ProjectSession({ project, threadId, epoch, page, onPage, onAdmitted, onUserInput, hosted = false, workspaceId = null, executionReady = true, readSuspended = false, setupIssue = null }: {
+type TraceRowRef = { kind: string; id: string };
+/** The trace row a question was started from: held here until it is sent, so the next request carries it. */
+type RowOrigin = { threadId: string; origin: PendingOrigin };
+
+function ProjectSession({ project, threadId, epoch, page, onPage, onAdmitted, onUserInput, hosted = false, workspaceId = null, executionReady = true, readSuspended = false, setupIssue = null,
+ rowOrigin = null, onOriginDone, onInvestigate, traceFocus = null, onOpenTrace }: {
project: ProjectSummary; threadId: string; epoch: RefObject; page: Page; onPage: (page: Page) => void; onAdmitted: (threadId: string) => void; onUserInput: () => void;
- hosted?: boolean; workspaceId?: string | null; executionReady?: boolean; readSuspended?: boolean; setupIssue?: WorkspaceSetupIssue | null;
+ hosted?: boolean; workspaceId?: string | null; executionReady?: boolean; readSuspended?: boolean; setupIssue?: WorkspaceSetupIssue | null;
+ rowOrigin?: RowOrigin | null; onOriginDone?: () => void; onInvestigate?: (item: RowInvestigation) => void; traceFocus?: TraceRowRef | null; onOpenTrace?: (row: TraceRowRef) => void;
}) {
const storageScope = useMemo(() => hosted ? { mode: "HOSTED" } : { mode: "LOCAL", workspaceId }, [hosted, workspaceId]);
- const session = useResearchSession(project.project_id, threadId, epoch, onAdmitted, storageScope, executionReady);
+ const origin = rowOrigin && rowOrigin.threadId === threadId ? rowOrigin.origin : null;
+ const session = useResearchSession(project.project_id, threadId, epoch, onAdmitted, storageScope, executionReady, origin, onOriginDone);
+ useEffect(() => { if (origin && page === "research") document.getElementById("live-problem")?.focus(); }, [origin, page]);
const contentRef = useRef(null);
// Once the user has typed or submitted a question here, the work list arriving later must not move them elsewhere.
const started = session.problem.trim() !== "" || session.submit.isPending;
@@ -126,7 +138,7 @@ function ProjectSession({ project, threadId, epoch, page, onPage, onAdmitted, on
{page==="research" ?
{status?.problem??(threadId?"연구 이어가기":"새로운 질문")}
{import.meta.env.VITE_THOTH_TEST_MODE==="true"&&
검증 모드 · 고정 응답/통제 자료로 확인하는 화면입니다. }
-
item.source_uri)} onDetail={openDetail} queuedInstruction={session.queuedNotice} onContinue={()=>document.getElementById("live-problem")?.focus()} onRetry={question=>{session.setProblem(question);document.getElementById("live-problem")?.focus();}} onResume={operationId=>session.resume.mutate(operationId)} resuming={session.resume.isPending} onOpenHistory={()=>onPage("records")}/>
+ item.source_uri)} onDetail={openDetail} queuedInstruction={session.queuedNotice} onContinue={()=>document.getElementById("live-problem")?.focus()} onRetry={question=>{session.setProblem(question);document.getElementById("live-problem")?.focus();}} onResume={operationId=>session.resume.mutate(operationId)} resuming={session.resume.isPending} onOpenHistory={()=>onPage("records")} onOpenTrace={onOpenTrace}/>
{session.resume.error&&이어서 조사를 시작하지 못했습니다. {session.resume.error.message} }
{queryError&&{queryError.message}{void session.research.refetch();void sources.refetch();}}>다시 읽기 }
@@ -134,7 +146,7 @@ function ProjectSession({ project, threadId, epoch, page, onPage, onAdmitted, on
onOpenSettings={()=>onPage("settings")} hosted={hosted} executionReady={executionReady}/>
{panelOpen&&
onPage("records")} onManageResources={()=>{closeDetail();onPage("resources");}}/>} :
page==="capabilities" && !hosted ? <>
onPage("settings")}>설정으로 돌아가기 > :
- page==="records" ? : page==="records" ? {session.setProblem(question);onPage("research");}}/> :
page==="raw-records" && !hosted ? <>onPage("settings")}>설정으로 돌아가기 {setCatalogNamespace(namespace);onPage("capabilities");}}/>> :
page==="resources" ? <>연결한 자료
@@ -144,7 +156,7 @@ function ProjectSession({ project, threadId, epoch, page, onPage, onAdmitted, on
{hosted ? (
심사 모델 운영자가 준비한 OpenAI 모델을 사용합니다. 브라우저에 키를 넣지 않습니다.
) : (
- 모델 기본값 session.clearModel(session.renderedRevision,capturedEpoch)}/>
+ 모델 기본값 session.clearModel(session.renderedRevision,capturedEpoch)}/>
)}
사용량 보고된 사용 토큰: {status?.usage?.total_tokens?.toLocaleString()??"아직 없음"}{status?.usage?.state==="PARTIAL"?" (부분 관측)":""}
진행 중인 호출의 사용량은 아직 포함되지 않을 수 있습니다.
{!hosted&&<>setProjectDetailsOpen(value=>!value)} aria-expanded={projectDetailsOpen}>현재 프로젝트 설정 >}
{hosted?"기록과 자료":"기록과 로컬 도구"} onPage("resources")}>자료·연결 관리 onPage("records")}>연구 이력 {!hosted&&<>setDeveloperOpen(value=>!value)} aria-expanded={developerOpen}>로컬 개발자 도구 onPage("raw-records")}>상세 기록 열기 {setCatalogNamespace("all");onPage("capabilities");}}>상세 API 도구 열기
>}
@@ -202,7 +214,9 @@ export function LiveProjectWorkspace() {
const [missingSelection, setMissingSelection] = useState<"project" | "thread" | null>(null);
const [page, setPage] = useState("research");
const [search, setSearch] = useState("");
- const [selectionVersion,setSelectionVersion] = useState(0);
+ const [selectionVersion,setSelectionVersion] = useState(0);
+ const [rowOrigin,setRowOrigin] = useState(null);
+ const [traceFocus,setTraceFocus] = useState(null);
const epoch = useRef(0);
const previousScope = useRef(null);
const pendingAdmission = useRef(null);
@@ -262,14 +276,24 @@ export function LiveProjectWorkspace() {
setContext({ projectId: context.projectId, threadId: "" }); setMissingSelection("thread");
}
}, [scopeKey, restoredFor, context, projects, project, projectsQuery.isSuccess, projectsQuery.isFetching, threads, threadsQuery.isSuccess, threadsQuery.isFetching, client]);
- const chooseProject = (projectId:string, nextPage:Page="research") => {epoch.current+=1;pendingAdmission.current=null;setMissingSelection(null);setContextRestoreIssue(null);setSelectionVersion(v=>v+1);setContext({projectId,threadId:""});setPage(projectId?nextPage:"research");};
+ const chooseProject = (projectId:string, nextPage:Page="research") => {setRowOrigin(null);setTraceFocus(null);epoch.current+=1;pendingAdmission.current=null;setMissingSelection(null);setContextRestoreIssue(null);setSelectionVersion(v=>v+1);setContext({projectId,threadId:""});setPage(projectId?nextPage:"research");};
const openProject = (projectId:string) => {
if (projectId===context.projectId) {setPage("research");return;}
chooseProject(projectId);
openLatestWork.current = Boolean(projectId);
};
- const chooseThread = (threadId:string) => {openLatestWork.current=false;epoch.current+=1;pendingAdmission.current=null;setMissingSelection(null);setContextRestoreIssue(null);setSelectionVersion(v=>v+1);setContext(current=>({...current,threadId}));setPage("research");};
- const cancelOpenLatest = useCallback(() => { openLatestWork.current = false; }, []);
+ const chooseThread = (threadId:string) => {setRowOrigin(null);openLatestWork.current=false;epoch.current+=1;pendingAdmission.current=null;setMissingSelection(null);setContextRestoreIssue(null);setSelectionVersion(v=>v+1);setContext(current=>({...current,threadId}));setPage("research");};
+ const cancelOpenLatest = useCallback(() => { openLatestWork.current = false; }, []);
+ // "원인 조사" on a trace row: open the conversation of that row (a new one when it has none) with the question filled in.
+ // Nothing is sent; the user sends it, and the request then carries the row.
+ const investigate = (item: RowInvestigation) => {
+ if (!project) return;
+ const target = threadForRow(threads, item.origin.subject_kind, item.origin.subject_id)?.thread_id ?? "";
+ writeDraft(project.project_id, target, fillQuestion(readDraft(project.project_id, target, storageScope), item.question), storageScope);
+ chooseThread(target);
+ setRowOrigin({ threadId: target, origin: item.origin });
+ };
+ const openTraceRow = (row: TraceRowRef) => { setTraceFocus(row); setPage("trace"); };
// Opening a project lands on its most recently updated work instead of an empty question screen.
useEffect(() => {
if (!openLatestWork.current || !project || context.threadId || !threadsQuery.isSuccess || threadsQuery.isFetching) return;
@@ -346,12 +370,14 @@ export function LiveProjectWorkspace() {
Workspace {import.meta.env.VITE_THOTH_TEST_MODE==="true"&&검증 모드 }/ {projectsQuery.isFetching?"프로젝트 확인 중":project?.name??"프로젝트 선택"} {context.threadId&&<>/ {threadsQuery.isFetching?"작업 확인 중":visibleThreads.find(item=>item.thread_id===context.threadId)?.problem??"선택한 작업"} >}
{project&&projectCutoffText(project.cutoff_at)&&기준시점 {projectCutoffText(project.cutoff_at)} }{health.data?.status==="ok"?"서버 연결됨":health.error?"서버 연결 실패":"연결 확인 중"} {!hosted&&!executionReady&&새 연구 준비 필요 }{!hosted&&contextStoreFailed&&작업 위치 저장 실패 }{missingSelection&&저장된 {missingSelection==="project"?"프로젝트":"작업"} 선택 확인 필요 }void client.invalidateQueries()}/>
- setPage(value as Page)}>
+
setPage(value as Page)}>
{project&&
chooseThread(event.target.value)} options={[{value:"",label:"새 작업"},...visibleThreads.map(item=>({value:item.thread_id,label:(item.problem||"제목 없는 작업").slice(0,40)}))]}/>
}
{project ?
client.invalidateQueries()}>{pendingAdmission.current=threadId;setContext(current=>({...current,threadId}));}}/> :
+ onUserInput={cancelOpenLatest} onAdmitted={threadId=>{pendingAdmission.current=threadId;setContext(current=>({...current,threadId}));}}
+ rowOrigin={rowOrigin} onOriginDone={()=>setRowOrigin(null)} onInvestigate={investigate} traceFocus={traceFocus} onOpenTrace={openTraceRow}/> :
isTimelineExampleRequested() && page === "research" ?
:
{missingSelection&&{missingSelection==="project"?"저장된 프로젝트를 현재 목록에서 확인하지 못해 선택을 비웠습니다.":"저장된 작업 선택 확인 필요"} 저장된 초안은 자동 제출하지 않습니다. }{contextStoreFailed&&!hosted&&이 브라우저에 마지막 작업 위치를 저장하지 못했습니다. 재시작 후 자동 복원이 제한될 수 있습니다. }{!hosted&&!validWorkspaceId(workspaceId)&&워크스페이스 식별자를 확인하지 못해 브라우저 재시작용 초안 저장을 사용할 수 없습니다. }{page==="settings"?<>{hosted?운영 정보와 사용 현황은 프로젝트를 선택한 뒤 확인할 수 있습니다. :모델 연결 프로젝트별 기본 모델은 프로젝트를 선택한 뒤 설정합니다.
}>:{if(renderedEpoch===epoch.current)chooseProject(projectId,"resources");}}/>} }
;
}
+
diff --git a/apps/web/src/components/LiveProjectWorkspaceInvestigate.test.tsx b/apps/web/src/components/LiveProjectWorkspaceInvestigate.test.tsx
new file mode 100644
index 0000000..40717f1
--- /dev/null
+++ b/apps/web/src/components/LiveProjectWorkspaceInvestigate.test.tsx
@@ -0,0 +1,171 @@
+// @vitest-environment jsdom
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { readDraft } from "../api/conversation";
+import { saveWorkspaceContext } from "../api/research";
+import { LiveProjectWorkspace } from "./LiveProjectWorkspace";
+import demo from "./traceDemo.fixture.json";
+
+const WORKSPACE = `workspace:${"a".repeat(32)}`;
+const scope = { mode: "LOCAL" as const, workspaceId: WORKSPACE };
+const RAIN = demo.phase1.items.find(item => item.title === "Detection rate in rain")!.item_id;
+const FOG = demo.phase1.items.find(item => item.title === "Detection rate in fog")!.item_id;
+type Origin = { subject_kind: string; subject_id: string; verdict_revision: string } | null;
+const fixture = vi.hoisted(() => ({
+ threads: [] as { thread_id: string; origin: Origin }[],
+ resultOrigin: null as Record
| null,
+ calls: [] as { method: string; input: Record }[],
+ active: "t",
+}));
+vi.mock("../api/rpcClient", async importOriginal => ({ ...(await importOriginal()),
+ rpc: async (method: string, input: Record) => {
+ fixture.calls.push({ method, input });
+ let value: unknown = {};
+ if (method === "workspace/ready") value = { ready: true, execution_ready: true, workspace_readable: true, setup_complete: true,
+ workspace_id: `workspace:${"a".repeat(32)}`, deployment_mode: "LOCAL", model_connected: true, setup: { internet_consent: "DENIED" } };
+ if (method === "project/list") value = { projects: [{ project_id: "p", name: "synthetic project", overlay: "test", lifecycle: "ACTIVE", revision: 1, cutoff_at: "2026-09-26T00:00:00Z" }] };
+ if (method === "thread/list") value = { threads: fixture.threads.map((item, index) => ({ project_id: "p", thread_id: item.thread_id, updated_at: `2026-09-2${index}T00:00:00Z`,
+ problem: `stored ${item.thread_id}`, execution_state: "IDLE", lifecycle: "OPEN", origin: item.origin })) };
+ if (method === "thread/read") value = { project_id: "p", thread_id: input.thread_id, problem: "stored work", lifecycle: "OPEN", execution_state: "IDLE",
+ current_object_ids: [], operation_state: "SUCCEEDED", current_result: null };
+ if (method === "thread/activity/list") value = { conversation: { turns: [{ request_epoch: 1, request_revision_digest: "1".repeat(64), operation_id: "op-stored",
+ text: "synthetic question", edit_kind: "INITIAL", created_at: "2026-09-26T00:00:00Z", authored_text_ref: { revision_digest: "1".repeat(64) } }], next_before_epoch: null, history_limited: false } };
+ if (method === "operation/result/read") value = { operation_id: "op-stored", state: "SUCCEEDED",
+ result: { answer: "stored synthetic answer", thread_id: fixture.active, request_epoch: 1, ...(fixture.resultOrigin ? { origin: fixture.resultOrigin } : {}) }, error: null };
+ if (method === "project/source/list") value = { artifacts: [] };
+ if (method === "evidence/list") value = { evidence: [] };
+ if (method === "model/settings/read") value = { settings_digest: null, selection: { provider: null, model: null, reasoning_effort: null },
+ effective_settings: null, availability: "UNAVAILABLE", reason_code: "MODEL_CAPABILITY_UNKNOWN", model_options: [] };
+ if (method === "model/credential/list") value = { accounts: [] };
+ if (method === "trace/read") value = demo.phase1;
+ if (method === "thread/start" || method === "thread/input") value = { contract_version: 2, project_id: "p", thread_id: String(input.thread_id ?? "t-new"),
+ operation_id: "op-new", status: "ACCEPTED_RUNNING", request_epoch: 2 };
+ return { value, state: method === "thread/start" || method === "thread/input" ? "RUNNING" : "SUCCEEDED", operation_id: method === "thread/start" || method === "thread/input" ? "op-new" : "op" };
+ },
+}));
+
+let root: Root | undefined; let container: HTMLDivElement; let client: QueryClient;
+async function settle(check: () => boolean) {
+ for (let i = 0; i < 40; i++) {
+ await act(async () => { await new Promise(resolve => setTimeout(resolve, 15)); });
+ if (check()) return;
+ }
+ throw new Error(`did not settle: ${container.textContent?.slice(0, 200)}`);
+}
+async function mount(threadId = "") {
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ status: "ok", version: "synthetic" }))));
+ fixture.active = threadId;
+ saveWorkspaceContext("p", threadId, scope);
+ container = document.createElement("div"); document.body.append(container); root = createRoot(container);
+ client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
+ await act(async () => root!.render( ));
+ await settle(() => Boolean(container.querySelector('[role="tab"][data-tab-id="trace"]')));
+}
+beforeEach(() => { fixture.threads = []; fixture.resultOrigin = null; fixture.calls = []; });
+afterEach(async () => {
+ if (root) await act(async () => root!.unmount());
+ root = undefined; client?.clear(); container?.remove(); localStorage.clear(); sessionStorage.clear(); vi.unstubAllGlobals();
+});
+
+const tab = (id: string) => container.querySelector(`[role="tab"][data-tab-id="${id}"]`)!;
+const button = (label: string) => [...container.querySelectorAll("button")].find(item => (item.getAttribute("aria-label") ?? item.textContent?.trim()) === label) as HTMLButtonElement | undefined;
+const editor = () => container.querySelector("#live-problem")!;
+const click = async (element: Element | undefined | null) => { expect(element).toBeTruthy(); await act(async () => (element as HTMLElement).click()); };
+const type = async (text: string) => {
+ const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!;
+ await act(async () => { setter.call(editor(), text); editor().dispatchEvent(new Event("input", { bubbles: true })); });
+};
+const submit = async () => act(async () => container.querySelector("form.prompt-composer")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })));
+async function investigate(label: string) {
+ await click(tab("trace"));
+ await settle(() => Boolean(button(`${label} 원인 조사`)));
+ await click(button(`${label} 원인 조사`));
+ await settle(() => Boolean(editor()));
+}
+const sent = (method: string) => fixture.calls.filter(call => call.method === method);
+const revisionOf = (id: string) => demo.phase1.verdicts.find(item => item.subject_kind === "CRITERION" && item.subject_id === id)!.revision_digest;
+
+it("opens a new conversation for a row with none, fills the question, starts nothing, and sends the row only when the user sends", async () => {
+ fixture.threads = [{ thread_id: "t-ordinary", origin: null }];
+ await mount("t-ordinary");
+ await investigate("Detection rate in rain");
+ expect(button("첫 조사 시작")).toBeTruthy(); // a new conversation, not the ordinary thread that was open
+ expect(editor().value).toContain("Detection rate in rain");
+ expect(editor().value).toContain("원인 후보와 그것을 가를 시험을 찾아 주세요.");
+ expect(editor().value).not.toMatch(/HOLD_|NO_RESULT|CRITERION/);
+ expect(sent("thread/start")).toHaveLength(0);
+ expect(sent("thread/input")).toHaveLength(0);
+ const question = editor().value;
+ await submit();
+ await settle(() => sent("thread/start").length === 1);
+ expect(sent("thread/start")[0].input).toMatchObject({ project_id: "p", contract_version: 2, problem: question,
+ origin: { kind: "TRACE_VERDICT", project_id: "p", subject_kind: "CRITERION", subject_id: RAIN, verdict_revision: revisionOf(RAIN) } });
+});
+
+it("opens the existing conversation of the same row, with the question filled in for the new verdict, and sends to that thread", async () => {
+ fixture.threads = [{ thread_id: "t-other", origin: null }, { thread_id: "t-rain", origin: { subject_kind: "CRITERION", subject_id: RAIN, verdict_revision: "old-revision" } }];
+ await mount("t-other");
+ await investigate("Detection rate in rain");
+ expect(button("지시 추가")).toBeTruthy(); // an existing thread
+ expect(editor().value).toContain("Detection rate in rain");
+ expect(readDraft("p", "t-rain", scope)).toContain("Detection rate in rain");
+ expect(sent("thread/start")).toHaveLength(0);
+ await submit();
+ await settle(() => sent("thread/input").length === 1);
+ expect(sent("thread/input")[0].input).toMatchObject({ thread_id: "t-rain", origin: { subject_id: RAIN, verdict_revision: revisionOf(RAIN) } });
+ expect(sent("thread/start")).toHaveLength(0);
+});
+
+it("gives another row its own conversation", async () => {
+ fixture.threads = [{ thread_id: "t-rain", origin: { subject_kind: "CRITERION", subject_id: RAIN, verdict_revision: "r" } }];
+ await mount("t-rain");
+ await investigate("Detection rate in fog");
+ expect(button("첫 조사 시작")).toBeTruthy();
+ expect(editor().value).toContain("Detection rate in fog");
+ await submit();
+ await settle(() => sent("thread/start").length === 1);
+ expect(sent("thread/start")[0].input).toMatchObject({ origin: { subject_id: FOG } });
+});
+
+it("keeps what the user had written and adds the question after it", async () => {
+ fixture.threads = [{ thread_id: "t-rain", origin: { subject_kind: "CRITERION", subject_id: RAIN, verdict_revision: "r" } }];
+ await mount("t-rain");
+ await type("내가 쓰던 메모");
+ await investigate("Detection rate in rain");
+ expect(editor().value.startsWith("내가 쓰던 메모\n\n")).toBe(true);
+ expect(editor().value).toContain("Detection rate in rain");
+});
+
+it("does not carry the row to a different question once the user has emptied the box", async () => {
+ await mount();
+ await investigate("Detection rate in fog");
+ await type("");
+ await type("전혀 다른 질문입니다");
+ await submit();
+ await settle(() => sent("thread/start").length === 1);
+ expect(sent("thread/start")[0].input).not.toHaveProperty("origin");
+});
+
+it("a result that came from a row says so, and the link goes back to that row on the trace page", async () => {
+ fixture.threads = [{ thread_id: "t-rain", origin: { subject_kind: "CRITERION", subject_id: RAIN, verdict_revision: "r" } }];
+ fixture.resultOrigin = { kind: "TRACE_VERDICT", subject_kind: "CRITERION", subject_id: RAIN, subject_title: "Detection rate in rain", state: "HOLD_NO_RESULT" };
+ await mount("t-rain");
+ await settle(() => Boolean(container.querySelector(".trace-origin-line")));
+ const line = container.querySelector(".trace-origin-line")!.textContent!;
+ expect(line).toContain("이 조사는 추적표의 「Detection rate in rain」 줄에서 시작했습니다.");
+ expect(line).not.toMatch(/HOLD_|CRITERION|TRACE_VERDICT/);
+ await click(button("추적표에서 보기"));
+ await settle(() => Boolean(container.querySelector(".trace-focus")));
+ expect(container.querySelector(".trace-focus")!.getAttribute("data-trace-row")).toBe(`CRITERION:${RAIN}`);
+ expect(tab("trace").getAttribute("aria-selected")).toBe("true");
+});
+
+it("shows nothing about a row for a result that did not start from one", async () => {
+ fixture.threads = [{ thread_id: "t-old", origin: null }];
+ await mount("t-old");
+ await settle(() => Boolean(container.textContent?.includes("stored synthetic answer")));
+ expect(container.querySelector(".trace-origin-line")).toBeNull();
+});
diff --git a/apps/web/src/components/MemoryInjectionSwitch.tsx b/apps/web/src/components/MemoryInjectionSwitch.tsx
index 7143d17..8984af7 100644
--- a/apps/web/src/components/MemoryInjectionSwitch.tsx
+++ b/apps/web/src/components/MemoryInjectionSwitch.tsx
@@ -2,11 +2,13 @@ import { Switch } from "@blueprintjs/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { rpc } from "../api/rpcClient";
-type MemorySettings = { memory_injection: boolean; settings_digest: string | null };
+type MemorySettings = { memory_injection: boolean; query_expansion?: boolean; settings_digest: string | null };
export const memoryInjectionHelp = "끄면 저장된 기억과 정정은 그대로 두고, 앞으로의 조사에는 기억을 넣지 않습니다.";
+export const queryExpansionLabel = "검색어 넓히기(조사마다 모델을 한 번 더 부릅니다)";
+export const queryExpansionHelp = "질문과 다른 말로 저장된 기억도 찾도록 모델이 검색어를 덧붙입니다. 끄면 질문에 쓴 말로만 찾고, 저장된 기억은 그대로입니다.";
-/** The project's on/off switch for handing stored memory to an investigation. */
+/** The project's switches: handing stored memory to an investigation, and widening its question. */
export function MemoryInjectionSwitch({ projectId }: { projectId: string }) {
const client = useQueryClient();
const key = ["memory-settings", projectId];
@@ -15,17 +17,25 @@ export function MemoryInjectionSwitch({ projectId }: { projectId: string }) {
queryFn: ({ signal }) => rpc("memory/settings/read", { project_id: projectId }, crypto.randomUUID(), signal),
});
const change = useMutation({
- mutationFn: (enabled: boolean) => rpc("memory/settings/update", {
- project_id: projectId, memory_injection: enabled, expected_digest: query.data?.value.settings_digest ?? null,
+ mutationFn: (next: { injection?: boolean; expansion?: boolean }) => rpc("memory/settings/update", {
+ project_id: projectId,
+ memory_injection: next.injection ?? query.data?.value.memory_injection ?? true,
+ // Only sent when the expansion switch itself changes; the project keeps its choice otherwise.
+ ...(next.expansion === undefined ? {} : { query_expansion: next.expansion }),
+ expected_digest: query.data?.value.settings_digest ?? null,
}, crypto.randomUUID()),
onSettled: () => client.invalidateQueries({ queryKey: key }),
});
if (query.isPending) return null;
if (query.error || !query.data) return 기억 사용 설정을 읽지 못했습니다.
;
const enabled = query.data.value.memory_injection;
+ const widening = query.data.value.query_expansion ?? true;
return
- change.mutate(event.currentTarget.checked)}/>
+ change.mutate({ injection: event.currentTarget.checked })}/>
{memoryInjectionHelp}
+ change.mutate({ expansion: event.currentTarget.checked })} data-query-expansion-switch/>
+ {queryExpansionHelp}
{change.error && 설정을 바꾸지 못했습니다. 화면을 다시 읽었으니 다시 시도해 주세요. }
;
}
diff --git a/apps/web/src/components/ModelSettings.test.tsx b/apps/web/src/components/ModelSettings.test.tsx
index 57e006e..a44fcdf 100644
--- a/apps/web/src/components/ModelSettings.test.tsx
+++ b/apps/web/src/components/ModelSettings.test.tsx
@@ -279,8 +279,8 @@ it("a late admission cannot replace a different selected thread",async()=>{
it("ordinary navigation only automatically calls the actual nonmutating query surface",async()=>{
await mount();
- // A8: opening a project also reads its latest work (read-only thread queries).
- expect(fixture.calls.filter(call=>!["project/list","thread/list","project/source/list","evidence/list","model/settings/read","workspace/ready","thread/read","thread/activity/list"].includes(call.method)).map(call=>call.method)).toEqual([]);
+ // A8: opening a project also reads its latest work (read-only thread queries), and which hypotheses came from a trace row (a read-only query).
+ expect(fixture.calls.filter(call=>!["project/list","thread/list","project/source/list","evidence/list","model/settings/read","workspace/ready","thread/read","thread/activity/list","hypothesis/link/list"].includes(call.method)).map(call=>call.method)).toEqual([]);
await act(async()=>{container.querySelector('[role="tab"][data-tab-id="records"]')?.click();});
expect(fixture.calls.some(call=>call.method==="receipt/verify")).toBe(false);
});
diff --git a/apps/web/src/components/ProjectMemoryPanel.test.tsx b/apps/web/src/components/ProjectMemoryPanel.test.tsx
index 7eaab10..c002777 100644
--- a/apps/web/src/components/ProjectMemoryPanel.test.tsx
+++ b/apps/web/src/components/ProjectMemoryPanel.test.tsx
@@ -6,14 +6,15 @@ import { afterEach, expect, it, vi } from "vitest";
import { ProjectMemoryPanel } from "./ProjectMemoryPanel";
const state = vi.hoisted(() => ({ revisions: [] as Record[], fail: false, calls: [] as { method: string; input: Record }[],
- transition: "COMMIT", support: "SUPPORTED", editError: null as string | null, injection: true, digest: null as string | null }));
+ transition: "COMMIT", support: "SUPPORTED", editError: null as string | null, injection: true, expansion: true, digest: null as string | null }));
vi.mock("../api/rpcClient", async importOriginal => ({ ...(await importOriginal()),
rpc: async (method: string, input: Record) => {
state.calls.push({ method, input });
- if (method === "memory/settings/read") return { value: { memory_injection: state.injection, settings_digest: state.digest }, state: "SUCCEEDED", operation_id: "" };
+ if (method === "memory/settings/read") return { value: { memory_injection: state.injection, query_expansion: state.expansion, settings_digest: state.digest }, state: "SUCCEEDED", operation_id: "" };
if (method === "memory/settings/update") {
state.injection = input.memory_injection as boolean; state.digest = "s".repeat(64);
- return { value: { memory_injection: state.injection, settings_digest: state.digest }, state: "SUCCEEDED", operation_id: "" };
+ if ("query_expansion" in input) state.expansion = input.query_expansion as boolean;
+ return { value: { memory_injection: state.injection, query_expansion: state.expansion, settings_digest: state.digest }, state: "SUCCEEDED", operation_id: "" };
}
if (method === "memory/edit/propose") {
if (state.editError) { const { RpcError } = await importOriginal(); throw new RpcError(state.editError, -32030, {}); }
@@ -47,7 +48,7 @@ const typeInto = async (label: string, value: string) => act(async () => {
field.dispatchEvent(new Event("input", { bubbles: true }));
});
afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; container?.remove(); document.body.innerHTML = ""; state.revisions = []; state.fail = false; state.calls = []; state.transition = "COMMIT"; state.editError = null; });
-afterEach(() => { state.support = "SUPPORTED"; state.injection = true; state.digest = null; });
+afterEach(() => { state.support = "SUPPORTED"; state.injection = true; state.expansion = true; state.digest = null; });
it("reads the memory version list and says plainly when it holds no stored record", async () => {
await mount();
@@ -212,3 +213,25 @@ it("shows a memory list that is switched off as it is: nothing is hidden or dele
expect(container.textContent).toContain("저장된 기억 기록 1개");
expect(container.textContent).toContain("표본이 작으면 결론을 보류한다");
});
+
+it("has a separate switch for widening the question that keeps the memory switch as it is", async () => {
+ await mount(); await settle();
+ expect(container.textContent).toContain("검색어 넓히기(조사마다 모델을 한 번 더 부릅니다)");
+ const boxes = container.querySelectorAll("[data-memory-switch] input[type=checkbox]");
+ expect(boxes).toHaveLength(2);
+ expect(boxes[1].checked).toBe(true);
+ await click(boxes[1]);
+ const sent = state.calls.find(call => call.method === "memory/settings/update")!;
+ expect(sent.input).toEqual({ project_id: "project:p", memory_injection: true, query_expansion: false, expected_digest: null });
+ const after = container.querySelectorAll("[data-memory-switch] input[type=checkbox]");
+ expect(after[0].checked).toBe(true);
+ expect(after[1].checked).toBe(false);
+});
+
+it("cannot widen the question while memory is switched off, and says nothing is asked then", async () => {
+ state.injection = false;
+ await mount(); await settle();
+ const boxes = container.querySelectorAll("[data-memory-switch] input[type=checkbox]");
+ expect(boxes[0].checked).toBe(false);
+ expect(boxes[1].disabled).toBe(true);
+});
diff --git a/apps/web/src/components/ReadableList.tsx b/apps/web/src/components/ReadableList.tsx
new file mode 100644
index 0000000..edbfd98
--- /dev/null
+++ b/apps/web/src/components/ReadableList.tsx
@@ -0,0 +1,12 @@
+import { plainReason, splitReadable } from "./plainWording";
+
+/** A list of texts the program or the model recorded: readable ones are listed, untranslated English is only counted and folded away. */
+export function ReadableList({ items }: { items: string[] }) {
+ const { plain, technical } = splitReadable(items);
+ const folded = [...items.filter(item => plainReason(item) !== null), ...technical];
+ return <>
+ {plain.length > 0 && {plain.map((item, index) => {item} )} }
+ {technical.length > 0 && 기록된 상세 문구 {technical.length}개
}
+ {folded.length > 0 && 기술 정보 {folded.join(" · ")} }
+ >;
+}
diff --git a/apps/web/src/components/RecordedStanding.test.tsx b/apps/web/src/components/RecordedStanding.test.tsx
new file mode 100644
index 0000000..be71024
--- /dev/null
+++ b/apps/web/src/components/RecordedStanding.test.tsx
@@ -0,0 +1,95 @@
+// @vitest-environment jsdom
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act, type ReactElement } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { renderToStaticMarkup } from "react-dom/server";
+import { afterEach, expect, it, vi } from "vitest";
+import excerpt from "../api/fixtures/iris-judgment-excerpt.json";
+import type { DiscriminationItem } from "../api/judgmentRecords";
+import { ActionCompare } from "./ActionCompare";
+import { HypothesisCompare } from "./HypothesisCompare";
+import { standingActionNote, standingLine } from "./judgmentRecordText";
+import { TestActionDraft } from "./TestActionDraft";
+
+const calls = vi.hoisted(() => [] as string[]);
+vi.mock("../api/rpcClient", async importOriginal => ({ ...(await importOriginal()),
+ rpc: async (method: string, input: Record) => {
+ calls.push(method);
+ if (method === "action/plan/read") return { value: { plan: { plan_id: input.plan_id }, authorizations: [] }, state: "SUCCEEDED", operation_id: "" };
+ return { value: { action: { risk_tier: "R3", policy_state: "APPROVAL_REQUIRED", required_roles: ["project-owner"] }, items: [] }, state: "SUCCEEDED", operation_id: "" };
+ } }));
+
+const AGAINST = "이 행동이 기대는 가설이 기록된 시험 결과에서 다른 설명과 맞았습니다";
+const item = (id: string, standing: DiscriminationItem["standing"]): DiscriminationItem => ({
+ hypothesis_id: id, results: [], result_history_count: 0, refutation_conditions: [], conditions_history_count: 0, elimination: null, standing });
+let root: Root | undefined; let node: HTMLDivElement;
+afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; node?.remove(); calls.length = 0; });
+async function mount(element: ReactElement) {
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+ node = document.createElement("div"); document.body.append(node); root = createRoot(node);
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
+ await act(async () => root!.render({element} ));
+ for (let i = 0; i < 4; i++) await act(async () => { await new Promise(done => setTimeout(done, 5)); });
+}
+
+it("names each standing in plain words and says nothing when there is none", () => {
+ expect([undefined, "NONE", "FITS", "AGAINST_ONCE", "AGAINST_REPEATED", "MIXED"].map(value => standingLine(value as never))).toEqual([
+ null, null, "기록된 시험 결과: 이 가설과 맞음", "기록된 시험 결과: 다른 설명과 맞음(1건)", "기록된 시험 결과: 다른 설명과 맞음(반복)", "기록된 시험 결과: 엇갈림"]);
+});
+
+it("notes an action only when a hypothesis it rests on went against the recorded results", () => {
+ const items = [item("h-1", "AGAINST_ONCE"), item("h-2", "AGAINST_REPEATED"), item("h-3", "FITS"), item("h-4", "MIXED")];
+ expect(standingActionNote(["h-1"], items)).toContain(AGAINST);
+ expect(standingActionNote(["h-3", "h-2"], items)).toContain(AGAINST);
+ for (const ids of [["h-3"], ["h-4"], ["h-9"], []]) expect(standingActionNote(ids, items)).toBeNull();
+ expect(standingActionNote(["h-1"], undefined)).toBeNull();
+ expect(standingActionNote(["h-1"], items)).toContain("막지 않습니다");
+});
+
+const hypothesisResult = () => structuredClone(excerpt) as unknown as Record;
+const firstId = () => (hypothesisResult().portfolio as { hypotheses: { hypothesis_id: string }[] }).hypotheses[0].hypothesis_id;
+
+it("shows the recorded-results line on a hypothesis card, apart from its other status, and none when nothing was recorded", async () => {
+ await mount( );
+ const card = node.querySelectorAll(".hypothesis-card")[0];
+ expect(card.textContent).toContain("기록된 시험 결과: 다른 설명과 맞음(반복)");
+ expect(card.textContent).toContain("봉인된 시험");
+ expect(card.textContent).not.toMatch(/AGAINST_|FITS|MIXED/);
+ const second = node.querySelectorAll(".hypothesis-card")[1];
+ expect(second.textContent).not.toContain("기록된 시험 결과");
+});
+
+it("does not hold back the draft button when the hypothesis went against the recorded results", async () => {
+ await mount( );
+ expect(node.textContent).toContain(AGAINST);
+ const button = [...node.querySelectorAll("button")].find(b => b.textContent?.includes("행동 요청 준비")) as HTMLButtonElement;
+ expect(button.disabled).toBe(false);
+ await act(async () => button.click());
+ await act(async () => { await new Promise(done => setTimeout(done, 5)); });
+ expect(calls).toContain("action/draft/fromTest");
+ await act(async () => root!.unmount()); node.remove();
+ await mount( );
+ expect(node.textContent).not.toContain(AGAINST);
+});
+
+const planResult = () => {
+ const result = structuredClone(excerpt) as unknown as { action_plan: { plan_id?: string; alternatives: Record[] } };
+ result.action_plan.plan_id = "plan:1";
+ result.action_plan.alternatives[0].hypothesis_ids = ["h-1"];
+ result.action_plan.alternatives[0].execution_authority = "HUMAN_REQUIRED_R3";
+ result.action_plan.alternatives[1].hypothesis_ids = ["h-3"];
+ return result as unknown as Record;
+};
+
+it("marks the action card and the approval place, and still lets the approval be read as before", async () => {
+ await mount( );
+ const cards = [...node.querySelectorAll(".action-card")];
+ expect(cards.filter(card => card.textContent?.includes(AGAINST))).toHaveLength(1);
+ expect(node.querySelectorAll(".standing-approval-note")).toHaveLength(1);
+ expect(calls).toContain("action/plan/read"); // the approval is read as it always was
+});
+
+it("shows no note on the action cards when no record is known", () => {
+ const html = renderToStaticMarkup();
+ expect(html).not.toContain(AGAINST);
+});
diff --git a/apps/web/src/components/RefutationConditionsPanel.tsx b/apps/web/src/components/RefutationConditionsPanel.tsx
new file mode 100644
index 0000000..de07c7c
--- /dev/null
+++ b/apps/web/src/components/RefutationConditionsPanel.tsx
@@ -0,0 +1,45 @@
+import { Button, Callout, Classes, Dialog, TextArea } from "@blueprintjs/core";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useState } from "react";
+import { discriminationKey, recordRefutationConditions, type DiscriminationItem } from "../api/judgmentRecords";
+import { ELIMINATION_NOTE, eliminationLine } from "./judgmentRecordText";
+
+const MAX = 10;
+
+/** What a person wrote would show the hypothesis wrong, and the elimination mark its recorded test results amount to. */
+export function RefutationConditionsPanel({ projectId, hypothesisId, item }: { projectId: string; hypothesisId: string; item?: DiscriminationItem }) {
+ const client = useQueryClient();
+ const [open, setOpen] = useState(false);
+ const [text, setText] = useState("");
+ const [message, setMessage] = useState("");
+ const conditions = item?.refutation_conditions ?? [];
+ const mark = eliminationLine(item?.elimination ?? null);
+ const close = () => { setOpen(false); setMessage(""); };
+ const lines = () => text.split("\n").map(line => line.trim()).filter(Boolean);
+ const send = useMutation({
+ mutationFn: () => recordRefutationConditions(projectId, hypothesisId, lines()),
+ onSuccess: async () => { await client.invalidateQueries({ queryKey: discriminationKey(projectId) }); close(); },
+ });
+ const submit = () => {
+ if (lines().length > MAX) { setMessage("기각 조건은 " + MAX + "개까지 적을 수 있습니다."); return; }
+ setMessage(""); send.mutate();
+ };
+ return
+ {mark &&
{mark} {ELIMINATION_NOTE} }
+
기각 조건
+ {conditions.length === 0 ?
아직 적어 둔 기각 조건이 없습니다.
:
{conditions.map((line, index) => {line} )} }
+
{ setText(conditions.join("\n")); setOpen(true); }}>기각 조건 기록
+
+
+
이런 결과가 나오면 이 가설이 틀렸다고 볼 조건을 한 줄에 하나씩 적습니다. 새로 기록하면 가장 최근 목록이 보이고, 이전 기록은 남습니다.
+
+
+
+
;
+}
diff --git a/apps/web/src/components/ResearchDetailPane.tsx b/apps/web/src/components/ResearchDetailPane.tsx
index a591de1..e57d1bc 100644
--- a/apps/web/src/components/ResearchDetailPane.tsx
+++ b/apps/web/src/components/ResearchDetailPane.tsx
@@ -7,7 +7,7 @@ import { objectList, objectValue, textValue } from "../api/presentation";
import { ResearchHistoryWorkspace } from "./history/ResearchHistoryWorkspace";
import { ResultEvidencePanel } from "./ResultEvidencePanel";
import { HypothesisCompareLive } from "./HypothesisCompareLive";
-import { ActionCompare } from "./ActionCompare";
+import { ActionCompareLive } from "./ActionCompareLive";
import { ProjectMemoryPanel } from "./ProjectMemoryPanel";
import { hypothesisRows } from "./hypothesisView";
import { selectionKey } from "../api/historyModels";
@@ -32,7 +32,7 @@ export function ResearchDetailPane({detail,project,thread,onClose}: {
{detail.kind==="evidence"&&selectedEvidence}
{detail.kind==="hypotheses"&&
}
- {detail.kind==="actions"&&}
+ {detail.kind==="actions"&&}
{detail.kind==="reference"&&<>참고 후보와 공식 기준을 구분합니다. 값이 없으면 임의의 수치를 채우지 않습니다.
>}
{detail.kind==="outcome"&&<>실제 기록된 관측과 해석입니다. 실행 제안만 있는 경우 결과로 표시하지 않습니다.
>}
{detail.kind==="memory"&&}
diff --git a/apps/web/src/components/ResearchFollowupSummary.tsx b/apps/web/src/components/ResearchFollowupSummary.tsx
index e38ecc5..5bff588 100644
--- a/apps/web/src/components/ResearchFollowupSummary.tsx
+++ b/apps/web/src/components/ResearchFollowupSummary.tsx
@@ -3,6 +3,7 @@ import type { CoverageMatrix, CoverageMatrixRow, DecisionDelta, NextUserAction,
import { Disclosure } from "./Disclosure";
import { currentnessLabel } from "./history/historyPresentation";
import { DecisionDeltaView } from "./history/DecisionDeltaComparison";
+import { criterionText, isReadable, plainReason } from "./plainWording";
import { coverageTermLabel, nextActionLabel, statusLabel } from "./statusLabels";
function actionLabel(action?: NextUserAction | null): string {
@@ -61,15 +62,25 @@ function compactList(items: string[], empty: string) {
const stages = items.filter(item => STAGE_ID.test(item));
const requirements = items.filter(item => REQUIREMENT_ID.test(item));
const rest = items.filter(item => !STAGE_ID.test(item) && !REQUIREMENT_ID.test(item));
- const plain = rest.map(item => listItemLabel(item) ?? (CODE_LIKE.test(item) ? null : item)).filter((item): item is string => item !== null);
- const codes = rest.filter(item => listItemLabel(item) === null && CODE_LIKE.test(item));
- const technical = [...stages, ...requirements, ...codes];
+ const plain: string[] = [];
+ const codes: string[] = [];
+ const sentences: string[] = [];
+ const originals: string[] = [];
+ for (const item of rest) {
+ const label = listItemLabel(item) ?? plainReason(item);
+ if (label) { plain.push(label); if (plainReason(item)) originals.push(item); }
+ else if (CODE_LIKE.test(item)) codes.push(item);
+ else if (isReadable(item)) plain.push(item);
+ else sentences.push(item); // English the program wrote: shown only in the folded technical part
+ }
+ const technical = [...stages, ...requirements, ...codes, ...sentences, ...originals];
if (!plain.length && !technical.length) return {empty}
;
return <>
{plain.length > 0 && {plain.slice(0, 4).map(item => {item} )} }
{stages.length > 0 && 검토 단계 {stages.length}개
}
{requirements.length > 0 && 조건별 평가 {requirements.length}건
}
{codes.length > 0 && 기록된 상세 코드 {codes.length}개
}
+ {sentences.length > 0 && 기록된 상세 문구 {sentences.length}개
}
{technical.length > 0 && 기술 정보 {technical.join(" · ")} }
>;
}
@@ -119,10 +130,10 @@ export function ResearchFollowupSummary({
바뀐 판단 {deltaLabel(delta)}
{delta?.reason_state === "UNKNOWN_REASON" && 변경 이유는 저장된 기록에서 확인되지 않았습니다.
}
필요한 다음 행동 {actionLabel(nextAction)}
{nextAction && 기술 정보 {nextAction.label} }{nextAction?.requires_permission && 권한 결정이 필요한 행동입니다.
}
}
- {progress && (progress.recorded_checks.length > 0 || progress.remaining_gaps.length > 0 || progress.unknowns.length > 0) &&
+ {progress && (progress.recorded_checks.length > 0 || progress.remaining_gaps.length > 0 || progress.unknowns.length > 0) &&
기록된 확인 {compactList(progress.recorded_checks, "기록된 확인 항목이 없습니다.")}
- 남은 gap {compactList(progress.remaining_gaps, "남은 gap이 없습니다.")}
+ 남은 확인 사항 {compactList(progress.remaining_gaps, "남은 확인 사항이 없습니다.")}
확인 불가 {compactList(progress.unknowns, "확인 불가 항목이 없습니다.")}
{currentnessLabel(progress.currentness)}
@@ -131,14 +142,20 @@ export function ResearchFollowupSummary({
{coverage.availability === "UNAVAILABLE" ? 현재 권한으로 평가기준 행을 읽을 수 없습니다.
: rows.length === 0 ? 이 답변에 연결된 평가기준 행이 없습니다.
:
기준 기준 상태 판단
- {rows.map(row =>
- {coverageTermLabel(row.target)} {row.question}
+ {rows.map(row => {
+ const text = criterionText(row.target, row.question);
+ const term = coverageTermLabel(row.target);
+ const title = text.title ?? (term !== row.target ? term : null);
+ const technical = [...text.technical.filter(item => !(title !== null && item === row.target)), ...row.evidence_refs.slice(0, 3), ...row.reason_codes.slice(0, 3)];
+ return
+ {title !== null ?
{title} : text.question === null &&
평가 기준 }{text.question !== null &&
{text.question}
}
{coverageLabel(row.status)}
{[row.relation, row.validation].filter(term => term && term !== "NOT_ASSESSED").map(coverageTermLabel).join(" · ")
|| (row.blocker ? coverageTermLabel(row.blocker) : "추가 설명 없음")}
- {(row.evidence_refs.length > 0 || row.reason_codes.length > 0) && 기술 정보
- {[...row.evidence_refs.slice(0, 3), ...row.reason_codes.slice(0, 3)].join(" · ")} }
- )}
+ {technical.length > 0 && 기술 정보
+ {technical.join(" · ")} }
+ ;
+ })}
}
}
{delta && }
diff --git a/apps/web/src/components/ResearchResultCard.tsx b/apps/web/src/components/ResearchResultCard.tsx
index 3a04a77..06a1b31 100644
--- a/apps/web/src/components/ResearchResultCard.tsx
+++ b/apps/web/src/components/ResearchResultCard.tsx
@@ -15,8 +15,17 @@ import { ResearchFollowupSummary } from "./ResearchFollowupSummary";
import { AnswerBody } from "./AnswerBody";
import { AnswerHold } from "./AnswerHold";
import { ConfirmedFacts } from "./ConfirmedFacts";
+import { TraceOriginLine } from "./TraceOriginLine";
+import { HypothesisLinkNotice } from "./HypothesisLinkNotice";
+import type { HypothesisLink } from "./hypothesisLinkText";
+import type { ReasonDistribution } from "../api/hypothesisLink";
import { counterSearchLine } from "./hypothesisView";
+import { causeUnconfirmed } from "./hypothesisReview";
+import { CauseUnconfirmed } from "./HypothesisFields";
import { nextActionLabel } from "./statusLabels";
+import { ReadableList } from "./ReadableList";
+import { ExecutionHoldNotice } from "./ExecutionHoldNotice";
+import type { ExecutionHold } from "../api/research";
function selectedEvidenceCount(result: Record): number | undefined {
const direct = result.selected_evidence_count ?? objectValue(result.retrieval).selected_count;
@@ -24,11 +33,17 @@ function selectedEvidenceCount(result: Record): number | undefi
return Array.isArray(result.selected_evidence_refs) && result.selected_evidence_refs.length ? result.selected_evidence_refs.length : undefined;
}
-export function ResearchResultCard({ result, state, unavailable, error, failure, terminalReason, onDetail, onRetry, onResume, resumeCompleted, resuming, historySelection, currentness, progressSummary, coverageMatrix, nextUserAction, decisionDelta, usageLine }: {
+export function ResearchResultCard({ result, state, unavailable, error, failure, terminalReason, onDetail, onRetry, onResume, resumeCompleted, resuming, historySelection, currentness, progressSummary, coverageMatrix, nextUserAction, decisionDelta, usageLine, executionHold, projectId, holdCauseRef, onOpenTrace, hypothesisLinks, linkDistribution }: {
result: Record | null; state: string; unavailable?: string; error?:ExecutionError | null; failure?:ResearchFailure | null; terminalReason?:string | null; onDetail: (detail: ResearchDetail) => void; onRetry?: () => void;
/** Continue the interrupted run from where it stopped; only offered with the number of stages it had finished. */
onResume?: () => void; resumeCompleted?: number; resuming?: boolean; historySelection?: HistorySelection; currentness?: Currentness;
progressSummary?: UserProgressSummary | null; coverageMatrix?: CoverageMatrix | null; nextUserAction?: NextUserAction | null; decisionDelta?: DecisionDelta | null; usageLine?: string;
+ /** A held closed-loop test of this result, with the project and the revision that clearing it is recorded against. */
+ executionHold?: ExecutionHold | null; projectId?: string; holdCauseRef?: string;
+ /** Back to the trace row this investigation started from. */
+ onOpenTrace?: (row: { kind: string; id: string }) => void;
+ /** Which of this answer's hypotheses came from a trace row and whether that row's verdict has changed since. */
+ hypothesisLinks?: HypothesisLink[]; linkDistribution?: ReasonDistribution;
}) {
const hypotheses = objectList(objectValue(result?.portfolio).hypotheses);
const actions = objectList(objectValue(result?.action_plan).alternatives);
@@ -71,16 +86,19 @@ export function ResearchResultCard({ result, state, unavailable, error, failure,
: state === "STALE" && 현재 기준으로 다시 확인이 필요한 과거 답변입니다.
)}
{unavailable && {unavailable}
}
{result && content && !unavailable ? <>
+
{typeof result.answer === "string" && onDetail({kind:"evidence",result,historySelection,focusSpanId:spanId,citations})}/>}
{typeof result.answer_status === "string" && onDetail({kind:"evidence",result,historySelection,focusSpanId:spanId,citations})}/>}
next={(() => { const step = nextUserAction ?? progressSummary?.next_user_action; return step && step.action_type !== "NONE" ? nextActionLabel(step) : null; })()}/>}
+ {executionHold && projectId && }
onDetail({kind:"evidence",result,historySelection}) : undefined}/>
- {gaps.length > 0 && 아직 확인할 내용 {gaps.map((gap,index)=>{gap} )} }
+ {gaps.length > 0 && 아직 확인할 내용
}
{Object.hasOwn(objectValue(result.portfolio), "hypotheses") && 다른 설명과 확인할 점
- {hypotheses.length ? hypotheses.map((h,index)=>{textValue(h.statement)}
{textValue(h.uncertainty)&&
{textValue(h.uncertainty)} }
) : 현재 자료로 제안된 가설이 없습니다.
}
+ {causeUnconfirmed(result) && }
+ {hypotheses.length ? hypotheses.map((h,index)=>{textValue(h.statement)}
{textValue(h.uncertainty)&&
{textValue(h.uncertainty)} }{projectId&&
item.hypothesis_id===textValue(h.hypothesis_id))} all={hypothesisLinks} distribution={linkDistribution}/>} ) : 현재 자료로 제안된 가설이 없습니다.
}
}
{actions.length > 0 && 다음에 해볼 일 {actions.map((action,index)=>{textValue(action.specification)}
{textValue(action.expected_information_value)} {action.execution_authority === "HUMAN_REQUIRED_R3" &&
실행 제안입니다. 대상과 영향을 검토한 권한 결정이 필요합니다.
}
)} }
diff --git a/apps/web/src/components/ResultExample.tsx b/apps/web/src/components/ResultExample.tsx
index 89deddc..e1f82ba 100644
--- a/apps/web/src/components/ResultExample.tsx
+++ b/apps/web/src/components/ResultExample.tsx
@@ -2,6 +2,7 @@ import { Button, Callout } from "@blueprintjs/core";
import { objectList, objectValue, stringValues, textValue } from "../api/presentation";
import excerpt from "../api/fixtures/iris-judgment-excerpt.json";
import { ResearchResultCard } from "./ResearchResultCard";
+import { ReadableList } from "./ReadableList";
const result = {
answer: "현재 자료만으로는 보고 결과를 계획된 수용 조건과 직접 비교할 수 없습니다. 측정 환경과 평가 방법이 서로 달라 같은 조건의 결과인지 확인이 필요합니다.",
@@ -57,7 +58,7 @@ function LegacyResultCard() {
const actions=objectList(objectValue(result.action_plan).alternatives);
const gaps=stringValues(objectValue(result.assessment).missing_items);
return {result.answer}
- 아직 확인할 내용 {gaps.map((gap,index)=>{gap} )}
+ 아직 확인할 내용
검토할 설명 {hypotheses.map((item,index)=>{textValue(item.statement)}
{textValue(item.uncertainty)} )}
다음에 해볼 일 {actions.map((item,index)=>{textValue(item.specification)}
{textValue(item.expected_information_value)} )}
{["근거","가설 비교","참고값·조건","행동 비교","연구 지도","결과","기억","변경 이력"].map(label=>{label} )}
diff --git a/apps/web/src/components/ResultScreen.test.tsx b/apps/web/src/components/ResultScreen.test.tsx
index ba77513..c36b5e3 100644
--- a/apps/web/src/components/ResultScreen.test.tsx
+++ b/apps/web/src/components/ResultScreen.test.tsx
@@ -133,6 +133,10 @@ it("says how many finished stages a resumed run reused and counts automatic retr
const retried = resultUsageLine(status(entry({ auto_retries: 1, calls: 3 })), "op");
expect(retried).toBe("이 조사: 토큰 15 · 걸린 시간 3분 · 자동 재시도 1회(연결 끊김) · 추정 비용 미확인");
expect(resultUsageLine(status(entry({ auto_retries: 0 })), "op")).not.toContain("자동 재시도");
+ const widened = resultUsageLine(status(entry({ by_purpose: { MEMORY_QUERY_EXPANSION: { label: "기억 검색어 넓히기", calls: 1, total_tokens: 220 } } })), "op");
+ expect(widened).toBe("이 조사: 토큰 15 · 걸린 시간 3분 · 그중 기억 검색어 넓히기 토큰 220 · 추정 비용 미확인");
+ const unknown = resultUsageLine(status(entry({ by_purpose: { MEMORY_QUERY_EXPANSION: { label: "기억 검색어 넓히기", calls: 1, total_tokens: null } } })), "op");
+ expect(unknown).toContain("그중 기억 검색어 넓히기 토큰 미확인");
});
diff --git a/apps/web/src/components/SameHypothesis.test.tsx b/apps/web/src/components/SameHypothesis.test.tsx
new file mode 100644
index 0000000..0b89276
--- /dev/null
+++ b/apps/web/src/components/SameHypothesis.test.tsx
@@ -0,0 +1,130 @@
+// @vitest-environment jsdom
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act, type ReactElement } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import type { HypothesisLink } from "../api/hypothesisLink";
+import type { SameView } from "../api/hypothesisSame";
+import type { DiscriminationItem } from "../api/judgmentRecords";
+import type { LessonItem } from "../api/lessons";
+import { LessonRecall } from "./LessonRecall";
+import { SameHypothesis } from "./SameHypothesis";
+
+const calls = vi.hoisted(() => [] as { method: string; input: Record }[]);
+const fail = vi.hoisted(() => ({ message: "" }));
+vi.mock("../api/rpcClient", async importOriginal => ({ ...(await importOriginal()),
+ rpc: async (method: string, input: Record) => {
+ calls.push({ method, input });
+ if (fail.message) throw new Error(fail.message);
+ return { value: { groups: [], members: {}, events: [] }, state: "SUCCEEDED", operation_id: "op" };
+ } }));
+
+const BANNED = /통과|해결|승인 완료|검증 완료/;
+const LEAK = /SAME_|LINK|UNLINK|AGAINST_|FITS|MIXED|undefined|\bnull\b|hypothesis:/;
+const NOTE = "사람이 같은 가설이라고 표시한 것입니다. 이전 조사의 결과는 참고로만 보이고, 이 가설의 배제나 시험 순서에 더해지지 않습니다.";
+const link = (id: string, patch: Partial = {}): HypothesisLink => ({
+ hypothesis_id: id, hypothesis_revision_digest: "d".repeat(64), statement: `가설 문장 ${id}`, subject_kind: "CRITERION", subject_id: "C-1", subject_title: "비 조건 탐지율",
+ state: "CURRENT", change: null, link_state: "FAIL_COMPUTED", current_state: "FAIL_COMPUTED", current_verdict_revision: "r".repeat(64), recheck: null, ...patch,
+});
+const none: SameView = { groups: [], pairs: [], members: {}, events: [] };
+const grouped: SameView = { groups: [["h-1", "h-2"]], pairs: [["h-1", "h-2"]], members: {
+ "h-1": { statement: "가설 문장 h-1", investigated_at: "2026-10-01T01:02:03+00:00", subject_kind: "CRITERION", subject_id: "C-1" },
+ "h-2": { statement: "가설 문장 h-2", investigated_at: "2026-10-05T01:02:03+00:00", subject_kind: "CRITERION", subject_id: "C-1" } }, events: [] };
+const standing = (id: string, value: DiscriminationItem["standing"]): DiscriminationItem => ({ hypothesis_id: id, results: [], result_history_count: 0, refutation_conditions: [], conditions_history_count: 0, elimination: null, standing: value });
+
+let root: Root | undefined; let node: HTMLDivElement;
+beforeEach(() => { calls.length = 0; fail.message = ""; });
+afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; node?.remove(); document.body.innerHTML = ""; });
+async function mount(element: ReactElement) {
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+ node = document.createElement("div"); document.body.append(node); root = createRoot(node);
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
+ await act(async () => root!.render({element} ));
+}
+const settle = () => act(async () => { await new Promise(done => setTimeout(done, 5)); });
+const press = async (label: string) => { await act(async () => [...document.body.querySelectorAll("button")].find(b => b.textContent?.trim() === label)!.click()); await settle(); };
+
+it("offers to mark a hypothesis as the same and lists the other hypotheses of the project, the same row first", async () => {
+ const candidates = [link("h-9", { subject_id: "C-2", subject_title: "다른 줄" }), link("h-2"), link("h-1")];
+ await mount();
+ expect(node.textContent).toContain("다른 조사의 같은 가설로 표시");
+ expect(calls).toHaveLength(0);
+ await press("다른 조사의 같은 가설로 표시");
+ const options = [...node.querySelectorAll("input[type=radio]")].map(input => input.closest("label")?.textContent ?? "");
+ expect(options).toHaveLength(2);
+ expect(options.some(text => text.includes("h-1"))).toBe(false); // not itself
+ expect(options[0]).toContain("가설 문장 h-2"); // the same trace row comes first
+ expect(options[0]).toContain("비 조건 탐지율");
+ expect(options[1]).toContain("가설 문장 h-9");
+ expect(node.textContent).not.toMatch(LEAK);
+});
+
+it("sends the mark only when a person picks one, with the note they wrote", async () => {
+ await mount();
+ await press("다른 조사의 같은 가설로 표시");
+ expect([...node.querySelectorAll("button")].find(b => b.textContent?.trim() === "표시하기")!.disabled).toBe(true);
+ await act(async () => node.querySelector("input[type=radio]")!.click());
+ const memo = node.querySelector("textarea, input[type=text]") as HTMLTextAreaElement;
+ await act(async () => { Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(memo, "같은 줄을 다시 조사함"); memo.dispatchEvent(new Event("input", { bubbles: true })); });
+ await press("표시하기");
+ expect(calls[0]).toEqual({ method: "hypothesis/same/record", input: { project_id: "p", hypothesis_ids: ["h-1", "h-2"], action: "LINK", note: "같은 줄을 다시 조사함" } });
+});
+
+it("shows the other hypothesis of the group with its name, date and recorded results, and the note that it is only a reference", async () => {
+ await mount( );
+ const text = node.textContent!;
+ expect(text).toContain("가설 문장 h-2");
+ expect(text).toContain("기록된 시험 결과: 다른 설명과 맞음(반복)");
+ expect(text).toContain("조사");
+ expect(text).toContain(NOTE);
+ expect(text).not.toContain("가설 문장 h-1"); // only the others are listed
+ expect(text).not.toMatch(LEAK);
+ expect(text).not.toMatch(BANNED);
+});
+
+it("says no results were recorded when there are none, and takes the mark back only on the button", async () => {
+ await mount();
+ expect(node.textContent).toContain("기록된 시험 결과 없음");
+ expect(calls).toHaveLength(0);
+ await press("연결 끊기");
+ expect(calls[0]).toEqual({ method: "hypothesis/same/record", input: { project_id: "p", hypothesis_ids: ["h-1", "h-2"], action: "UNLINK", note: "" } });
+});
+
+it("offers to take a mark back only on a pair that was linked directly", async () => {
+ const chain: SameView = { groups: [["h-1", "h-2", "h-3"]], pairs: [["h-1", "h-2"], ["h-2", "h-3"]], events: [], members: {
+ "h-1": { statement: "가설 문장 h-1", investigated_at: "2026-10-01T01:02:03+00:00" },
+ "h-2": { statement: "가설 문장 h-2", investigated_at: "2026-10-02T01:02:03+00:00" },
+ "h-3": { statement: "가설 문장 h-3", investigated_at: "2026-10-03T01:02:03+00:00" } } };
+ await mount();
+ const items = [...node.querySelectorAll(".same-group li")];
+ expect(items).toHaveLength(2);
+ expect(items[0].textContent).toContain("가설 문장 h-2");
+ expect(items[0].textContent).toContain("연결 끊기"); // linked directly
+ expect(items[1].textContent).toContain("가설 문장 h-3");
+ expect(items[1].textContent).not.toContain("연결 끊기"); // only joined through h-2: that mark is taken back from h-2
+ expect(items[1].textContent).toContain("다른 가설을 거쳐 이어져 있습니다");
+ await press("연결 끊기");
+ expect(calls[0].input).toEqual({ project_id: "p", hypothesis_ids: ["h-1", "h-2"], action: "UNLINK", note: "" });
+});
+
+it("names a refusal in plain words", async () => {
+ fail.message = "SAME_ALREADY_LINKED";
+ await mount();
+ await press("다른 조사의 같은 가설로 표시");
+ await act(async () => node.querySelector("input[type=radio]")!.click());
+ await press("표시하기");
+ expect(node.textContent).toContain("이미 같은 가설로 표시되어 있습니다");
+ expect(node.textContent).not.toMatch(LEAK);
+});
+
+it("tells the lessons of the same hypothesis from another investigation apart in the recall", async () => {
+ const lesson = (id: string, hypothesisId: string, same?: string[]): LessonItem => ({
+ lesson_id: id, kind: "TEST_RESULT", outcome: "ALTERNATIVE", state: "SAME_CONDITION", subject_kind: "CRITERION", subject_id: "C-1",
+ hypothesis_id: hypothesisId, test_id: "t1", actor_id: "human:local-user", created_at: "2026-10-05T01:02:03+00:00",
+ detail: { observations: [{ test_id: "t1", matched: "ALTERNATIVE", observation: "관찰함", evidence_refs: [] }] }, ...(same ? { same_hypothesis_ids: same } : {}) });
+ await mount( );
+ const items = [...node.querySelectorAll("li")];
+ expect(items).toHaveLength(2);
+ expect(items.filter(item => item.textContent?.includes("다른 조사의 같은 가설"))).toHaveLength(1);
+ expect(items.find(item => item.textContent?.includes("다른 조사의 같은 가설"))!.textContent).toContain("관찰함");
+});
diff --git a/apps/web/src/components/SameHypothesis.tsx b/apps/web/src/components/SameHypothesis.tsx
new file mode 100644
index 0000000..04e3581
--- /dev/null
+++ b/apps/web/src/components/SameHypothesis.tsx
@@ -0,0 +1,54 @@
+import { Button, Callout, Radio, RadioGroup, Tag, TextArea } from "@blueprintjs/core";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useState } from "react";
+import type { HypothesisLink } from "../api/hypothesisLink";
+import { recordSame, sameKey, type SameView } from "../api/hypothesisSame";
+import type { DiscriminationItem } from "../api/judgmentRecords";
+import { lessonsKey } from "../api/lessons";
+import { standingLine } from "./judgmentRecordText";
+import { candidateLabel, memberLine, NO_RESULTS, SAME_BUTTON, SAME_NOTE, sameCandidates, sameRefusal } from "./sameHypothesisText";
+
+/**
+ * Hypotheses of other investigations that a person marked as the same as this one, and the controls to mark or take back.
+ * The earlier results are shown as a reference; they are not added to this hypothesis's elimination or to the test order.
+ */
+export function SameHypothesis({ projectId, hypothesisId, same, candidates, discrimination }: {
+ projectId: string; hypothesisId: string; same: SameView | undefined; candidates: HypothesisLink[] | undefined; discrimination?: DiscriminationItem[];
+}) {
+ const client = useQueryClient();
+ const [picking, setPicking] = useState(false);
+ const [choice, setChoice] = useState("");
+ const [note, setNote] = useState("");
+ const group = same?.groups.find(item => item.includes(hypothesisId)) ?? [];
+ const others = group.filter(id => id !== hypothesisId);
+ // A mark is taken back from the pair it was made on; a hypothesis joined only through a third one has no mark of its own here.
+ const direct = (id: string) => (same?.pairs ?? []).some(pair => pair.length === 2 && pair.includes(hypothesisId) && pair.includes(id));
+ const options = sameCandidates(hypothesisId, candidates, same);
+ const reset = () => { setPicking(false); setChoice(""); setNote(""); };
+ const send = useMutation({
+ mutationFn: (input: { other: string; action: "LINK" | "UNLINK"; memo: string }) => recordSame(projectId, [hypothesisId, input.other], input.action, input.memo),
+ onSuccess: async () => { await Promise.all([sameKey(projectId), lessonsKey(projectId)].map(queryKey => client.invalidateQueries({ queryKey }))); reset(); },
+ });
+ const results = (id: string) => standingLine(discrimination?.find(item => item.hypothesis_id === id)?.standing) ?? NO_RESULTS;
+ return
+ {others.length > 0 &&
+
같은 가설로 표시된 다른 조사
+
{others.map(id => {same && memberLine(same, id)}{results(id)}
+ {direct(id)
+ ? <>{" "} send.mutate({ other: id, action: "UNLINK", memo: "" })}>연결 끊기 >
+ : 다른 가설을 거쳐 이어져 있습니다. } )}
+
{SAME_NOTE}
+
}
+ {!picking && options.length > 0 &&
setPicking(true)}>{SAME_BUTTON} }
+ {picking &&
+ setChoice(event.currentTarget.value)}>
+ {options.map(item => )}
+
+ 메모 (선택)
+
+ send.mutate({ other: choice, action: "LINK", memo: note })}>표시하기
+ {" "}닫기
+
}
+ {send.error &&
{sameRefusal(send.error.message)} }
+
;
+}
diff --git a/apps/web/src/components/TestActionDraft.test.tsx b/apps/web/src/components/TestActionDraft.test.tsx
new file mode 100644
index 0000000..85e8e7f
--- /dev/null
+++ b/apps/web/src/components/TestActionDraft.test.tsx
@@ -0,0 +1,150 @@
+// @vitest-environment jsdom
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { TestActionDraft } from "./TestActionDraft";
+
+const calls = vi.hoisted(() => [] as { method: string; input: Record }[]);
+const reply = vi.hoisted(() => ({ action: { risk_tier: "R3", policy_state: "POLICY_UNDEFINED", proposal_state: "DRAFT" } as Record, fail: "" }));
+vi.mock("../api/rpcClient", async importOriginal => ({ ...(await importOriginal()),
+ rpc: async (method: string, input: Record) => {
+ calls.push({ method, input });
+ if (reply.fail) throw new Error(reply.fail);
+ return { value: { action: { action_id: "a1", ...reply.action } }, state: "SUCCEEDED", operation_id: "op" };
+ } }));
+
+const BANNED = /승인됨|승인 완료|통과|해결|종결|면제/;
+const LEAK = /POLICY_|DRAFT|HYPOTHESIS_BASIS|R4_|undefined|\bnull\b/;
+let root: Root | undefined; let container: HTMLDivElement;
+beforeEach(() => { calls.length = 0; reply.fail = ""; reply.action = { risk_tier: "R3", policy_state: "POLICY_UNDEFINED", proposal_state: "DRAFT" }; });
+afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; container?.remove(); });
+async function mount() {
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+ container = document.createElement("div"); document.body.append(container); root = createRoot(container);
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
+ await act(async () => root!.render( ));
+}
+const press = async () => {
+ await act(async () => [...container.querySelectorAll("button")].find(item => item.textContent?.includes("행동 요청 준비"))!.click());
+ await act(async () => { await new Promise(done => setTimeout(done, 0)); });
+};
+
+it("offers the button and says preparing a request is not an approval and asks no model", async () => {
+ await mount();
+ expect(container.textContent).toContain("이 시험으로 행동 요청 준비");
+ expect(container.textContent).toContain("요청을 준비할 뿐 승인이 아닙니다");
+ expect(container.textContent).toContain("모델을 부르지 않습니다");
+ expect(calls).toHaveLength(0);
+});
+
+it("sends which test it is and an empty declaration, never a risk, then says the request is ready and still waits for an approval", async () => {
+ await mount();
+ await press();
+ expect(calls).toEqual([{ method: "action/draft/fromTest",
+ input: { project_id: "p", hypothesis_id: "h1", test_id: "t1", effect_declaration: { effect_completeness_confirmed: false } } }]);
+ const text = container.textContent!;
+ expect(text).toContain("행동 요청을 준비했습니다");
+ expect(text).toContain("아직 승인이 아닙니다");
+ expect(text).toContain("높음 · R3");
+ expect(text).toContain("효과를 아직 선언하지 않아 보호된 행동으로 분류했습니다");
+ expect(text).not.toMatch(BANNED);
+ expect(text).not.toMatch(LEAK);
+});
+
+it("tells a declared protected action from a read-only one", async () => {
+ reply.action = { risk_tier: "R3", policy_state: "APPROVAL_REQUIRED", proposal_state: "DRAFT" };
+ await mount();
+ await press();
+ expect(container.textContent).toContain("밖으로 쓰거나 기준을 바꾸는 행동이라 사람의 승인이 필요합니다");
+ reply.action = { risk_tier: "R0", policy_state: "AUTO_ALLOWED", proposal_state: "DRAFT" };
+ await act(async () => root!.unmount()); container.remove(); root = undefined;
+ await mount();
+ await press();
+ expect(container.textContent).toContain("위험 낮음 · R0");
+ expect(container.textContent).not.toContain("사람의 승인이 필요합니다");
+});
+
+it("explains a refusal because the hypothesis stands on an old basis, and any other refusal in plain words", async () => {
+ reply.fail = "HYPOTHESIS_BASIS_CHANGED";
+ await mount();
+ await press();
+ expect(container.textContent).toContain("이 가설은 이전 근거 기준이라 행동 요청을 만들 수 없습니다");
+ expect(container.textContent).not.toMatch(LEAK);
+ reply.fail = "TEST_NOT_FOUND";
+ await press();
+ expect(container.textContent).toContain("행동 요청을 준비하지 못했습니다");
+ expect(container.textContent).not.toMatch(LEAK);
+});
+const open = async () => act(async () => [...container.querySelectorAll("button")].find(item => item.textContent?.includes("이 행동이 하는 일 적기"))!.click());
+const tick = (label: string) => [...container.querySelectorAll("label")].find(item => item.textContent?.includes(label))!.querySelector("input")!;
+const choose = async (label: string) => act(async () => tick(label).click());
+
+it("keeps the effect list folded and every box unchecked, and the model fills nothing in", async () => {
+ await mount();
+ await open();
+ const boxes = [...container.querySelectorAll("input[type=checkbox]")] as HTMLInputElement[];
+ expect(boxes.length).toBe(12);
+ expect(boxes.every(box => !box.checked)).toBe(true);
+ expect(container.textContent).toContain("외부 시스템에 씀");
+ expect(container.textContent).toContain("이 행동이 하는 일을 빠짐없이 적었습니다");
+ expect(container.textContent).not.toMatch(LEAK);
+});
+
+it("sends the one effect that was chosen as true and says the list is not complete", async () => {
+ await mount();
+ await open();
+ await choose("면제를 내림");
+ await press();
+ expect(calls[0].input.effect_declaration).toEqual({ grants_waiver: true, effect_completeness_confirmed: false });
+});
+
+it("warns before sending when a forbidden effect is chosen, and still lets the request be made", async () => {
+ await mount();
+ await open();
+ expect(container.textContent).not.toContain("실행할 수 없는 행동으로 분류됩니다");
+ await choose("공식 성과 지표를 바꿈");
+ expect(container.textContent).toContain("이 효과는 THOTH에서 실행할 수 없는 행동으로 분류됩니다");
+ await press();
+ expect(calls).toHaveLength(1);
+});
+
+it("names every effect as true or false when the person says the list is complete", async () => {
+ await mount();
+ await open();
+ await choose("이 행동이 하는 일을 빠짐없이 적었습니다");
+ await press();
+ const declared = calls[0].input.effect_declaration as Record;
+ expect(Object.keys(declared)).toHaveLength(12);
+ expect(Object.values(declared).filter(Boolean)).toHaveLength(1);
+ expect(declared.effect_completeness_confirmed).toBe(true);
+ expect(declared.external_write).toBe(false);
+});
+
+it("shows the rule's answer in plain words and does not work the risk out on its own", async () => {
+ reply.action = { risk_tier: "R3", policy_state: "APPROVAL_REQUIRED", proposal_state: "DRAFT", required_roles: ["project-owner", "safety-owner"] };
+ await mount();
+ await open();
+ await choose("외부 시스템에 씀");
+ await press();
+ const text = container.textContent!;
+ expect(text).toContain("규칙상: 승인 필요 · 필요한 사람: 프로젝트 책임자, 안전 책임자");
+ expect(text).not.toMatch(LEAK);
+ expect(text).not.toMatch(/project-owner|safety-owner/);
+ const result = [...container.querySelectorAll("[role=status]")].find(node => node.textContent?.includes("행동 요청을 준비했습니다"));
+ expect(result).toBeDefined();
+ expect(result!.textContent).not.toMatch(BANNED);
+});
+
+it("says who must look at an undeclared draft, and shows no person when the rule asks for none", async () => {
+ reply.action = { risk_tier: "R3", policy_state: "POLICY_UNDEFINED", proposal_state: "DRAFT", required_roles: ["effect-owner"] };
+ await mount();
+ await press();
+ expect(container.textContent).toContain("규칙상: 효과를 확인해야 함 · 필요한 사람: 효과 확인 책임자");
+ reply.action = { risk_tier: "R0", policy_state: "AUTO_ALLOWED", proposal_state: "DRAFT", required_roles: [] };
+ await act(async () => root!.unmount()); container.remove(); root = undefined;
+ await mount();
+ await press();
+ expect(container.textContent).toContain("규칙상: 별도 승인 없이 가능");
+ expect(container.textContent).not.toContain("필요한 사람");
+});
diff --git a/apps/web/src/components/TestActionDraft.tsx b/apps/web/src/components/TestActionDraft.tsx
new file mode 100644
index 0000000..dd01cae
--- /dev/null
+++ b/apps/web/src/components/TestActionDraft.tsx
@@ -0,0 +1,34 @@
+import { Button, Callout } from "@blueprintjs/core";
+import { useMutation } from "@tanstack/react-query";
+import { useState } from "react";
+import { draftActionFromTest } from "../api/actionDraft";
+import { effectDeclaration } from "./effectDeclaration";
+import { EffectDeclarationFields } from "./EffectDeclarationFields";
+import type { DiscriminationItem } from "../api/judgmentRecords";
+import { standingActionNote } from "./judgmentRecordText";
+import { draftReadyLines, draftRefusal } from "./testActionDraftText";
+
+/**
+ * "Prepare an action request from this test." A person presses it; no model is asked and nothing is approved.
+ * The person may say what the request would do (nothing is ticked for them); the server decides the risk from it, so a request
+ * with no declared effect comes back as protected, and the screen shows the answer the rules gave.
+ */
+export function TestActionDraft({ projectId, hypothesisId, testId, standing }: {
+ projectId: string; hypothesisId: string; testId: string;
+ /** Where the recorded test results leave this hypothesis; a note is shown when they went against it, and nothing is held back. */
+ standing?: DiscriminationItem["standing"];
+}) {
+ const against = standingActionNote([hypothesisId], [{ hypothesis_id: hypothesisId, standing } as DiscriminationItem]);
+ const [chosen, setChosen] = useState>(new Set());
+ const [complete, setComplete] = useState(false);
+ const send = useMutation({ mutationFn: () => draftActionFromTest(projectId, hypothesisId, testId, effectDeclaration(chosen, complete)) });
+ const choose = (key: string, on: boolean) => setChosen(previous => { const next = new Set(previous); if (on) next.add(key); else next.delete(key); return next; });
+ return
+
+ {against && {against} }
+ send.mutate()}>이 시험으로 행동 요청 준비
+ 요청을 준비할 뿐 승인이 아닙니다. 모델을 부르지 않습니다.
+ {send.error && {draftRefusal(send.error.message)} }
+ {send.data && {draftReadyLines(send.data.action).map((line, index) => {line}
)} }
+ ;
+}
diff --git a/apps/web/src/components/TestOrder.test.tsx b/apps/web/src/components/TestOrder.test.tsx
new file mode 100644
index 0000000..cc5d78f
--- /dev/null
+++ b/apps/web/src/components/TestOrder.test.tsx
@@ -0,0 +1,117 @@
+// @vitest-environment jsdom
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { renderToStaticMarkup } from "react-dom/server";
+import { afterEach, expect, it } from "vitest";
+import excerpt from "../api/fixtures/iris-judgment-excerpt.json";
+import type { DiscriminationItem } from "../api/judgmentRecords";
+import { ActionCompare } from "./ActionCompare";
+import { HypothesisCompare } from "./HypothesisCompare";
+import { hypothesisRows } from "./hypothesisView";
+import { TestOrderSection } from "./TestOrderSection";
+
+const BANNED = /확률|순위|\d\s*점|\d\s*%|통과|해결|승인 완료/;
+const LEAK = /READY|UNCOUNTABLE|COST_UNKNOWN|NOT_EXECUTABLE|RESULT_RECORDED|HYPOTHESIS_ELIMINATED|SINGLE|REPEATED|undefined|\bnull\b/;
+
+type Test = Record;
+const test = (id: string, procedure: string, extra: Test = {}): Test => ({ test_id: id, procedure_candidate: procedure, expected_if_true: "맞으면", expected_if_alternative: "다르면", reversibility: "FULL", ...extra });
+// Three hypotheses; the first has three tests, the second one, the third none.
+const result = (tests: Test[][]) => {
+ const copy = structuredClone(excerpt) as unknown as { portfolio: { hypotheses: Record[] } };
+ copy.portfolio.hypotheses.forEach(item => { item.discriminating_tests = []; });
+ tests.forEach((list, at) => { copy.portfolio.hypotheses[at].discriminating_tests = list; });
+ return copy as unknown as Record;
+};
+const ids = () => (excerpt as unknown as { portfolio: { hypotheses: { hypothesis_id: string }[] } }).portfolio.hypotheses.map(item => item.hypothesis_id);
+const render = (value: Record, discrimination?: DiscriminationItem[]) =>
+ renderToStaticMarkup();
+const order = (html: string, names: string[]) => names.map(name => html.indexOf(name));
+const sorted = (positions: number[]) => positions.every((value, at) => value >= 0 && (at === 0 || value > positions[at - 1]));
+
+it("lists a test with a known cost before one whose cost is unknown, and says so without a number", () => {
+ const html = render(result([[test("t1", "비용을 모르는 시험"), test("t2", "싼 시험", { estimated_cost: "2" }), test("t3", "비싼 시험", { estimated_cost: "9" })]]));
+ expect(sorted(order(html, ["싼 시험", "비싼 시험", "비용을 모르는 시험"]))).toBe(true);
+ expect(html).toContain("비용 미상");
+ expect(html).toContain("후보를 하나만 줄이는 시험으로 셉니다");
+ expect(html).not.toMatch(BANNED);
+ expect(html).not.toMatch(LEAK);
+});
+
+it("keeps what was made first when everything else is equal, and marks the tie", () => {
+ const html = render(result([[test("t1", "먼저 만든 시험", { estimated_cost: "2" }), test("t2", "나중에 만든 시험", { estimated_cost: "2" })]]));
+ expect(sorted(order(html, ["먼저 만든 시험", "나중에 만든 시험"]))).toBe(true);
+ expect(html).toContain("동점");
+});
+
+it("puts a test that can be undone before one that cannot, at the same cost", () => {
+ const html = render(result([[test("t1", "되돌릴 수 없는 시험", { estimated_cost: "2", reversibility: "NONE" }), test("t2", "되돌릴 수 있는 시험", { estimated_cost: "2" })]]));
+ expect(sorted(order(html, ["되돌릴 수 있는 시험", "되돌릴 수 없는 시험"]))).toBe(true);
+});
+
+it("never reads the risk tier the model wrote on a test: the cheaper test goes first whatever the model called it", () => {
+ const html = render(result([[test("t1", "모델이 낮다고 쓴 비싼 시험", { risk_tier: "R0", estimated_cost: "9" }), test("t2", "모델이 높다고 쓴 싼 시험", { risk_tier: "R3", estimated_cost: "2" })]]));
+ expect(sorted(order(html, ["모델이 높다고 쓴 싼 시험", "모델이 낮다고 쓴 비싼 시험"]))).toBe(true);
+});
+
+it("names a test that cannot be run now apart", () => {
+ const html = render(result([[test("t1", "지금 못 하는 시험", { executable: false }), test("t2", "할 수 있는 시험")]]));
+ expect(html).toContain("지금은 할 수 없는 시험");
+ expect(sorted(order(html, ["할 수 있는 시험", "지금 못 하는 시험"]))).toBe(true);
+});
+
+it("sets apart a test whose result is already recorded, and a hypothesis eliminated by repeated results, and only marks one eliminated once", () => {
+ const [first, second] = ids();
+ const html = render(result([[test("t1", "이미 한 시험"), test("t2", "아직 안 한 시험")], [test("t3", "배제된 가설의 시험")]]), [
+ { hypothesis_id: first, elimination: "SINGLE", result_history_count: 1, refutation_conditions: [], conditions_history_count: 0,
+ results: [{ event_id: "e", hypothesis_id: first, test_id: "t1", observation: "봤다", matched: "ALTERNATIVE", evidence_refs: [], actor_id: "human:local-user", created_at: "2026-10-06T00:00:00+00:00" }] },
+ { hypothesis_id: second, elimination: "REPEATED", result_history_count: 2, refutation_conditions: [], conditions_history_count: 0, results: [] },
+ ]);
+ expect(sorted(order(html, ["아직 안 한 시험", "이미 한 시험", "배제된 가설의 시험"]))).toBe(true);
+ expect(html).toContain("결과를 이미 기록한 시험");
+ expect(html).toContain("배제된 가설의 시험");
+ expect(html).toContain("다른 설명과 맞음"); // the recorded result is shown beside it
+ expect(html).toContain("배제(결과 1건)"); // marked, still counted
+ expect(html).not.toContain("배제(반복 확인)을 후보에서"); // (the group title says it in words)
+ expect(html).not.toMatch(LEAK);
+});
+
+it("says the order is a rule and not a score, that plausibility is not used, and that the unknown cause always stays", () => {
+ const html = render(result([[test("t1", "시험")]]));
+ expect(html).toContain("점수가 아니라 규칙");
+ expect(html).toContain("그럴듯함은 순서에 쓰지 않습니다");
+ expect(html).toContain("원인을 아직 모른다는 칸은 어떤 결과에서도 남습니다");
+ expect(html).not.toMatch(/\d\s*%|확률/);
+});
+
+it("shows nothing when no hypothesis has a test", () => {
+ expect(render(result([]))).toBe("");
+});
+
+let root: Root | undefined; let node: HTMLDivElement;
+afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; node?.remove(); });
+it("is the first thing on the hypothesis comparison, above the cards", async () => {
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+ node = document.createElement("div"); document.body.append(node); root = createRoot(node);
+ await act(async () => root!.render( ));
+ const section = node.querySelector(".test-order")!;
+ expect(section).toBeTruthy();
+ expect(section.compareDocumentPosition(node.querySelector(".hypothesis-card")!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+});
+
+const action = (id: string, specification: string, extra: Record = {}) => ({ action_id: id, action_family: "READ_ONLY_ANALYSIS", specification, expected_information_value: "정보",
+ risk_tier: "R0", execution_authority: "AUTO_R0", reversibility: "FULL", state: "AUTO_ALLOWED", source_refs: [], missing_evidence: [], estimated_cost: null, estimated_seconds: null, ...extra });
+const compare = (alternatives: Record[]) => {
+ const copy = structuredClone(excerpt) as unknown as { action_plan: { alternatives: unknown[] } };
+ copy.action_plan.alternatives = alternatives;
+ return renderToStaticMarkup(}/>);
+};
+
+it("orders the action cards by the same rule and moves a forbidden-level one out of the order, still showing it", () => {
+ const html = compare([action("a1", "보호된 싼 행동", { risk_tier: "R3", execution_authority: "HUMAN_REQUIRED_R3", estimated_cost: "1" }), action("a2", "금지 수준 행동", { risk_tier: "R4", execution_authority: "PROHIBITED_R4" }),
+ action("a3", "낮은 위험 비싼 행동", { estimated_cost: "8" }), action("a4", "낮은 위험 비용 미상 행동")]);
+ const cards = html.split('[]; discrimination?: DiscriminationItem[] }) {
+ const { input, labels } = testOrderInput(rows, actions, discrimination);
+ if (input.items.length === 0) return null;
+ const ranked = rankItems(input);
+ return
+ 다음에 해 볼 시험의 순서
+ {ORDER_NOTE}
+ {ranked.excluded.length > 0 && {excludedTestsLine(ranked.excluded.length)}
}
+ {ranked.groups.map(group =>
+
{groupTitle(group.group, "TEST")}
+
{groupHint(group.group)}
+
{group.items.map(entry => {
+ const label = labels.get(entry.id)!;
+ return
+ {label.procedure || "시험 내용이 기록되지 않았습니다"}
+ 가설: {label.statement}
+ {reductionLine(entry, ranked.candidates)}
+ {entry.costUnknown && 비용 미상 }{label.cost !== null && 비용 추정 {label.cost} }
+ {entry.tie && 동점 · 만든 순서 }
+ {entry.eliminatedOnce && {eliminationLine("SINGLE")} }
+ {label.recorded && 기록된 결과: {matchLabel(label.recorded.matched)} }
+ ;
+ })}
+
)}
+ ;
+}
diff --git a/apps/web/src/components/TestResultRecorder.tsx b/apps/web/src/components/TestResultRecorder.tsx
new file mode 100644
index 0000000..4f18bc1
--- /dev/null
+++ b/apps/web/src/components/TestResultRecorder.tsx
@@ -0,0 +1,49 @@
+import { Button, Callout, Classes, Dialog, Radio, RadioGroup, TextArea } from "@blueprintjs/core";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useState } from "react";
+import { discriminationKey, recordTestResult, type MatchKind, type TestResult } from "../api/judgmentRecords";
+import { MATCH_KINDS, matchLabel } from "./judgmentRecordText";
+import { actorLabel, timeText } from "./traceText";
+
+/** The result a person recorded for one test (the latest counts), and the dialog to record or correct it. No model is asked. */
+export function TestResultRecorder({ projectId, hypothesisId, testId, recorded }: { projectId: string; hypothesisId: string; testId: string; recorded?: TestResult }) {
+ const client = useQueryClient();
+ const [open, setOpen] = useState(false);
+ const [matched, setMatched] = useState("");
+ const [observation, setObservation] = useState("");
+ const [references, setReferences] = useState("");
+ const [message, setMessage] = useState("");
+ const close = () => { setOpen(false); setMatched(""); setObservation(""); setReferences(""); setMessage(""); };
+ const send = useMutation({
+ mutationFn: () => recordTestResult(projectId, hypothesisId, testId, observation.trim(), matched as MatchKind, references.split("\n").map(line => line.trim()).filter(Boolean)),
+ onSuccess: async () => { await client.invalidateQueries({ queryKey: discriminationKey(projectId) }); close(); },
+ });
+ const submit = () => {
+ if (matched === "") { setMessage("어느 설명과 맞았는지 고르세요."); return; }
+ if (!observation.trim()) { setMessage("관찰한 내용을 적어 주세요."); return; }
+ setMessage(""); send.mutate();
+ };
+ return
+ {recorded &&
기록된 결과: {matchLabel(recorded.matched)} — {recorded.observation}
+ {actorLabel(recorded.actor_id)} · {timeText(recorded.created_at)}{recorded.evidence_refs.length > 0 ? " · 근거 " + recorded.evidence_refs.join(", ") : ""}
}
+
setOpen(true)}>{recorded ? "결과 다시 기록" : "결과 기록"}
+
+
+
사람이 한 시험의 결과를 적어 둡니다. 가설은 바뀌지 않고, 기록은 지워지지 않습니다. 같은 시험을 다시 기록하면 가장 최근 기록이 반영됩니다.
+
setMatched(event.currentTarget.value as MatchKind)}>
+ {MATCH_KINDS.map(item => )}
+
+
관찰한 내용 (필수)
+
+
근거 참조 (선택, 한 줄에 하나)
+
+ {message &&
{message} }
+ {send.error &&
기록하지 못했습니다. 잠시 뒤 다시 시도해 주세요. }
+
+
+
+
;
+}
diff --git a/apps/web/src/components/ThothBlueprint.test.tsx b/apps/web/src/components/ThothBlueprint.test.tsx
new file mode 100644
index 0000000..3634729
--- /dev/null
+++ b/apps/web/src/components/ThothBlueprint.test.tsx
@@ -0,0 +1,29 @@
+// @vitest-environment jsdom
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, expect, it } from "vitest";
+import { Dialog } from "@blueprintjs/core";
+import { ThothBlueprint } from "./ThothBlueprint";
+import { TraceConfirmDialog } from "./TraceConfirmDialog";
+
+let root: Root | undefined; let container: HTMLDivElement;
+afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; container?.remove(); document.body.innerHTML = ""; });
+const mount = async (node: React.ReactNode) => {
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+ container = document.createElement("div"); document.body.append(container); root = createRoot(container);
+ await act(async () => root!.render(node));
+ await act(async () => { await new Promise(r => setTimeout(r, 20)); });
+};
+
+it("draws an open dialog in the dark theme", async () => {
+ await mount(내용
);
+ const portal = document.body.querySelector(".bp6-portal");
+ expect(portal?.classList.contains("bp6-dark")).toBe(true);
+ expect(portal?.textContent).toContain("내용");
+});
+
+it("also covers the dialogs of this app, such as the trace confirmation", async () => {
+ const verdict = { subject_kind: "REQUIREMENT", subject_id: "R", state: "PASS" } as never;
+ await mount( undefined} onSubmit={() => undefined}/> );
+ expect(document.body.querySelector(".bp6-portal.bp6-dark .bp6-dialog")).not.toBeNull();
+});
diff --git a/apps/web/src/components/ThothBlueprint.tsx b/apps/web/src/components/ThothBlueprint.tsx
new file mode 100644
index 0000000..4f763e3
--- /dev/null
+++ b/apps/web/src/components/ThothBlueprint.tsx
@@ -0,0 +1,7 @@
+import { BlueprintProvider, Classes } from "@blueprintjs/core";
+import type { ReactNode } from "react";
+
+/** The app is dark, but Blueprint draws dialogs, popovers and toasts in a portal outside it. This puts the dark theme on every portal. */
+export function ThothBlueprint({ children }: { children: ReactNode }) {
+ return {children} ;
+}
diff --git a/apps/web/src/components/TraceClosure.test.tsx b/apps/web/src/components/TraceClosure.test.tsx
new file mode 100644
index 0000000..cbce425
--- /dev/null
+++ b/apps/web/src/components/TraceClosure.test.tsx
@@ -0,0 +1,121 @@
+// @vitest-environment jsdom
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { TracePage } from "./TracePage";
+import demo from "./traceDemo.fixture.json";
+
+type Json = Record;
+const state = vi.hoisted(() => ({ calls: [] as { method: string; input: Json }[], closures: [] as Json[] }));
+vi.mock("../api/rpcClient", async importOriginal => ({ ...(await importOriginal()),
+ rpc: async (method: string, input: Json) => {
+ state.calls.push({ method, input });
+ const done = (value: unknown) => ({ value, state: "SUCCEEDED", operation_id: "" });
+ if (method === "trace/read") return done(demo.phase1);
+ if (method === "trace/closure/list") return done({ closures: state.closures });
+ if (method === "trace/closure/record") return done({ event: {}, closures: state.closures });
+ throw new Error("unexpected " + method);
+ } }));
+
+const BANNED = /통과|해결|승인 완료|검증 완료/;
+const RAW = ["FIX_APPLIED", "HUMAN_CLOSED", "WAIVER_RECORDED", "CONDITION_CHANGED", "HOLD_NO_RESULT", "FAIL_COMPUTED", "effect_confirmed"];
+let root: Root | undefined; let container: HTMLDivElement;
+const settle = async () => { for (let i = 0; i < 10; i++) await act(async () => { await new Promise(r => setTimeout(r, 10)); }); };
+async function mount() {
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+ container = document.createElement("div"); document.body.append(container); root = createRoot(container);
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
+ await act(async () => root!.render( ));
+ await settle();
+}
+beforeEach(() => { state.calls = []; state.closures = []; });
+afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; container?.remove(); document.body.innerHTML = ""; });
+
+const rowOf = (text: string) => [...container.querySelectorAll("tr")].find(tr => tr.querySelector("th")?.textContent?.includes(text)) as HTMLElement;
+const button = (label: string, scope: ParentNode = document.body) => [...scope.querySelectorAll("button")].find(b => (b.getAttribute("aria-label") ?? b.textContent?.trim()) === label) as HTMLButtonElement | undefined;
+const click = async (element: Element | undefined | null) => { expect(element).toBeTruthy(); await act(async () => (element as HTMLElement).click()); await settle(); };
+const dialog = () => document.body.querySelector('[role="dialog"]')!;
+const choose = async (label: string) => click([...dialog().querySelectorAll("label")].find(l => l.textContent?.includes(label))!.querySelector("input"));
+const write = async (label: string, text: string) => {
+ const area = dialog().querySelector('[aria-label="' + label + '"]')!;
+ const proto = area instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
+ await act(async () => { Object.getOwnPropertyDescriptor(proto, "value")!.set!.call(area, text); area.dispatchEvent(new Event("input", { bubbles: true })); });
+};
+const verdictOf = (id: string) => (demo.phase1.verdicts as { subject_id: string; revision_digest: string }[]).find(item => item.subject_id === id)!;
+const closure = (kind: string, extra: Json = {}, row: Json = {}): Json => ({ subject_kind: "CRITERION", subject_id: "SYN-C-DET-RAIN", current_state: "HOLD_NO_RESULT", effect_confirmed: false,
+ effect_verdict_revision: null, verdict_changed_since: false, ...row, events: [{ event_id: "e1", subject_kind: "CRITERION", subject_id: "SYN-C-DET-RAIN", kind, basis_ref: "ECN-12", note: "", scope: null,
+ verdict_revision: verdictOf("SYN-C-DET-RAIN").revision_digest, verdict_digest: "d", verdict_state: "HOLD_NO_RESULT", actor_id: "human:local-user", created_at: "2026-10-06T01:02:03+00:00", ...extra }] });
+
+it("offers to record a closure on a row that is not met, and not on a met one", async () => {
+ await mount();
+ expect(button("Detection rate in rain 처분 기록 남기기", rowOf("Detection rate in rain"))).toBeTruthy();
+ expect(button("Detection rate in fog 처분 기록 남기기", rowOf("Detection rate in fog"))).toBeTruthy();
+ expect(rowOf("Detection rate in dry weather").querySelector('[aria-label$="처분 기록 남기기"]')).toBeNull();
+ expect(state.calls.map(call => call.method)).toEqual(["trace/read", "trace/closure/list"]);
+});
+
+it("shows a closure beside the verdict and leaves the verdict's words and colour as they were", async () => {
+ await mount();
+ const tagBefore = rowOf("Detection rate in rain").querySelector(".bp6-tag")!;
+ const before = [tagBefore.textContent, tagBefore.className];
+ await act(async () => root!.unmount()); container.remove(); root = undefined;
+ state.closures = [closure("WAIVER_RECORDED")];
+ await mount();
+ const rain = rowOf("Detection rate in rain");
+ const tag = rain.querySelector(".bp6-tag")!;
+ expect([tag.textContent, tag.className]).toEqual(before); // the verdict's tag is untouched
+ const mark = rain.querySelector(".trace-closure")!;
+ expect(mark.textContent).toContain("편차·면제 승인(원 기준 미충족 유지)");
+ expect(mark.textContent).toContain("원래 판정: 보류 · 결과 없음");
+ expect(mark.textContent).toContain("근거 문서: ECN-12");
+ expect(tag.contains(mark)).toBe(false);
+ expect(rowOf("Detection rate in fog").querySelector(".trace-closure")).toBeNull();
+});
+
+it("calls the effect confirmed only when the server says the rules confirmed it", async () => {
+ state.closures = [closure("FIX_APPLIED")];
+ await mount();
+ expect(rowOf("Detection rate in rain").querySelector(".trace-closure")!.textContent).toContain("수정 반영됨(효과 미확인)");
+ expect(container.textContent).not.toContain("효과 확인됨");
+ await act(async () => root!.unmount()); container.remove(); root = undefined;
+ state.closures = [closure("FIX_APPLIED", {}, { effect_confirmed: true, effect_verdict_revision: "x" })];
+ await mount();
+ expect(rowOf("Detection rate in rain").querySelector(".trace-closure")!.textContent).toContain("효과 확인됨");
+});
+
+it("needs a kind, the document it rests on, and for a changed condition its range; then sends the verdict it saw", async () => {
+ await mount();
+ await click(button("Detection rate in rain 처분 기록 남기기"));
+ expect(dialog().textContent).toContain("THOTH가 면제나 승인을 내리지 않으며");
+ await click(button("기록 남기기", dialog()));
+ expect(dialog().textContent).toContain("처분 종류를 고르세요");
+ await choose("운용 조건 변경");
+ await click(button("기록 남기기", dialog()));
+ expect(dialog().textContent).toContain("근거 문서를 적어 주세요");
+ await write("근거 문서", " OPS-9 ");
+ await click(button("기록 남기기", dialog()));
+ expect(dialog().textContent).toContain("해당 조건 범위를 적어 주세요");
+ expect(state.calls.some(call => call.method === "trace/closure/record")).toBe(false);
+ await write("해당 조건 범위", "50 m 이하 안개");
+ await click(button("기록 남기기", dialog()));
+ const sent = state.calls.find(call => call.method === "trace/closure/record")!;
+ expect(sent.input).toEqual({ project_id: "project:radar", subject_kind: "CRITERION", subject_id: "SYN-C-DET-RAIN", kind: "CONDITION_CHANGED", basis_ref: "OPS-9", note: "",
+ scope: "50 m 이하 안개", current_verdict_revision: verdictOf("SYN-C-DET-RAIN").revision_digest });
+});
+
+it("sends no scope for the other kinds and shows no forbidden word or code", async () => {
+ state.closures = [closure("HUMAN_CLOSED")];
+ await mount();
+ await click(button("Detection rate in fog 처분 기록 남기기"));
+ await choose("수정 반영됨");
+ await write("근거 문서", "ECN-3");
+ await write("메모", "배선을 바꿈");
+ await click(button("기록 남기기", dialog()));
+ const sent = state.calls.find(call => call.method === "trace/closure/record")!;
+ expect(sent.input).toMatchObject({ subject_id: "SYN-C-DET-FOG", kind: "FIX_APPLIED", basis_ref: "ECN-3", note: "배선을 바꿈" });
+ expect("scope" in sent.input).toBe(false);
+ const text = container.textContent!;
+ expect(text).not.toMatch(BANNED);
+ for (const raw of RAW) expect(text).not.toContain(raw);
+});
diff --git a/apps/web/src/components/TraceConfirmDialog.tsx b/apps/web/src/components/TraceConfirmDialog.tsx
new file mode 100644
index 0000000..3f3c6f8
--- /dev/null
+++ b/apps/web/src/components/TraceConfirmDialog.tsx
@@ -0,0 +1,28 @@
+import { Button, Callout, Classes, Dialog, TextArea } from "@blueprintjs/core";
+import { useState } from "react";
+import { verdictLook } from "./traceText";
+import type { Verdict } from "../api/trace";
+
+/** Collects the reason for a person's confirmation. Sending is the caller's job; a confirmation never changes the verdict. */
+export function TraceConfirmDialog({ verdict, title, pending, error, onClose, onSubmit }: {
+ verdict: Verdict | null; title: string; pending: boolean; error: string | null; onClose: () => void; onSubmit: (rationale: string) => void;
+}) {
+ const [reason, setReason] = useState("");
+ const [message, setMessage] = useState("");
+ const close = () => { setReason(""); setMessage(""); onClose(); };
+ const submit = () => { if (!reason.trim()) { setMessage("확인한 이유를 적어 주세요."); return; } setMessage(""); onSubmit(reason.trim()); };
+ return
+
+ {verdict &&
{title} : {verdictLook(verdict.subject_kind, verdict.state).text}
}
+
확인은 사람이 이 판정을 보았다는 기록만 남깁니다. 판정은 바뀌지 않고, 나중에 다시 계산되면 새 판정에는 이 확인이 옮겨지지 않습니다.
+
확인한 이유
+
+ {message &&
{message} }
+ {error &&
{error} }
+
+
+ ;
+}
diff --git a/apps/web/src/components/TraceEvidenceDialog.tsx b/apps/web/src/components/TraceEvidenceDialog.tsx
new file mode 100644
index 0000000..80626f7
--- /dev/null
+++ b/apps/web/src/components/TraceEvidenceDialog.tsx
@@ -0,0 +1,30 @@
+import { Button, Callout, Classes, Dialog } from "@blueprintjs/core";
+import { useQuery } from "@tanstack/react-query";
+import { rpc } from "../api/rpcClient";
+import type { EvidenceSpan } from "../types";
+import { EvidenceRail } from "./EvidenceRail";
+
+/** Opens one "근거 위치": the sentence it points at, highlighted among its document's sentences, or the reason it cannot be shown. */
+export function TraceEvidenceDialog({ projectId, spanRef, onClose }: { projectId: string; spanRef: string | null; onClose: () => void }) {
+ const query = useQuery({
+ queryKey: ["trace-evidence-spans", projectId], enabled: spanRef !== null,
+ queryFn: ({ signal }) => rpc<{ evidence: EvidenceSpan[] }>("evidence/list", { project_id: projectId }, crypto.randomUUID(), signal),
+ });
+ const spans = query.data?.value.evidence ?? [];
+ const target = spans.find(span => span.span_id === spanRef);
+ // In the order of the document, so the highlighted sentence sits where it is in the file.
+ const byPosition = (a: EvidenceSpan, b: EvidenceSpan) => (a.locator.line ?? Number.MAX_SAFE_INTEGER) - (b.locator.line ?? Number.MAX_SAFE_INTEGER);
+ const sameDocument = target ? spans.filter(span => span.artifact_id === target.artifact_id).sort(byPosition).slice(0, 300) : [];
+ return
+
+
근거 위치는 이 판정에 연결된 자료의 위치 기록입니다. 연결되어 있다는 것만으로 THOTH가 그 문장을 근거로 확인한 것은 아닙니다.
+ {query.isPending &&
자료에서 위치를 찾는 중…
}
+ {query.error &&
자료 목록을 읽지 못해 이 위치를 열 수 없습니다. 위치가 없다는 뜻은 아닙니다.
+ void query.refetch()}>다시 읽기 }
+ {query.data && !target &&
이 위치를 이 프로젝트에 연결된 자료에서 찾지 못했습니다. 자료를 연결하지 않았거나, 위치 표기가 THOTH가 만든 원문 위치가 아닐 수 있습니다.
+ 기록된 위치 표기: {spanRef}
}
+ {target &&
}
+
+
+ ;
+}
diff --git a/apps/web/src/components/TraceHistory.tsx b/apps/web/src/components/TraceHistory.tsx
new file mode 100644
index 0000000..895955c
--- /dev/null
+++ b/apps/web/src/components/TraceHistory.tsx
@@ -0,0 +1,63 @@
+import { Button, Callout, Tag } from "@blueprintjs/core";
+import { useState } from "react";
+import { readTraceHistory, type TraceView, type Verdict, type VerdictRevision } from "../api/trace";
+import { resultText, titleOf } from "./traceRows";
+import { actorLabel, changeSentence, reasonSentence, resultReasonText, timeText, triggerLabel, verdictLook } from "./traceText";
+
+const policyText = (name: string) => (name === "ALL_MUST_PASS" ? "모든 결과가 기준을 만족해야 함" : "조건별로 가장 최근 결과");
+
+function Revision({ view, revision, parent, current }: { view: TraceView; revision: VerdictRevision; parent: VerdictRevision | undefined; current: boolean }) {
+ const look = verdictLook(revision.subject_kind, revision.state);
+ const title = titleOf(view);
+ const confirmations = view.confirmations.filter(item => item.verdict_revision_digest === revision.revision_digest);
+ const selection = revision.selection;
+ const found = (id: string, version: number) => view.results.find(item => item.result_id === id && item.result_revision === version);
+ return
+ {current ? "현재 판정" : "이전 판정"} · {timeText(revision.actor.computed_at)} · {actorLabel(revision.actor.actor_id)}
+ {look.text}
+ {parent ? 이전 판정 "{verdictLook(parent.subject_kind, parent.state).text}" → 이번 판정 "{look.text}"
+ : revision.parent_digest === null ? 처음 계산한 판정입니다.
+ : 이 앞의 판정은 "이전 이력 더 보기"로 볼 수 있습니다. }
+ 계산을 일으킨 일 : {triggerLabel(revision.cause.trigger)}
+ {revision.cause.changed.length > 0
+ ? {revision.cause.changed.map((change, index) => {changeSentence(change, view)} )}
+ : 이 판정에 영향을 준 입력 변경은 기록되지 않았습니다.
}
+ {selection ? 고른 결과 ({policyText(selection.policy.name)})
+ {selection.chosen.length === 0 ?
고를 수 있는 결과가 없었습니다.
+ :
{selection.chosen.map(ref => { const result = found(ref.result_id, ref.result_revision);
+ return {ref.result_id} (개정 {ref.result_revision}){result ? `: ${resultText(result)}` : ""} ; })} }
+ {selection.excluded.length > 0 &&
{selection.excluded.map(item => {item.result_id} (개정 {item.result_revision}): {resultReasonText(item.reason)} )} }
+
: revision.conditions.required_criteria.length > 0 && 판정에 쓴 필수 기준 : {revision.conditions.required_criteria.map(title).join(", ")}
}
+ {revision.reasons.computed.length > 0 && 이유 {revision.reasons.computed.map(code => {reasonSentence(code, title, revision.conditions.condition)} )} }
+ 사람 확인 : {confirmations.length === 0 ? "이 판정에 대한 확인은 없습니다." : ""}
+ {confirmations.length > 0 && {confirmations.map(item => {actorLabel(item.actor_id)} · {timeText(item.confirmed_at)} — {item.rationale} )} }
+ 기술 정보
+ 판정 개정 {revision.revision_digest} {revision.parent_digest && 이전 개정 {revision.parent_digest} }
+ {revision.reasons.computed.map(code => {code} )}
+ ;
+}
+
+/** The recent revisions of one verdict, newest first, and older ones on request: each says what changed, why and who confirmed it. */
+export function TraceHistory({ view, projectId, verdict }: { view: TraceView; projectId: string; verdict: Verdict | undefined }) {
+ const [older, setOlder] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [failed, setFailed] = useState(false);
+ if (!verdict) return 아직 계산된 판정이 없습니다.
;
+ const shown = [...verdict.recent_history, ...older.filter(item => !verdict.recent_history.some(recent => recent.revision_digest === item.revision_digest))];
+ const loadMore = async () => {
+ setLoading(true); setFailed(false);
+ try {
+ const page = await readTraceHistory(projectId, verdict.subject_kind, verdict.subject_id, shown[shown.length - 1].revision_digest);
+ setOlder(current => [...current, ...page.revisions]);
+ } catch { setFailed(true); }
+ finally { setLoading(false); }
+ };
+ return
+
{shown.map((revision, index) =>
+ item.revision_digest === revision.parent_digest)}/>)}
+
판정 이력 전체 {verdict.history_total}개 중 {shown.length}개를 보고 있습니다.
+ {failed &&
이전 이력을 읽지 못했습니다. 이력이 없다는 뜻이 아닙니다. }
+ {shown.length < verdict.history_total &&
void loadMore()}>이전 이력 더 보기 }
+
;
+}
diff --git a/apps/web/src/components/TraceImportDialog.tsx b/apps/web/src/components/TraceImportDialog.tsx
new file mode 100644
index 0000000..09bb23f
--- /dev/null
+++ b/apps/web/src/components/TraceImportDialog.tsx
@@ -0,0 +1,88 @@
+import { Button, Callout, Classes, Dialog, Radio, RadioGroup } from "@blueprintjs/core";
+import { useState } from "react";
+import { applyTraceImport, previewTraceImport, type ImportApplied, type ImportMode, type ImportPreview } from "../api/trace";
+import { applyFailureText, issueText, lossText } from "./traceText";
+
+const kindName: Record = { ITEM: "항목", LINK: "연결", RULE: "판정 규칙", RESULT: "결과" };
+
+function readText(file: File): Promise {
+ if (typeof file.text === "function") return file.text();
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(String(reader.result));
+ reader.onerror = () => reject(reader.error);
+ reader.readAsText(file);
+ });
+}
+
+function ChangeList({ heading, items }: { heading: string; items: { kind: string; id: string; fields: string[] }[] }) {
+ if (items.length === 0) return null;
+ return {heading} {items.length}개
+
{items.map(item => {kindName[item.kind] ?? item.kind} {item.id} )} ;
+}
+
+/** Choose a file, look at what it would change, then apply exactly that. Nothing is saved before the last button. */
+export function TraceImportDialog({ projectId, isOpen, hasTrace, onClose, onApplied }: {
+ projectId: string; isOpen: boolean; hasTrace: boolean; onClose: () => void; onApplied: (result: ImportApplied) => void;
+}) {
+ const [mode, setMode] = useState(hasTrace ? "UPDATE" : "CREATE");
+ const [text, setText] = useState(null);
+ const [fileName, setFileName] = useState("");
+ const [preview, setPreview] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [failure, setFailure] = useState<{ message: string; next: string } | null>(null);
+ const reset = () => { setText(null); setFileName(""); setPreview(null); setFailure(null); setBusy(false); };
+ const close = () => { reset(); onClose(); };
+ const look = async (nextMode: ImportMode, csv: string | null) => {
+ setPreview(null); setFailure(null);
+ if (csv === null) return;
+ setBusy(true);
+ try { setPreview(await previewTraceImport(projectId, nextMode, csv)); }
+ catch { setFailure({ message: "파일을 미리보기하지 못했습니다. 아무것도 저장되지 않았습니다.", next: "잠시 뒤 다시 시도하세요." }); }
+ finally { setBusy(false); }
+ };
+ const choose = async (file: File | undefined) => {
+ if (!file) return;
+ const csv = await readText(file);
+ setText(csv); setFileName(file.name);
+ await look(mode, csv);
+ };
+ const apply = async () => {
+ if (!preview || text === null) return;
+ setBusy(true); setFailure(null);
+ try { const result = await applyTraceImport(projectId, mode, text, preview); reset(); onApplied(result); }
+ catch (error) { setFailure(applyFailureText(error)); setBusy(false); }
+ };
+ const changes = preview?.changes;
+ const total = changes ? changes.added.length + changes.updated.length + changes.deleted.length : 0;
+ return
+
+
{ const next = event.currentTarget.value as ImportMode; setMode(next); void look(next, text); }}>
+
+
+
+
CSV 파일 선택
+ void choose(event.currentTarget.files?.[0])}/>
+ {fileName &&
선택한 파일: {fileName}
}
+ {busy &&
처리 중…
}
+ {failure &&
{failure.message}다음 행동 : {failure.next}
}
+ {preview && changes &&
+ 미리보기 — 아직 저장하지 않았습니다
+ 추가 {changes.added.length}개 · 변경 {changes.updated.length}개 · 삭제 {changes.deleted.length}개 · 그대로 {changes.unchanged}개
+
+ {preview.conflicts.length > 0 &&
+ {preview.conflicts.map((issue, index) => { const info = issueText(issue);
+ return {info.message}다음 행동 : {info.next}
+ 기술 정보 {issue.code}{issue.detail ? ` · ${issue.detail}` : ""} ; })} }
+ {preview.losses.length > 0 && CSV에 담기지 않아 이 들여오기가 건드리지 않는 것
+ {preview.losses.map(line => {lossText(line)} )} }
+ {preview.applicable && total === 0 && 바뀌는 것이 없습니다.
}
+ {preview.applicable && total > 0 && 반영하면 바뀐 입력에 해당하는 판정이 같은 순간에 다시 계산됩니다. 이전 판정은 이력에 남습니다.
}
+ }
+
+
+ 닫기
+ void apply()}>이 내용으로 반영
+
+ ;
+}
diff --git a/apps/web/src/components/TraceOriginLine.tsx b/apps/web/src/components/TraceOriginLine.tsx
new file mode 100644
index 0000000..5b9d4e4
--- /dev/null
+++ b/apps/web/src/components/TraceOriginLine.tsx
@@ -0,0 +1,14 @@
+import { Button } from "@blueprintjs/core";
+import { objectValue, textValue } from "../api/presentation";
+
+/** One line on a result that started from a trace row: which row, and a way back to it. */
+export function TraceOriginLine({ result, onOpenTrace }: { result: Record; onOpenTrace?: (row: { kind: string; id: string }) => void }) {
+ const origin = objectValue(result.origin);
+ const kind = textValue(origin.subject_kind);
+ const id = textValue(origin.subject_id);
+ if (origin.kind !== "TRACE_VERDICT" || !id || (kind !== "CRITERION" && kind !== "REQUIREMENT")) return null;
+ return
+ 이 조사는 추적표의 「{textValue(origin.subject_title) || id}」 줄에서 시작했습니다.
+ {onOpenTrace && onOpenTrace({ kind, id })}>추적표에서 보기 }
+
;
+}
diff --git a/apps/web/src/components/TracePage.test.tsx b/apps/web/src/components/TracePage.test.tsx
new file mode 100644
index 0000000..c5dccac
--- /dev/null
+++ b/apps/web/src/components/TracePage.test.tsx
@@ -0,0 +1,390 @@
+// @vitest-environment jsdom
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { TracePage } from "./TracePage";
+import type { RowInvestigation } from "./traceInvestigation";
+import demo from "./traceDemo.fixture.json";
+
+type Json = Record;
+const state = vi.hoisted(() => ({ view: {} as Json, calls: [] as { method: string; input: Json }[], preview: {} as Json, applyError: null as string | null,
+ spans: [] as Json[], readFails: false, applied: {} as Json, afterConfirm: {} as Json, exported: {} as Json, links: [] as Json[] }));
+vi.mock("../api/rpcClient", async importOriginal => ({ ...(await importOriginal()),
+ rpc: async (method: string, input: Json) => {
+ state.calls.push({ method, input });
+ const done = (value: unknown) => ({ value, state: "SUCCEEDED", operation_id: "" });
+ if (method === "trace/read") { if (state.readFails) throw new Error("synthetic read failure"); return done(state.view); }
+ if (method === "trace/importPreview") return done(state.preview);
+ if (method === "trace/importApply") {
+ if (state.applyError) { const { RpcError } = await importOriginal(); throw new RpcError(state.applyError, -32030, {}); }
+ state.view = demo.phase2 as Json; return done(state.applied);
+ }
+ if (method === "trace/confirm") { state.view = state.afterConfirm; return done(state.view); }
+ if (method === "trace/export") return done(state.exported);
+ if (method === "trace/history") return done(demo.manyOlder);
+ if (method === "evidence/list") return done({ evidence: state.spans });
+ if (method === "trace/closure/list") return done({ closures: [] });
+ if (method === "hypothesis/link/list") return done({ trace_digest: null, links: state.links, reason_distribution: { total: 0, flipped: 0, by_reason: {} } });
+ throw new Error("unexpected " + method);
+ } }));
+
+let root: Root | undefined; let container: HTMLDivElement;
+const settle = async () => { for (let i = 0; i < 10; i++) await act(async () => { await new Promise(r => setTimeout(r, 10)); }); };
+async function mount(props: { onInvestigate?: (item: RowInvestigation) => void; focusRow?: { kind: string; id: string } | null } = {}) {
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+ container = document.createElement("div"); document.body.append(container); root = createRoot(container);
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ await act(async () => root!.render( ));
+ await settle();
+}
+beforeEach(() => { state.links = []; state.view = demo.phase1 as Json; state.calls = []; state.applyError = null; state.spans = []; state.readFails = false; state.preview = demo.rainPreview as Json;
+ state.applied = demo.rainApply as Json; state.afterConfirm = demo.afterConfirm as Json; state.exported = demo.exported as Json; });
+afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; container?.remove(); document.body.innerHTML = ""; vi.restoreAllMocks(); });
+
+const withoutTech = (html: string) => html.replace(//g, "");
+const page = () => withoutTech(container.textContent === null ? "" : container.innerHTML);
+const visibleText = (scope: ParentNode = document.body) => {
+ const copy = (scope as HTMLElement).cloneNode(true) as HTMLElement;
+ copy.querySelectorAll("details").forEach(node => node.remove());
+ return copy.textContent ?? "";
+};
+const buttonByLabel = (label: string, scope: ParentNode = document.body) => [...scope.querySelectorAll("button")].find(b => (b.getAttribute("aria-label") ?? b.textContent?.trim()) === label) ?? null;
+const click = async (element: Element | null) => { expect(element).not.toBeNull(); await act(async () => (element as HTMLElement).click()); await settle(); };
+const rowOf = (text: string) => [...container.querySelectorAll("tr")].find(tr => tr.querySelector("th")?.textContent?.includes(text)) as HTMLElement;
+const RAW = ["HOLD_NO_RESULT", "PASS_COMPUTED", "FAIL_COMPUTED", "HOLD_INCOMPLETE", "FAIL_WITH_INCOMPLETE_COVERAGE", "NO_RESULT:", "THRESHOLD_NOT_MET", "CURRENT", "STALE_BASIS", "CSV_IMPORT"];
+
+it("shows the first-phase data with rain and fog held, dry passing, and no internal codes on screen", async () => {
+ await mount();
+ const text = visibleText(container);
+ const rain = rowOf("Detection rate in rain"); const fog = rowOf("Detection rate in fog"); const dry = rowOf("Detection rate in dry weather");
+ expect(rain.textContent).toContain("보류 · 결과 없음"); expect(fog.textContent).toContain("보류 · 결과 없음");
+ expect(dry.textContent).toContain("기준 충족");
+ expect(rain.textContent).toContain("날씨: 비 조건의 결과가 아직 없습니다.");
+ expect(rain.textContent).toContain("조건 날씨: 비"); expect(visibleText(container)).not.toMatch(/weather=/);
+ expect(rowOf("Synthetic radar detects").textContent).toContain("보류 · 결과 없는 조건이 있음");
+ expect(rowOf("Synthetic radar detects").textContent).toContain("기준 6개 · 충족 2 · 미달 0 · 보류 4");
+ for (const raw of RAW) expect(text).not.toContain(raw);
+ for (const banned of ["엑셀 대체", "인증", "검증 완료"]) expect(document.body.textContent).not.toContain(banned);
+ expect(container.querySelector(".trace-synthetic-banner")?.textContent).toContain("합성 데이터입니다");
+ expect(container.querySelector(".trace-synthetic-banner")?.textContent).toContain("SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE");
+ expect(text).toContain("0.95 ratio (19/20)");
+ expect(page()).not.toContain("엑셀 대체");
+});
+
+it("is a labelled table whose rows open and close from a named button", async () => {
+ await mount();
+ expect(container.querySelector("table caption")?.textContent).toContain("판정");
+ expect([...container.querySelectorAll("thead th")].every(th => th.getAttribute("scope") === "col")).toBe(true);
+ const open = buttonByLabel("Detection rate in rain 판정 이력 펼치기", container) as HTMLButtonElement;
+ expect(open.tagName).toBe("BUTTON"); expect(open.getAttribute("aria-expanded")).toBe("false");
+ await click(open);
+ const close = buttonByLabel("Detection rate in rain 판정 이력 접기", container) as HTMLButtonElement;
+ expect(close.getAttribute("aria-expanded")).toBe("true");
+ expect(document.getElementById(close.getAttribute("aria-controls")!)?.textContent).toContain("처음 계산한 판정입니다.");
+ expect(buttonByLabel("CSV로 내보내기", container)).not.toBeNull();
+ expect(buttonByLabel("CSV 들여오기", container)).not.toBeNull();
+ expect(buttonByLabel("Detection rate in dry weather 판정 확인 기록 남기기", container)).not.toBeNull();
+ expect(buttonByLabel("Detection rate in dry weather 근거 위치 1 보기", container)).not.toBeNull();
+});
+
+async function chooseFile(text: string) {
+ const input = document.body.querySelector("input[type=file]")!;
+ const file = new File([text], "rain.csv", { type: "text/csv" });
+ Object.defineProperty(input, "files", { value: [file], configurable: true });
+ await act(async () => { input.dispatchEvent(new Event("change", { bubbles: true })); });
+ await settle();
+}
+
+it("previews the rain results, applies them, and then only the rain lines fail while the old hold stays in the history", async () => {
+ await mount();
+ const dryBefore = rowOf("Detection rate in dry weather").textContent;
+ await click(buttonByLabel("CSV 들여오기", container));
+ expect(document.body.querySelector("input[value=UPDATE]")?.checked).toBe(true);
+ await chooseFile(demo.rainCsv);
+ const preview = state.calls.find(call => call.method === "trace/importPreview")!;
+ expect(preview.input).toMatchObject({ project_id: "project:radar", mode: "UPDATE", csv_text: demo.rainCsv.replace(/^\uFEFF/, "") });
+ const dialog = document.body.querySelector(".trace-import-dialog")!;
+ expect(visibleText(dialog)).toContain("추가 6개 · 변경 0개 · 삭제 0개");
+ expect(visibleText(dialog)).toContain("아직 저장하지 않았습니다");
+ expect(state.calls.some(call => call.method === "trace/importApply")).toBe(false);
+ await click(buttonByLabel("이 내용으로 반영", dialog));
+ const apply = state.calls.find(call => call.method === "trace/importApply")!;
+ expect(apply.input).toMatchObject({ mode: "UPDATE", preview_id: demo.rainPreview.preview_id, input_sha256: demo.rainPreview.input_sha256 });
+ expect(rowOf("Detection rate in rain").textContent).toContain("기준 미달");
+ expect(rowOf("False tracks per minute in rain").textContent).toContain("기준 미달");
+ expect(rowOf("Synthetic radar detects").textContent).toContain("기준 미달 · 결과 없는 조건도 있음");
+ expect(rowOf("Detection rate in dry weather").textContent).toBe(dryBefore);
+ expect(rowOf("Detection rate in fog").textContent).toContain("보류 · 결과 없음");
+ expect(rowOf("Detection rate in rain").textContent).toContain("측정값 0.80이(가) 기준 0.90 이상(ratio)에 못 미칩니다.");
+ const notice = container.querySelector(".trace-notice")!.textContent!;
+ expect(notice).toContain("반영했습니다."); expect(notice).toContain("보류 · 결과 없음 → 기준 미달");
+ await click(buttonByLabel("Detection rate in rain 판정 이력 펼치기", container));
+ const history = visibleText(container.querySelector("[aria-label='판정 이력']")!);
+ expect(history).toContain("현재 판정"); expect(history).toContain("이전 판정");
+ expect(history).toContain('이전 판정 "보류 · 결과 없음" → 이번 판정 "기준 미달"');
+ expect(history).toContain("CSV 들여오기 반영"); expect(history).toContain("결과 추가: SYN-RES-SYN-C-DET-RAIN (0.80 ratio (16/20) · 날씨: 비)");
+ expect(history).toContain("날씨: 비 조건의 결과가 아직 없습니다."); expect(history).not.toMatch(/weather=/);
+ expect(container.querySelector(".trace-revision:last-child details")?.textContent).toContain("NO_RESULT:weather=rain");
+ expect(history).toContain("SYN-RES-SYN-C-DET-RAIN (개정 1): 0.80 ratio (16/20)");
+ for (const raw of RAW) expect(history).not.toContain(raw);
+ expect(container.querySelector(".trace-revision details")?.textContent).toContain("판정 개정");
+});
+
+it("explains a refused file in words with the next step and keeps the apply button off", async () => {
+ state.view = demo.phase2 as Json; state.preview = demo.staleBasePreview as Json;
+ await mount();
+ await click(buttonByLabel("CSV 들여오기", container));
+ await chooseFile("stale");
+ const dialog = document.body.querySelector(".trace-import-dialog")!;
+ const text = visibleText(dialog);
+ expect(text).toContain("지금의 추적표와 다른 상태에서 내보낸 것입니다");
+ expect(text).toContain("다음 행동: 지금 상태에서 'CSV로 내보내기'를 다시 한 뒤");
+ expect(text).not.toContain("STALE_BASE");
+ expect(dialog.querySelector("details")?.textContent).toContain("STALE_BASE");
+ expect((buttonByLabel("이 내용으로 반영", dialog) as HTMLButtonElement).disabled).toBe(true);
+ expect(state.calls.some(call => call.method === "trace/importApply")).toBe(false);
+});
+
+it("says the preview went stale when the server refuses the apply and changes nothing", async () => {
+ state.applyError = "TRACE_IMPORT_PREVIEW_STALE";
+ await mount();
+ await click(buttonByLabel("CSV 들여오기", container));
+ await chooseFile(demo.rainCsv);
+ await click(buttonByLabel("이 내용으로 반영", document.body.querySelector(".trace-import-dialog")!));
+ expect(visibleText(document.body.querySelector(".trace-import-dialog")!)).toContain("미리보기를 본 뒤에 추적표가 바뀌어 반영하지 않았습니다");
+ expect(rowOf("Detection rate in rain").textContent).toContain("보류 · 결과 없음");
+});
+
+it("records a confirmation with a reason and leaves every verdict as it was", async () => {
+ await mount();
+ const before = rowOf("Synthetic radar detects").textContent!.split("확인")[0];
+ await click(container.querySelector(".trace-requirement button[aria-label$='판정 확인 기록 남기기']"));
+ const dialog = document.body.querySelector(".bp6-dialog")!;
+ expect(visibleText(dialog)).toContain("판정은 바뀌지 않고");
+ await click(buttonByLabel("확인 기록 남기기", dialog));
+ expect(visibleText(dialog)).toContain("확인한 이유를 적어 주세요.");
+ expect(state.calls.some(call => call.method === "trace/confirm")).toBe(false);
+ const area = dialog.querySelector("textarea[aria-label='확인한 이유']")!;
+ await act(async () => { Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(area, "1단계 결과 확인"); area.dispatchEvent(new Event("input", { bubbles: true })); });
+ await click(buttonByLabel("확인 기록 남기기", dialog));
+ const sent = state.calls.find(call => call.method === "trace/confirm")!;
+ const requirement = (demo.phase1.verdicts as { subject_kind: string; revision_digest: string }[]).find(item => item.subject_kind === "REQUIREMENT")!;
+ expect(sent.input).toMatchObject({ project_id: "project:radar", verdict_revision_digest: requirement.revision_digest, rationale: "1단계 결과 확인", expected_digest: demo.phase1.record_digest });
+ const after = rowOf("Synthetic radar detects").textContent!;
+ expect(after).toContain("이 컴퓨터의 사용자 확인"); expect(after).toContain("1단계 결과 확인");
+ expect(after).toContain("보류 · 결과 없는 조건이 있음"); expect(after.startsWith(before.slice(0, 10))).toBe(true);
+ expect(rowOf("Detection rate in rain").textContent).toContain("보류 · 결과 없음");
+ expect(container.querySelector(".trace-notice")?.textContent).toContain("판정은 바뀌지 않았습니다");
+});
+
+it("opens a 근거 위치 on the sentence it points at, or says why it cannot", async () => {
+ await mount();
+ const ref = "30_RESULT_DRY_SYNTHETIC.yaml#detection";
+ await click(buttonByLabel("Detection rate in dry weather 근거 위치 1 보기", container));
+ let dialog = document.body.querySelector(".trace-evidence-dialog")!;
+ expect(visibleText(dialog)).toContain("이 위치를 이 프로젝트에 연결된 자료에서 찾지 못했습니다");
+ expect(visibleText(dialog)).toContain("연결되어 있다는 것만으로 THOTH가 그 문장을 근거로 확인한 것은 아닙니다");
+ expect(dialog.textContent).toContain(ref);
+ await click(buttonByLabel("닫기", dialog));
+ state.spans = [{ span_id: ref, artifact_id: "artifact:1", source_version_id: "v1", exact_text: "맑은 날 20회 중 19회 탐지했다", authority_state: "OFFICIAL", cutoff_state: "VALID",
+ verification_state: "UNVERIFIED", support_state: "SUPPORTED", locator: { line: 12 } },
+ { span_id: "span:other", artifact_id: "artifact:1", source_version_id: "v1", exact_text: "앞 문장", authority_state: "OFFICIAL", cutoff_state: "VALID", verification_state: "UNVERIFIED", support_state: "SUPPORTED", locator: { line: 11 } }];
+ document.body.innerHTML = ""; if (root) await act(async () => root!.unmount()); await mount();
+ const scrolled = vi.fn(); Object.assign(Element.prototype, { scrollIntoView: scrolled });
+ await click(buttonByLabel("Detection rate in dry weather 근거 위치 1 보기", container));
+ dialog = document.body.querySelector(".trace-evidence-dialog")!;
+ const focused = dialog.querySelector("[aria-current=true]")!;
+ expect(focused.textContent).toContain("맑은 날 20회 중 19회 탐지했다"); expect(focused.getAttribute("data-span-id")).toBe(ref);
+ expect(dialog.querySelectorAll("[data-span-id]").length).toBe(2);
+ // Opening the window scrolls the highlighted sentence into view inside the window.
+ expect(scrolled).toHaveBeenCalledWith(expect.objectContaining({ block: "center" }));
+ expect(scrolled.mock.contexts).toContain(focused);
+ // The sentences read in the order of the file, so the highlighted one sits where it is in the document.
+ expect([...dialog.querySelectorAll("[data-span-id]")].map(card => card.getAttribute("data-span-id"))).toEqual(["span:other", ref]);
+});
+
+it("downloads the CSV and says what the file cannot carry", async () => {
+ const create = vi.fn(() => "blob:x"); Object.assign(URL, { createObjectURL: create, revokeObjectURL: vi.fn() });
+ const clicked = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
+ await mount();
+ await click(buttonByLabel("CSV로 내보내기", container));
+ expect(state.calls.some(call => call.method === "trace/export")).toBe(true);
+ expect(create).toHaveBeenCalledTimes(1); expect(clicked).toHaveBeenCalledTimes(1);
+ const blob = (create.mock.calls[0] as unknown as [Blob])[0];
+ expect(await new Promise(resolve => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.readAsText(blob); })).toBe((demo.exported as { csv_text: string }).csv_text.replace(/^\uFEFF/, ""));
+ const note = container.querySelector(".trace-notice")!.textContent!;
+ expect(note).toContain("판정 이력(들여온 뒤 다시 계산됩니다)"); expect(note).not.toContain("verdict_history");
+});
+
+it("marks a line whose basis changed as needing a new calculation and names the change", async () => {
+ const view = structuredClone(demo.phase2) as { verdicts: Json[] };
+ const rain = view.verdicts.find(item => item.subject_id === "SYN-C-DET-RAIN")!;
+ rain.currentness = { state: "STALE_BASIS", changed_dependencies: [{ kind: "RESULT", ref_id: "SYN-RES-X", field: "value", before: "0.80", after: "0.92", before_revision: 1, after_revision: 2, criterion_ids: ["SYN-C-DET-RAIN"], requirement_ids: [] }] };
+ state.view = view as Json;
+ await mount();
+ const text = rowOf("Detection rate in rain").textContent!;
+ expect(text).toContain("근거가 바뀜 · 다시 계산 필요"); expect(text).toContain("결과 SYN-RES-X의 측정값: 0.80 → 0.92");
+ expect(rowOf("Detection rate in dry weather").textContent).toContain("지금 입력 기준");
+});
+
+it("starts an empty project in create mode and says there is no table yet, and a failed read is not shown as empty", async () => {
+ state.view = demo.empty as Json;
+ await mount();
+ expect(container.textContent).toContain("아직 추적표가 없습니다");
+ expect(container.querySelector(".trace-synthetic-banner")).toBeNull();
+ await click(buttonByLabel("CSV 들여오기", container));
+ expect(document.body.querySelector("input[value=CREATE]")?.checked).toBe(true);
+ document.body.innerHTML = ""; if (root) await act(async () => root!.unmount()); root = undefined;
+ state.readFails = true; await mount();
+ expect(container.textContent).toContain("추적표를 읽지 못했습니다. 비어 있다는 뜻이 아닙니다.");
+ expect(container.textContent).not.toContain("아직 추적표가 없습니다");
+});
+
+it("shows the three latest revisions of a long history and reads older ones only when asked", async () => {
+ state.view = demo.many as Json;
+ await mount();
+ await click(buttonByLabel("Detection rate in rain 판정 이력 펼치기", container));
+ const region = () => container.querySelector("[aria-label='판정 이력']")!;
+ expect(region().querySelectorAll(".trace-revision")).toHaveLength(3);
+ expect(visibleText(container)).toContain("판정 이력 전체 6개 중 3개를 보고 있습니다.");
+ expect(visibleText(region())).toContain('이 앞의 판정은 "이전 이력 더 보기"로 볼 수 있습니다.');
+ expect(state.calls.some(call => call.method === "trace/history")).toBe(false);
+ const rain = (demo.many.verdicts as { subject_id: string; recent_history: { revision_digest: string }[] }[]).find(item => item.subject_id === "SYN-C-DET-RAIN")!;
+ await click(buttonByLabel("이전 이력 더 보기", container));
+ const asked = state.calls.find(call => call.method === "trace/history")!;
+ expect(asked.input).toMatchObject({ project_id: "project:radar", subject_kind: "CRITERION", subject_id: "SYN-C-DET-RAIN", before_digest: rain.recent_history[2].revision_digest });
+ expect(region().querySelectorAll(".trace-revision")).toHaveLength(6);
+ expect(visibleText(container)).toContain("판정 이력 전체 6개 중 6개를 보고 있습니다.");
+ expect(buttonByLabel("이전 이력 더 보기", container)).toBeNull();
+ expect(visibleText(region())).toContain("처음 계산한 판정입니다.");
+ expect(visibleText(region())).not.toContain('이 앞의 판정은 "이전 이력 더 보기"로 볼 수 있습니다.');
+});
+
+const NEXT_BANNED = ["재시험 필수", "원인은", "기준을 낮추", "통과 가능", "승인됨", "종결", "해결", "면제", "AI 추천"];
+const tags = (row: HTMLElement) => [...row.querySelectorAll(".bp6-tag")].filter(tag => /bp6-intent-/.test(tag.className)).map(tag => tag.textContent + "|" + tag.className.match(/bp6-intent-\w+/)?.[0]);
+
+it("shows a next check under the held lines, with who decides, and none under the met line", async () => {
+ await mount();
+ for (const title of ["Detection rate in rain", "Detection rate in fog"]) {
+ const next = rowOf(title).querySelector(".trace-next")!;
+ expect(next.getAttribute("role")).toBe("note");
+ expect(next.textContent).toContain("다음 확인");
+ expect(next.textContent).toContain("연결된 시험의 결과가 아직 들어오지 않았습니다"); // the 미시험 tag's own check
+ expect(next.textContent).toContain("결정: 시험 책임자");
+ }
+ expect(rowOf("Detection rate in dry weather").querySelector(".trace-next")).toBeNull();
+ const requirement = rowOf("Synthetic radar detects").querySelector(".trace-next")!;
+ expect(requirement.textContent).toContain("각 기준 줄의 '다음 확인'을 따릅니다");
+ expect(requirement.textContent).not.toContain("결정:");
+ const text = visibleText(container);
+ for (const banned of NEXT_BANNED) expect(text).not.toContain(banned);
+ for (const raw of RAW) expect(text).not.toContain(raw);
+});
+
+it("after the rain results, the failed lines say what to check and the requirement says both things, without changing a verdict", async () => {
+ await mount();
+ const before = { dry: tags(rowOf("Detection rate in dry weather")), fog: tags(rowOf("Detection rate in fog")), rainTags: tags(rowOf("Detection rate in rain")) };
+ expect(before.rainTags[0]).toContain("보류 · 결과 없음");
+ state.view = demo.phase2 as Json;
+ document.body.innerHTML = ""; if (root) await act(async () => root!.unmount()); await mount();
+ const rain = rowOf("Detection rate in rain");
+ expect(tags(rain)[0]).toContain("기준 미달"); expect(tags(rain)[0]).toContain("bp6-intent-danger");
+ expect(rain.querySelector(".trace-next")!.textContent).toContain("시험이 유효했는지, 다른 원인 후보가 있는지");
+ expect(rain.querySelector(".trace-next")!.textContent).toContain("결정: 해당 권한자");
+ const requirement = rowOf("Synthetic radar detects").querySelector(".trace-next")!;
+ const lines = [...requirement.querySelectorAll("li")].map(item => item.textContent!);
+ expect(lines).toHaveLength(2);
+ expect(lines[0]).toContain("원인 후보"); expect(lines[1]).toContain("시험하지 않은 조건"); expect(lines[1]).toContain("시험 계획");
+ expect(lines[0]).toContain("결정: 해당 권한자"); expect(lines[1]).toContain("결정: 시험 책임자"); // each line names its own decider
+ expect(tags(rowOf("Detection rate in dry weather"))).toEqual(before.dry);
+ expect(tags(rowOf("Detection rate in fog"))).toEqual(before.fog);
+ expect(rowOf("Detection rate in dry weather").querySelector(".trace-next")).toBeNull();
+ const text = visibleText(container);
+ for (const banned of NEXT_BANNED) expect(text).not.toContain(banned);
+ for (const raw of RAW) expect(text).not.toContain(raw);
+});
+
+it("on a line whose basis changed, says the old guidance is for the old basis and keeps the verdict", async () => {
+ const view = structuredClone(demo.phase2) as { verdicts: Json[] };
+ const rain = view.verdicts.find(item => item.subject_id === "SYN-C-DET-RAIN")!;
+ rain.currentness = { state: "STALE_BASIS", changed_dependencies: [{ kind: "RESULT", ref_id: "SYN-RES-X", field: "value", before: "0.80", after: "0.92", before_revision: 1, after_revision: 2, criterion_ids: ["SYN-C-DET-RAIN"], requirement_ids: [] }] };
+ state.view = view as Json;
+ await mount();
+ const row = rowOf("Detection rate in rain");
+ expect(tags(row)).toContain("기준 미달|bp6-intent-danger");
+ const next = row.querySelector(".trace-next")!.textContent!;
+ expect(next).toContain("바뀐 근거로 판정을 다시 볼지"); expect(next).toContain("이전 안내는 이전 근거 기준입니다");
+ expect(next).toContain("시험이 유효했는지");
+});
+
+it("shows the tags that say why a line is held or failed, in grey with a one-line hint, and none on a met line", async () => {
+ await mount();
+ const tagTexts = (row: HTMLElement) => [...row.querySelectorAll(".trace-tags .bp6-tag")].map(tag => tag.textContent);
+ expect(tagTexts(rowOf("Detection rate in rain"))).toEqual(["미시험"]);
+ expect(tagTexts(rowOf("Detection rate in fog"))).toEqual(["미시험"]);
+ expect(tagTexts(rowOf("Synthetic radar detects"))).toEqual(["필수 기준 미완료"]);
+ expect(rowOf("Detection rate in dry weather").querySelector(".trace-tags")).toBeNull();
+ const tag = rowOf("Detection rate in rain").querySelector(".trace-tags .bp6-tag") as HTMLElement;
+ expect(tag.getAttribute("title")).toContain("연결된 시험은 있지만 아직 결과가 없습니다");
+ expect(tag.className).not.toMatch(/bp6-intent-/);
+ state.view = demo.phase2 as Json;
+ document.body.innerHTML = ""; if (root) await act(async () => root!.unmount()); await mount();
+ expect(tagTexts(rowOf("Detection rate in rain"))).toEqual(["유효한 미달"]);
+ expect(tagTexts(rowOf("Detection rate in fog"))).toEqual(["미시험"]);
+ expect(tagTexts(rowOf("Synthetic radar detects"))).toEqual(["필수 기준 미완료"]);
+ expect(tagTexts(rowOf("Detection rate in dry weather"))).toEqual([]);
+ const text = visibleText(container);
+ for (const raw of RAW) expect(text).not.toContain(raw);
+});
+
+it("offers the cause investigation only on held and failed rows, and pressing it hands the row to the workspace without starting anything", async () => {
+ const asked: RowInvestigation[] = [];
+ await mount({ onInvestigate: item => asked.push(item) });
+ const names = () => [...container.querySelectorAll(".trace-investigate")].map(button => button.getAttribute("aria-label"));
+ expect(names()).toContain("Detection rate in rain 원인 조사");
+ expect(names()).toContain("Detection rate in fog 원인 조사");
+ expect(names().some(name => name?.startsWith("Synthetic radar detects"))).toBe(true); // the held requirement
+ expect(names()).not.toContain("Detection rate in dry weather 원인 조사"); // a met row has none
+ expect(rowOf("Detection rate in dry weather").querySelector(".trace-investigate")).toBeNull();
+ const before = state.calls.length;
+ await click(buttonByLabel("Detection rate in rain 원인 조사", container));
+ expect(asked).toHaveLength(1);
+ expect(asked[0].origin).toMatchObject({ kind: "TRACE_VERDICT", project_id: "project:radar", subject_kind: "CRITERION" });
+ expect(asked[0].question).toContain("Detection rate in rain");
+ expect(asked[0].question).not.toMatch(/HOLD_|FAIL_|PASS_|NO_RESULT/);
+ expect(state.calls.length).toBe(before); // no request went to the server; the user sends the question
+ expect(state.calls.every(call => ["trace/read", "hypothesis/link/list", "trace/closure/list"].includes(call.method))).toBe(true); // only reads
+});
+
+it("has no investigation button when the page is not given a place to open one, and marks the row a result came from", async () => {
+ await mount();
+ expect(container.querySelector(".trace-investigate")).toBeNull();
+ await act(async () => root!.unmount()); container.remove(); root = undefined;
+ await mount({ onInvestigate: () => undefined, focusRow: { kind: "CRITERION", id: "SYN-C-DET-FOG" } });
+ const marked = [...container.querySelectorAll(".trace-focus")];
+ expect(marked).toHaveLength(1);
+ expect(marked[0].getAttribute("data-trace-row")).toBe("CRITERION:SYN-C-DET-FOG");
+});
+
+
+it("tells, beside the investigation link, how many hypotheses of that row stand on an old verdict", async () => {
+ const rain = demo.phase1.items.find(item => item.title === "Detection rate in rain")!.item_id;
+ const stale = (id: string, state = "STALE") => ({ hypothesis_id: id, subject_kind: "CRITERION", subject_id: rain, state });
+ state.links = [stale("h1"), stale("h2"), stale("h3", "CURRENT"), stale("h4", "RECHECKED")];
+ await mount({ onInvestigate: () => undefined });
+ const tag = (row: HTMLElement) => [...row.querySelectorAll(".bp6-tag")].map(item => item.textContent).filter(text => text?.includes("낡은 가설"));
+ expect(tag(rowOf("Detection rate in rain"))).toEqual(["낡은 가설 2개"]);
+ expect(tag(rowOf("Detection rate in fog"))).toEqual([]);
+ expect(visibleText(container)).not.toMatch(/STALE|HOLD_|CRITERION/);
+});
+
+it("does not ask for the hypothesis marks when the page cannot open a conversation, and survives a failed read", async () => {
+ await mount();
+ expect(state.calls.some(call => call.method === "hypothesis/link/list")).toBe(false);
+ await act(async () => root!.unmount()); container.remove(); root = undefined;
+ state.links = [{ hypothesis_id: "h", subject_kind: "CRITERION", subject_id: "x", state: "STALE" }];
+ await mount({ onInvestigate: () => undefined });
+ expect(state.calls.some(call => call.method === "hypothesis/link/list")).toBe(true);
+ expect(container.querySelectorAll(".trace-investigate").length).toBeGreaterThan(0);
+});
diff --git a/apps/web/src/components/TracePage.tsx b/apps/web/src/components/TracePage.tsx
new file mode 100644
index 0000000..56535ef
--- /dev/null
+++ b/apps/web/src/components/TracePage.tsx
@@ -0,0 +1,172 @@
+import { Button, Callout, Tag } from "@blueprintjs/core";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Fragment, useEffect, useState } from "react";
+import { confirmTraceVerdict, exportTrace, readTrace, traceKey, type ImportApplied, type TraceView, type Verdict } from "../api/trace";
+import { reasonTags } from "./traceReasonTags";
+import { investigationFor, type RowInvestigation } from "./traceInvestigation";
+import { hypothesisLinksKey } from "../api/hypothesisLink";
+import { staleCount } from "./hypothesisLinkText";
+import { useHypothesisLinks } from "./useHypothesisLinks";
+import { useTraceClosures } from "./useJudgmentRecords";
+import type { ClosureRow } from "../api/judgmentRecords";
+import { ClosureMark } from "./ClosureMark";
+import { ClosureRecorder } from "./ClosureRecorder";
+import { TraceConfirmDialog } from "./TraceConfirmDialog";
+import { TraceEvidenceDialog } from "./TraceEvidenceDialog";
+import { TraceHistory } from "./TraceHistory";
+import { TraceImportDialog } from "./TraceImportDialog";
+import { buildGroups, isSyntheticTrace, resultText, ruleText, titleOf, type CriterionRow } from "./traceRows";
+import { actorLabel, changeSentence, describeConditionText, lossText, nextStep, reasonSentence, timeText, verdictChangeSentence, verdictLook, type ChangeLookup } from "./traceText";
+
+const SYNTHETIC_NOTICE = "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE";
+
+function download(name: string, text: string) {
+ const url = URL.createObjectURL(new Blob([text], { type: "text/csv;charset=utf-8" }));
+ const link = document.createElement("a");
+ link.href = url; link.download = name; document.body.append(link); link.click(); link.remove();
+ URL.revokeObjectURL(url);
+}
+
+type Cells = { lookup: ChangeLookup; view: TraceView; kind: string; verdict: Verdict | undefined; label: string; titleOf: (id: string) => string; onConfirm: (verdict: Verdict) => void; onEvidence: (ref: string) => void;
+ investigation: RowInvestigation | null; onInvestigate?: (item: RowInvestigation) => void; staleHypotheses: number; closure: ClosureRow | undefined; onClose: (verdict: Verdict) => void };
+
+/** Verdict, currentness, person's confirmation and evidence positions of one line. */
+function VerdictCells({ lookup, view, kind, verdict, label, titleOf: nameOf, onConfirm, onEvidence, investigation, onInvestigate, staleHypotheses, closure, onClose }: Cells) {
+ if (!verdict) return <>아직 계산되지 않음 — — — >;
+ const look = verdictLook(kind, verdict.state);
+ const confirmed = verdict.confirmations[verdict.confirmations.length - 1];
+ const stale = verdict.currentness.state === "STALE_BASIS";
+ const tags = reasonTags(kind === "REQUIREMENT" ? "REQUIREMENT" : "CRITERION", verdict, view);
+ const next = nextStep(kind === "REQUIREMENT" ? "REQUIREMENT" : "CRITERION", verdict.state, stale, tags.map(tag => tag.id));
+ return <>
+ {look.text}
+ {verdict.reasons.computed.length > 0 && {verdict.reasons.computed.map(code => {reasonSentence(code, nameOf, verdict.conditions.condition)} )} }
+ {tags.length > 0 && {tags.map(tag => {tag.label} )}
}
+ {next && 다음 확인 {next.lines.map(line => {line.text}{line.decider && <>결정: {line.decider} >} )} }
+ {investigation && onInvestigate && onInvestigate(investigation)}>원인 조사
+ {staleHypotheses > 0 && 낡은 가설 {staleHypotheses}개 }
}
+
+ {stale ? <>근거가 바뀜 · 다시 계산 필요
+ {verdict.currentness.changed_dependencies.map((change, index) => {changeSentence(change, lookup)} )} >
+ : 지금 입력 기준 }
+ {confirmed ? {actorLabel(confirmed.actor_id)} 확인 · {timeText(confirmed.confirmed_at)}{confirmed.rationale} : 아직 확인 없음 }
+ onConfirm(verdict)}>확인
+ {verdict.basis.source_span_refs.length === 0 ? 근거 위치 없음
+ : verdict.basis.source_span_refs.map((ref, index) => onEvidence(ref)}>위치 {index + 1} )}
+ >;
+}
+
+function groupSummary(rows: CriterionRow[]) {
+ const states = rows.map(row => row.verdict?.state);
+ return `기준 ${rows.length}개 · 충족 ${states.filter(state => state === "PASS_COMPUTED").length} · 미달 ${states.filter(state => state === "FAIL_COMPUTED").length} · 보류 ${states.filter(state => !state || state.startsWith("HOLD")).length}`;
+}
+
+/** `onInvestigate` is given by the workspace that can open a conversation; `focusRow` marks the row a result came from. */
+export function TracePage({ projectId, onInvestigate, focusRow = null }: {
+ projectId: string; onInvestigate?: (item: RowInvestigation) => void; focusRow?: { kind: string; id: string } | null;
+}) {
+ const client = useQueryClient();
+ const query = useQuery({ queryKey: traceKey(projectId), queryFn: ({ signal }) => readTrace(projectId, signal) });
+ const [open, setOpen] = useState>(new Set());
+ const [confirming, setConfirming] = useState(null);
+ const [closing, setClosing] = useState(null);
+ const [evidence, setEvidence] = useState(null);
+ const [importing, setImporting] = useState(false);
+ const [notice, setNotice] = useState(null);
+ const [problem, setProblem] = useState(null);
+ const linked = useHypothesisLinks(projectId, Boolean(onInvestigate));
+ const closures = useTraceClosures(projectId);
+ const confirm = useMutation({
+ mutationFn: (rationale: string) => confirmTraceVerdict(projectId, confirming!.revision_digest, rationale, query.data?.record_digest ?? null),
+ onSuccess: view => { client.setQueryData(traceKey(projectId), view); setConfirming(null); setNotice(["확인을 기록했습니다. 판정은 바뀌지 않았습니다."]); },
+ onError: () => void client.invalidateQueries({ queryKey: traceKey(projectId) }),
+ });
+ const loaded = Boolean(query.data);
+ useEffect(() => {
+ if (!focusRow || !loaded) return;
+ const key = `${focusRow.kind}:${focusRow.id}`;
+ Array.from(document.querySelectorAll("[data-trace-row]")).find(row => row.getAttribute("data-trace-row") === key)?.scrollIntoView?.({ block: "center" });
+ }, [focusRow, loaded]);
+ if (query.isPending) return 추적표를 읽는 중…
;
+ if (query.error || !query.data) return 추적표를 읽지 못했습니다. 비어 있다는 뜻이 아닙니다.
+ void query.refetch()}>다시 읽기 ;
+ const view: TraceView = query.data;
+ const name = titleOf(view);
+ const groups = buildGroups(view);
+ const toggle = (key: string) => setOpen(current => { const next = new Set(current); if (!next.delete(key)) next.add(key); return next; });
+ const exportCsv = async () => {
+ setProblem(null);
+ try {
+ const file = await exportTrace(projectId);
+ download(file.filename, file.csv_text);
+ setNotice([`CSV 파일을 내려받았습니다. 이 파일에 담기지 않는 것: ${file.loss_manifest.map(lossText).join(" / ")}`]);
+ } catch { setProblem("CSV를 내보내지 못했습니다. 잠시 뒤 다시 시도하세요."); }
+ };
+ const applied = (result: ImportApplied) => {
+ setImporting(false);
+ if (result.trace) client.setQueryData(traceKey(projectId), result.trace); else void client.invalidateQueries({ queryKey: traceKey(projectId) });
+ void client.invalidateQueries({ queryKey: hypothesisLinksKey(projectId) });
+ const lines = result.applied ? result.verdict_changes.map(change => verdictChangeSentence(change, result.trace ? titleOf(result.trace) : name)) : [];
+ setNotice([result.applied ? "반영했습니다." : "바뀌는 것이 없어 반영하지 않았습니다.", ...(lines.length > 0 ? ["바뀐 판정:", ...lines] : [])]);
+ };
+ const cells = (kind: string, verdict: Verdict | undefined, label: string) =>
+ item.subject_kind === kind && item.subject_id === verdict.subject_id) : undefined} onClose={setClosing}/>;
+ const rowMark = (kind: string, id: string, base: string) => ({ "data-trace-row": `${kind}:${id}`,
+ className: focusRow?.kind === kind && focusRow.id === id ? `${base} trace-focus` : base });
+ const expander = (key: string, label: string) => {
+ const isOpen = open.has(key);
+ return <> toggle(key)}/> >;
+ };
+ return
+ {isSyntheticTrace(view) &&
+ 합성 데이터입니다. 실제로 측정한 값이 아니고, 승인된 값도 아니며, 공학 용도로 쓸 수 없습니다.{SYNTHETIC_NOTICE} }
+
+
+ {problem &&
{problem} }
+ {notice &&
{notice.map(line => {line}
)} }
+ {groups.length === 0 ?
아직 추적표가 없습니다. 'CSV 들여오기'에서 '새로 만들기'로 파일을 들여오면 요구사항과 기준이 여기에 나타납니다. :
+
+ 요구사항별 기준, 고른 결과, 판정, 현재성, 사람 확인, 근거 위치
+
+ 이력 요구사항 / 기준 (조건) 고른 결과
+ 판정 현재성 사람 확인 근거 위치
+ {groups.map((group, at) => {
+ const groupKey = group.item ? `REQUIREMENT-${group.item.item_id}` : `loose-${at}`;
+ return
+ {group.item ? expander(groupKey, group.item.title || group.item.item_id) : }
+ 요구사항 {group.item ? (group.item.title || group.item.item_id) : "요구사항에 연결되지 않은 기준"}
+ {group.item && <>{group.item.item_id} >}
+ {groupSummary(group.criteria)}
+ {group.item ? cells("REQUIREMENT", group.verdict, group.item.title || group.item.item_id) : <>— — — — >}
+ {group.item && open.has(groupKey) && }
+ {group.criteria.map(row => {
+ const key = `CRITERION-${row.item.item_id}`;
+ const label = row.item.title || row.item.item_id;
+ return
+ {expander(key, label)}
+ {label}
+ {ruleText(row.rule)}{row.rule ? <> · 조건 {describeConditionText(row.rule.condition)} > : null}
+ {row.chosen.length === 0 ? 고른 결과 없음 : row.chosen.map(result => {resultText(result)}{timeText(result.observed_at)} 관측
)}
+ {cells("CRITERION", row.verdict, label)}
+ {open.has(key) && }
+ ;
+ })}
+ ;
+ })}
+
}
+
+ 0)} projectId={projectId} isOpen={importing} hasTrace={groups.length > 0} onClose={() => setImporting(false)} onApplied={applied}/>
+ { confirm.reset(); setConfirming(null); }} onSubmit={rationale => confirm.mutate(rationale)}/>
+ setEvidence(null)}/>
+ setClosing(null)}/>
+ ;
+}
diff --git a/apps/web/src/components/effectDeclaration.test.ts b/apps/web/src/components/effectDeclaration.test.ts
new file mode 100644
index 0000000..1d2721a
--- /dev/null
+++ b/apps/web/src/components/effectDeclaration.test.ts
@@ -0,0 +1,33 @@
+import { expect, it } from "vitest";
+import { EFFECT_GROUPS, effectDeclaration, prohibitedChosen } from "./effectDeclaration";
+
+const ALL_KEYS = [
+ "changes_official_kpi", "grants_waiver", "changes_safety_threshold", "finalizes_model_weights",
+ "external_write", "physical_action", "changes_official_baseline", "operational_equipment_change",
+ "runs_untrusted_code", "sandbox_required", "changes_local_draft",
+];
+
+it("lists every effect the server reads, with a plain label and no code value", () => {
+ const keys = EFFECT_GROUPS.flatMap(group => group.effects.map(effect => effect.key));
+ expect([...keys].sort()).toEqual([...ALL_KEYS].sort());
+ for (const group of EFFECT_GROUPS) for (const effect of group.effects) expect(effect.label).not.toMatch(/[a-z]_[a-z]|R[0-4]/);
+});
+
+it("sends only what was chosen, and says the list is not complete, until the person confirms it is", () => {
+ expect(effectDeclaration(new Set(), false)).toEqual({ effect_completeness_confirmed: false });
+ expect(effectDeclaration(new Set(["grants_waiver"]), false)).toEqual({ grants_waiver: true, effect_completeness_confirmed: false });
+});
+
+it("names every effect as true or false once the person says the list is complete", () => {
+ const none = effectDeclaration(new Set(), true);
+ expect(Object.keys(none).sort()).toEqual([...ALL_KEYS, "effect_completeness_confirmed"].sort());
+ expect(ALL_KEYS.every(key => none[key] === false)).toBe(true);
+ expect(none.effect_completeness_confirmed).toBe(true);
+ const some = effectDeclaration(new Set(["external_write", "physical_action"]), true);
+ expect(Object.entries(some).filter(([, value]) => value).map(([key]) => key).sort()).toEqual(["effect_completeness_confirmed", "external_write", "physical_action"]);
+});
+
+it("finds the effects that no one may delegate", () => {
+ expect(prohibitedChosen(new Set(["external_write"]))).toEqual([]);
+ expect(prohibitedChosen(new Set(["external_write", "changes_official_kpi"]))).toEqual(["changes_official_kpi"]);
+});
diff --git a/apps/web/src/components/effectDeclaration.ts b/apps/web/src/components/effectDeclaration.ts
new file mode 100644
index 0000000..3413754
--- /dev/null
+++ b/apps/web/src/components/effectDeclaration.ts
@@ -0,0 +1,52 @@
+/**
+ * What a person says an action request would do. The screen only collects it and sends it as written; the rules
+ * on the server decide the risk from it, so nothing here works a risk out. Nothing is ticked for the person.
+ */
+
+export type EffectChoice = { key: string; label: string };
+export type EffectGroup = { title: string; note?: string; effects: EffectChoice[] };
+
+export const COMPLETE_KEY = "effect_completeness_confirmed";
+export const COMPLETE_LABEL = "이 행동이 하는 일을 빠짐없이 적었습니다";
+
+export const EFFECT_GROUPS: EffectGroup[] = [
+ { title: "THOTH에서 실행할 수 없는 일", note: "고르면 실행할 수 없는 행동으로 분류됩니다.", effects: [
+ { key: "changes_official_kpi", label: "공식 성과 지표를 바꿈" },
+ { key: "grants_waiver", label: "면제를 내림" },
+ { key: "changes_safety_threshold", label: "안전 기준값을 바꿈" },
+ { key: "finalizes_model_weights", label: "모델 가중치를 확정함" },
+ ] },
+ { title: "사람의 승인이 필요한 일", effects: [
+ { key: "external_write", label: "외부 시스템에 씀" },
+ { key: "physical_action", label: "장비를 물리적으로 조작함" },
+ { key: "changes_official_baseline", label: "공식 기준선을 바꿈" },
+ { key: "operational_equipment_change", label: "운용 장비 설정을 바꿈" },
+ ] },
+ { title: "실행 환경", effects: [
+ { key: "runs_untrusted_code", label: "검증 안 된 코드를 실행함" },
+ { key: "sandbox_required", label: "격리된 환경이 필요함" },
+ ] },
+ { title: "그 밖에", effects: [
+ { key: "changes_local_draft", label: "로컬 초안만 바꿈" },
+ ] },
+];
+
+const FORBIDDEN = EFFECT_GROUPS[0].effects.map(effect => effect.key);
+
+/** The chosen effects that THOTH cannot run (shown as a notice before sending; the server still decides). */
+export function prohibitedChosen(chosen: ReadonlySet): string[] {
+ return FORBIDDEN.filter(key => chosen.has(key));
+}
+
+/**
+ * The declaration to send. Once the person says the list is complete, every effect is named true or false; before
+ * that only the chosen ones are sent, and the list is said not to be complete.
+ */
+export function effectDeclaration(chosen: ReadonlySet, complete: boolean): Record {
+ const keys = EFFECT_GROUPS.flatMap(group => group.effects.map(effect => effect.key));
+ const named = complete ? keys : keys.filter(key => chosen.has(key));
+ return { ...Object.fromEntries(named.map(key => [key, chosen.has(key)])), [COMPLETE_KEY]: complete };
+}
+
+export const FORBIDDEN_NOTICE = "이 효과는 THOTH에서 실행할 수 없는 행동으로 분류됩니다.";
+
diff --git a/apps/web/src/components/history/DecisionDeltaComparison.tsx b/apps/web/src/components/history/DecisionDeltaComparison.tsx
index 53dc47b..40fc319 100644
--- a/apps/web/src/components/history/DecisionDeltaComparison.tsx
+++ b/apps/web/src/components/history/DecisionDeltaComparison.tsx
@@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
import type { HistorySelection } from "../../api/historyModels";
import { readDecisionDelta, type DecisionDelta, type ResultIdentity } from "../../api/researchFollowup";
+import { describeHistoryReasons } from "./historyReasonText";
import { currentnessLabel } from "./historyPresentation";
import { CriteriaChange } from "./CriteriaChange";
@@ -130,16 +131,17 @@ export function DecisionDeltaView({ delta }: { delta: DecisionDelta }) {
>)}
기록된 변경 이유
{delta.reason_state === "UNKNOWN_REASON" ?
변경 이유가 기록되지 않았습니다. 이유를 추정하지 않습니다.
- : <>{delta.reason_codes.length ?
{delta.reason_codes.slice(0, 3).map((code, index) => {code} )} :
기록된 이유 문구가 없습니다.
}
- {(delta.reason_codes.length > 3 || delta.reason_refs.length > 0) &&
setReasonDetailsOpen(value => !value)}>
+ : <>{delta.reason_codes.length ? {describeHistoryReasons(delta.reason_codes).slice(0, 3).map(line => {line} )} : 기록된 이유 문구가 없습니다.
}
+ {(describeHistoryReasons(delta.reason_codes).length > 3 || delta.reason_refs.length > 0) && setReasonDetailsOpen(value => !value)}>
{reasonDetailsOpen ? "이유 상세 닫기" : `이유 상세 보기${delta.reason_refs.length ? ` · 연결 기록 ${delta.reason_refs.length}건` : ""}`}
}
- {reasonDetailsOpen && delta.reason_codes.length > 3 && {delta.reason_codes.slice(3).map((code, index) => {code} )} }
+ {reasonDetailsOpen && describeHistoryReasons(delta.reason_codes).length > 3 && {describeHistoryReasons(delta.reason_codes).slice(3).map(line => {line} )} }
{reasonDetailsOpen && delta.reason_refs.length > 0 && 연결 기록: {delta.reason_refs.map((ref, index) => {
const kind = typeof ref.entity_type === "string" ? ref.entity_type : "기록";
const id = typeof ref.entity_id === "string" ? ref.entity_id : "식별자 없음";
return {index > 0 ? " · " : ""}{kind} {id} ;
- })}
}>}
+ })}}
+ {delta.reason_codes.length > 0 && 기술 정보 {delta.reason_codes.map((code, index) => {code} )} }>}
비교 전: {currentnessLabel(delta.basis_currentness.before ?? { state: "UNKNOWN_BASIS", reasons: [] })} · 비교 후: {currentnessLabel(delta.basis_currentness.after ?? { state: "UNKNOWN_BASIS", reasons: [] })}
;
diff --git a/apps/web/src/components/history/HistoryContent.tsx b/apps/web/src/components/history/HistoryContent.tsx
index 105a909..3dcc30e 100644
--- a/apps/web/src/components/history/HistoryContent.tsx
+++ b/apps/web/src/components/history/HistoryContent.tsx
@@ -1,5 +1,6 @@
import { objectList, objectValue, stringValues, textValue } from "../../api/presentation";
import { Disclosure } from "../Disclosure";
+import { ReadableList } from "../ReadableList";
const prose: Record = {
statement: "가설", observed_problem: "관측한 문제", evidence_basis: "근거의 바탕", uncertainty: "남은 불확실성", interpretation: "관측 해석", resolution: "정리한 결론",
@@ -41,7 +42,7 @@ export function HistoryContent({ content }: { content: Record }
{counterQuestions.length > 0 && 반대 근거를 확인할 질문 {counterQuestions.map((question, i) => {question} )} }
{hypotheses.length > 0 && 당시 검토한 가설 {hypotheses.map((item, i) => {textValue(item.statement)} )} }
{actions.length > 0 && 당시 제안한 행동 {actions.map((item, i) => {readableText(item.specification)} )} }
- {gaps.length > 0 && 아직 확인할 내용 {gaps.map((gap, i) => {gap} )} }
+ {gaps.length > 0 && }
{steps.length > 0 && 행동 계획 {steps.map((step, i) => {readableText(step) || textValue(step.label) || "세부 내용은 저장 기록에서 확인할 수 있습니다."} )} }
{members.length > 0 && 목록의 구성 이 버전은 {members.length}개 항목의 연결을 포함합니다. 연결된 항목의 내용까지 같은 시점으로 복원되는 것은 아닙니다.
{members.map(ref => {ref} )} }
{!hasContent && 이 기록에 표시할 설명이 없습니다. 저장된 값은 세부 기록에서 확인할 수 있습니다.
}
diff --git a/apps/web/src/components/history/ProjectReviewList.test.tsx b/apps/web/src/components/history/ProjectReviewList.test.tsx
new file mode 100644
index 0000000..637ec07
--- /dev/null
+++ b/apps/web/src/components/history/ProjectReviewList.test.tsx
@@ -0,0 +1,28 @@
+// @vitest-environment jsdom
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, expect, it, vi } from "vitest";
+import { ProjectReviewList } from "./ProjectReviewList";
+
+const item = { item_id: "t:1", project_id: "project:p", thread_id: "thread:t", request_revision_digest: "a".repeat(64), result_revision_digest: null, title: "추가 질문에 대한 답변", priority: "HIGH",
+ reason_codes: ["SOURCE_BASIS_CHANGED", "SOMETHING_NEW_HAPPENED", "MEMORY_CORRECTED_AFTER_RESULT"], currentness: { state: "REVIEW_REQUIRED", reasons: [] },
+ next_user_action: { schema_version: "1.0.0", action_type: "REVIEW_CURRENTNESS", label: "현재 자료 기준으로 다시 확인", reason_codes: [], requires_permission: false, target: null, basis: {} } };
+vi.mock("../../api/researchFollowup", async importOriginal => ({ ...(await importOriginal()),
+ readProjectReviewList: async () => ({ items: [item], next_cursor: null, unread_supported: true, assignment_supported: true }) }));
+
+let root: Root | undefined; let container: HTMLDivElement;
+afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; container?.remove(); });
+
+it("shows the reasons as sentences and keeps the raw codes under technical details", async () => {
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+ container = document.createElement("div"); document.body.append(container); root = createRoot(container);
+ await act(async () => root!.render( undefined}/> ));
+ for (let i = 0; i < 10; i++) await act(async () => { await new Promise(r => setTimeout(r, 10)); });
+ const copy = container.cloneNode(true) as HTMLElement;
+ copy.querySelectorAll("details").forEach(node => node.remove());
+ expect(copy.textContent).toContain("답변이 쓴 자료가 이후 바뀌었습니다.");
+ expect(copy.textContent).toContain("기록된 다른 이유가 있습니다.");
+ expect(copy.textContent).not.toMatch(/SOURCE_BASIS_CHANGED|SOMETHING_NEW_HAPPENED|MEMORY_CORRECTED_AFTER_RESULT/);
+ expect(container.querySelector("details")?.textContent).toContain("SOURCE_BASIS_CHANGED");
+});
diff --git a/apps/web/src/components/history/ProjectReviewList.tsx b/apps/web/src/components/history/ProjectReviewList.tsx
index c13e4e4..b17a274 100644
--- a/apps/web/src/components/history/ProjectReviewList.tsx
+++ b/apps/web/src/components/history/ProjectReviewList.tsx
@@ -4,6 +4,7 @@ import { readProjectReviewList, type ProjectReviewItem } from "../../api/researc
import type { HistorySelection } from "../../api/historyModels";
import { currentnessLabel, currentnessTone } from "./historyPresentation";
import { nextActionLabel } from "../statusLabels";
+import { describeHistoryReasons } from "./historyReasonText";
function priorityLabel(priority: ProjectReviewItem["priority"]) {
if (priority === "HIGH") return "높음";
@@ -23,14 +24,14 @@ export function ProjectReviewList({ projectId, onSelect }: { projectId: string;
const items = pages.flatMap(page => page.items);
if (query.error && pages.length === 0) return 검토 필요 목록을 읽지 못했습니다. 연구 이력은 계속 볼 수 있습니다. void query.refetch()}>다시 읽기 ;
return
- 검토 필요 서버가 현재 기준 재확인이나 gap 검토가 필요하다고 표시한 답변입니다.
void query.refetch()} loading={query.isRefetching}>새로 읽기
+ 검토 필요 서버가 현재 기준 재확인이나 남은 확인 사항 검토가 필요하다고 표시한 답변입니다.
void query.refetch()} loading={query.isRefetching}>새로 읽기
{query.isPending ? 검토 목록을 읽는 중…
: items.length === 0 ? 현재 프로젝트에 표시할 검토 필요 답변이 없습니다.
: {items.map(item =>
{priorityLabel(item.priority)} {currentnessLabel(item.currentness)}
{item.title}
{nextActionLabel(item.next_user_action)}
- 기술 정보 {item.next_user_action.label}
- {item.reason_codes.length > 0 && {item.reason_codes.slice(0, 3).join(" · ")} }
+ {item.reason_codes.length > 0 && {describeHistoryReasons(item.reason_codes).slice(0, 3).map(line => {line} )} }
+ 기술 정보 {item.next_user_action.label} {item.reason_codes.map(code => {code} )}
onSelect(selectionOf(item))}>해당 답변 열기
)}
}
{query.error && pages.length > 0 && 검토 목록의 다음 구간을 읽지 못했습니다. }
diff --git a/apps/web/src/components/history/historyReasonText.test.ts b/apps/web/src/components/history/historyReasonText.test.ts
new file mode 100644
index 0000000..60b18bf
--- /dev/null
+++ b/apps/web/src/components/history/historyReasonText.test.ts
@@ -0,0 +1,49 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+import { describeHistoryReason, describeHistoryReasons, isKnownHistoryReason } from "./historyReasonText";
+
+const source = (path: string) => readFileSync(new URL("../../../../../src/thoth/" + path, import.meta.url), "utf8");
+const RAW_CODE = /[A-Z]{3,}_[A-Z_]{3,}/;
+// Lifecycle states that appear on the same lines as reasons but are not reasons themselves.
+const NOT_REASONS = new Set(["REVIEW_REQUIRED", "INVALIDATED", "UNAVAILABLE", "UNKNOWN_REASON", "RECORDED", "UNKNOWN_BASIS_STATE"]);
+
+/** Codes a screen can receive in reason_codes, read from where the backend writes them:
+ * research_freshness.py (currentness reasons), research_coverage.py (coverage reasons via reasons.append),
+ * research_followup_projection.py (review list and comparison), history_projection.py (RESULT_SUPERSEDED),
+ * research_followup.py (comparison input error). */
+function backendCodes(): string[] {
+ const found = new Set();
+ const files: [string, RegExp][] = [
+ ["application/services/research_freshness.py", /reasons=\(\s*"([A-Z_]+)"|currentness\.reasons, "([A-Z_]+)"/g],
+ ["application/services/research_coverage.py", /reasons\.append\("([A-Z_]+)"\)/g],
+ ["application/services/research_followup_projection.py", /reason_codes=\(\s*"([A-Z_]+)"|\("([A-Z_]+)",\)\)/g],
+ ["application/services/history_projection.py", /\*currentness\.reasons, "([A-Z_]+)"/g],
+ ];
+ for (const [path, pattern] of files) {
+ for (const match of source(path).matchAll(pattern)) {
+ const code = match[1] ?? match[2];
+ if (code && !NOT_REASONS.has(code)) found.add(code);
+ }
+ }
+ return [...found];
+}
+
+describe("history reason wording", () => {
+ it("has a sentence for every reason code the backend writes", () => {
+ const codes = backendCodes();
+ expect(codes.length).toBeGreaterThan(25);
+ for (const code of codes) {
+ expect(isKnownHistoryReason(code), code).toBe(true);
+ expect(describeHistoryReason(code), code).not.toMatch(RAW_CODE);
+ }
+ });
+
+ it("says something general for an unknown code and shows research-written text as it is", () => {
+ expect(describeHistoryReason("SOMETHING_NEW_HAPPENED")).toBe("기록된 다른 이유가 있습니다.");
+ expect(describeHistoryReason("표본이 작아 결론을 보류했습니다")).toBe("표본이 작아 결론을 보류했습니다");
+ });
+
+ it("lists each wording once", () => {
+ expect(describeHistoryReasons(["SOMETHING_NEW_HAPPENED", "ANOTHER_NEW_ONE", "SOURCE_BASIS_CHANGED"])).toEqual(["기록된 다른 이유가 있습니다.", "답변이 쓴 자료가 이후 바뀌었습니다."]);
+ });
+});
diff --git a/apps/web/src/components/history/historyReasonText.ts b/apps/web/src/components/history/historyReasonText.ts
new file mode 100644
index 0000000..6b6b849
--- /dev/null
+++ b/apps/web/src/components/history/historyReasonText.ts
@@ -0,0 +1,57 @@
+/** Plain-language text for the reason codes the research-history screens receive. Raw codes stay in the technical details. */
+
+const reasons: Record = {
+ // Currentness of an answer (research_freshness.py)
+ HEAD_UNAVAILABLE: "현재 기록의 최신 상태를 읽을 수 없습니다.",
+ DEPENDENCY_INVALIDATED: "이 답변이 기대는 다른 기록이 더 이상 유효하지 않습니다.",
+ DEPENDENCY_REVIEW_REQUIRED: "이 답변이 기대는 다른 기록을 다시 검토해야 합니다.",
+ LEGACY_BASIS_UNKNOWN: "옛 기록이라 어떤 자료 기준으로 만든 답변인지 확인할 수 없습니다.",
+ RESULT_OPERATION_UNAVAILABLE: "이 답변을 만든 실행 기록을 찾을 수 없습니다.",
+ ATTEMPT_ENDED_WITH_CHECKPOINT: "조사가 끝까지 가지 못하고 중간 저장 상태에서 멈췄습니다.",
+ REQUEST_BASIS_CHANGED: "질문을 한 뒤 질문의 기준이 바뀌었습니다.",
+ POLICY_OR_CUTOFF_CHANGED: "프로젝트의 정책이나 기준시점이 바뀌었습니다.",
+ SOURCE_BASIS_CHANGED: "답변이 쓴 자료가 이후 바뀌었습니다.",
+ MEMORY_CORRECTED_AFTER_RESULT: "이 결과가 쓴 기억이 이후 정정되었습니다.",
+ BASIS_INCOMPLETE: "답변의 자료 기준 기록이 불완전합니다.",
+ OWNER_UNAVAILABLE: "이 기록을 가진 항목을 읽을 수 없습니다.",
+ REQUEST_SOURCE_OR_ACCESS_BASIS_CHANGED: "질문 뒤에 자료나 접근 범위가 바뀌었습니다.",
+ UNKNOWN_BASIS: "현재 자료 기준을 확인할 수 없습니다.",
+ RESULT_SUPERSEDED: "더 새로운 답변이 이 답변을 대체했습니다.",
+ // Coverage of what the question needs (research_coverage.py)
+ MANDATORY_RULE_COVERAGE_MISSING: "반드시 확인해야 하는 규칙 중 아직 다루지 못한 것이 있습니다.",
+ PROFILE_DECISION_REQUIRED: "적용할 기준을 사용자가 먼저 정해야 합니다.",
+ REQUIREMENT_INTERPRETATION_INCOMPLETE: "질문이 요구하는 것을 아직 다 풀어내지 못했습니다.",
+ REQUIREMENT_GAPS: "아직 충족하지 못한 요구 항목이 있습니다.",
+ GAP_MAPPING_INCOMPLETE: "빠진 부분이 어느 요구 항목에 해당하는지 아직 연결하지 못했습니다.",
+ REQUIRED_CONTEXT_OMITTED: "답하는 데 꼭 필요한 맥락이 빠져 있습니다.",
+ REQUIRED_SCOPE_UNEXAMINED: "꼭 살펴봐야 하는 범위를 아직 살펴보지 못했습니다.",
+ EVIDENCE_CONFLICT: "근거끼리 서로 맞지 않는 부분이 있습니다.",
+ REVIEWED_CONFLICT_HISTORY: "근거가 충돌했던 기록이 있고 검토를 거쳤습니다.",
+ EXPLICIT_PUBLIC_SEARCH: "사용자가 공개 웹 검색을 직접 요청했습니다.",
+ // Review list and comparison projections (research_followup_projection.py, research_followup.py)
+ ASSESSMENT_UNAVAILABLE: "이 항목의 평가 기록을 읽을 수 없습니다.",
+ COVERAGE_UNAVAILABLE: "근거 충족 여부 기록을 읽을 수 없습니다.",
+ REQUIREMENT_SET_UNAVAILABLE: "요구 항목 기록을 읽을 수 없습니다.",
+ RESULT_NOT_PRODUCED: "아직 답변이 만들어지지 않았습니다.",
+ RESULT_COMPARE_REQUIRES_TWO_IDENTITIES: "비교하려면 서로 다른 두 답변이 필요합니다.",
+ FAILED: "조사가 실패로 끝났습니다.",
+ CANCELLED: "조사가 취소되었습니다.",
+ // How an attempt ended (research_attempt_runner.py, research_failure.py)
+ BOUNDED_RESEARCH_COMPLETE: "정해진 범위의 조사를 마쳤습니다.",
+ RESEARCH_INTERNAL_ERROR: "조사 중 예상하지 못한 내부 오류가 있었습니다.",
+};
+
+const CODE_SHAPE = /^[A-Z][A-Z0-9_]+$/;
+
+/** A sentence for one recorded reason. Text that is not a code (a gap or next step the research wrote) is shown as it is. */
+export function describeHistoryReason(code: string): string {
+ if (reasons[code]) return reasons[code];
+ return CODE_SHAPE.test(code) ? "기록된 다른 이유가 있습니다." : code;
+}
+
+/** Sentences for a list of reasons, each wording once, in the order given. */
+export function describeHistoryReasons(codes: readonly string[]): string[] {
+ return [...new Set(codes.map(describeHistoryReason))];
+}
+
+export const isKnownHistoryReason = (code: string) => code in reasons;
diff --git a/apps/web/src/components/hypothesisLinkText.test.ts b/apps/web/src/components/hypothesisLinkText.test.ts
new file mode 100644
index 0000000..6088807
--- /dev/null
+++ b/apps/web/src/components/hypothesisLinkText.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, it } from "vitest";
+import { linkChangeLine, linkSiblings, noteRequired, RECHECK_REASONS, recheckLine, staleActionNote, staleCount, type HypothesisLink } from "./hypothesisLinkText";
+
+const BANNED = /재시험 필수|원인은|기준을 낮추|통과 가능|승인됨|종결|해결|면제|AI 추천/;
+const LEAK = /HOLD_|FAIL_|PASS_|STALE|CONTENT_CHANGED|FLIPPED|SUBJECT_MISSING|NEEDS_RESEARCH|STILL_MATCHES|UNRELATED|verdict|\bnull\b|undefined/;
+
+const link = (patch: Partial = {}): HypothesisLink => ({
+ hypothesis_id: "h1", hypothesis_revision_digest: "d".repeat(64), statement: "가설", subject_kind: "CRITERION", subject_id: "C-1", subject_title: "비 조건 탐지율",
+ state: "STALE", change: "CONTENT_CHANGED", link_state: "HOLD_NO_RESULT", current_state: "FAIL_COMPUTED", current_verdict_revision: "r".repeat(64), recheck: null, ...patch,
+});
+
+describe("what a changed verdict says on a hypothesis", () => {
+ it("says the old basis, what it was and what it is now", () => {
+ expect(linkChangeLine(link())).toBe("이전 근거 기준 — 판정이 바뀌었습니다(이전: 보류 · 결과 없음 → 지금: 기준 미달)");
+ });
+ it("says so when met and failed swapped, and when the row is gone", () => {
+ const flipped = linkChangeLine(link({ change: "FLIPPED", link_state: "PASS_COMPUTED", current_state: "FAIL_COMPUTED" }));
+ expect(flipped).toContain("이전: 기준 충족 → 지금: 기준 미달");
+ expect(flipped).toContain("충족과 미달이 뒤바뀌었습니다");
+ expect(linkChangeLine(link({ change: "SUBJECT_MISSING", current_state: null }))).toBe("이전 근거 기준 — 이 가설이 나온 추적표 줄이 지금은 없습니다(이전: 보류 · 결과 없음)");
+ });
+ it("has no line for a current link, none for a link-less hypothesis and a rechecked one is not called old", () => {
+ expect(linkChangeLine(link({ state: "CURRENT", change: null }))).toBeNull();
+ expect(linkChangeLine(link({ state: "RECHECKED", recheck: { reason_code: "STILL_MATCHES", note: "", actor_id: "human:local-user", created_at: "2026-10-06T00:00:00+00:00", flipped: false } }))).toBeNull();
+ });
+ it("never shows a code, a forbidden claim or a probability", () => {
+ const lines = [link(), link({ change: "FLIPPED", link_state: "PASS_COMPUTED" }), link({ change: "SUBJECT_MISSING", current_state: null }), link({ link_state: "SOMETHING_NEW" })]
+ .flatMap(item => [linkChangeLine(item) ?? ""]);
+ for (const reason of RECHECK_REASONS) lines.push(reason.label, reason.hint);
+ for (const line of lines) { expect(line).not.toMatch(BANNED); expect(line).not.toMatch(LEAK); expect(line).not.toMatch(/\d\s*%|확률/); }
+ });
+});
+
+describe("the recheck line", () => {
+ it("names the reason in words, who and when, and says a research request keeps it old", () => {
+ const kept = recheckLine(link({ state: "RECHECKED", recheck: { reason_code: "UNRELATED", note: "", actor_id: "human:local-user", created_at: "2026-10-06T01:02:03+00:00", flipped: false } }));
+ expect(kept).toContain("다시 확인됨");
+ expect(kept).toContain("판정이 바뀌었지만 이 가설과 관계없음");
+ const asked = recheckLine(link({ recheck: { reason_code: "NEEDS_RESEARCH", note: "", actor_id: "human:local-user", created_at: "2026-10-06T01:02:03+00:00", flipped: false } }));
+ expect(asked).toContain("다시 조사가 필요함");
+ expect(asked).toContain("이전 근거 기준으로 남습니다");
+ expect(recheckLine(link())).toBeNull();
+ });
+});
+
+describe("the words a recheck needs", () => {
+ it("asks for words on 'other' and on keeping a link across met/failed, and never on a research request", () => {
+ expect(noteRequired("OTHER", false)).toBe(true);
+ expect(noteRequired("UNRELATED", false)).toBe(false);
+ expect(noteRequired("STILL_MATCHES", true)).toBe(true);
+ expect(noteRequired("UNRELATED", true)).toBe(true);
+ expect(noteRequired("NEEDS_RESEARCH", true)).toBe(false);
+ expect(RECHECK_REASONS.map(reason => reason.code)).toEqual(["UNRELATED", "STILL_MATCHES", "NEEDS_RESEARCH", "OTHER"]);
+ });
+});
+
+describe("hypotheses of the same change", () => {
+ const others = [link({ hypothesis_id: "h2" }), link({ hypothesis_id: "h3", subject_id: "C-2" }), link({ hypothesis_id: "h4", current_verdict_revision: "x".repeat(64) }), link({ hypothesis_id: "h5", state: "CURRENT", change: null })];
+ it("picks the old hypotheses of the same row and the same new verdict, and not the current ones", () => {
+ expect(linkSiblings(link(), [link(), ...others]).map(item => item.hypothesis_id)).toEqual(["h2"]);
+ });
+ it("counts old hypotheses per row", () => {
+ expect(staleCount([link(), ...others], "CRITERION", "C-1")).toBe(3);
+ expect(staleCount([link(), ...others], "CRITERION", "C-2")).toBe(1);
+ expect(staleCount([link(), ...others], "REQUIREMENT", "C-1")).toBe(0);
+ expect(staleCount(undefined, "CRITERION", "C-1")).toBe(0);
+ });
+});
+
+describe("a changed basis under the same verdict", () => {
+ it("says the verdict state is the same and only the basis changed", () => {
+ const line = linkChangeLine(link({ change: "EVIDENCE_ONLY", link_state: "PASS_COMPUTED", current_state: "PASS_COMPUTED" }));
+ expect(line).toBe("이전 근거 기준 — 판정 상태는 같고 근거만 바뀌었습니다(판정: 기준 충족)");
+ expect(line).not.toMatch(BANNED);
+ expect(line).not.toMatch(LEAK);
+ });
+ it("names an action's stale hypotheses, and the kind of change only when every one of them is a changed basis", () => {
+ const stale = link({ hypothesis_id: "h1" });
+ const basis = link({ hypothesis_id: "h2", change: "EVIDENCE_ONLY", link_state: "PASS_COMPUTED", current_state: "PASS_COMPUTED" });
+ const current = link({ hypothesis_id: "h3", state: "CURRENT", change: null });
+ const kept = link({ hypothesis_id: "h4", state: "RECHECKED" });
+ expect(staleActionNote(["h3", "h4", "unknown"], [stale, basis, current, kept])).toBeNull();
+ expect(staleActionNote(undefined, [stale])).toBeNull();
+ expect(staleActionNote(["h1"], undefined)).toBeNull();
+ const one = staleActionNote(["h1", "h3"], [stale, current])!;
+ expect(one).toContain("이 행동이 기대는 가설 1개가 이전 근거 기준입니다");
+ expect(one).not.toContain("근거만 바뀜");
+ expect(staleActionNote(["h2"], [basis])).toContain("판정 상태는 같고 근거만 바뀜");
+ expect(staleActionNote(["h1", "h2"], [stale, basis])).not.toContain("근거만 바뀜");
+ for (const note of [one, staleActionNote(["h2"], [basis])!]) {
+ expect(note).toContain("승인 요청과 결정, 실행이 막힙니다");
+ expect(note).not.toMatch(BANNED);
+ expect(note).not.toMatch(LEAK);
+ }
+ });
+});
diff --git a/apps/web/src/components/hypothesisLinkText.ts b/apps/web/src/components/hypothesisLinkText.ts
new file mode 100644
index 0000000..d2e84ba
--- /dev/null
+++ b/apps/web/src/components/hypothesisLinkText.ts
@@ -0,0 +1,54 @@
+import type { HypothesisLink, RecheckReasonCode } from "../api/hypothesisLink";
+export type { HypothesisLink, RecheckReasonCode } from "../api/hypothesisLink";
+import { actorLabel, timeText, verdictLook } from "./traceText";
+
+/** Plain words for a hypothesis that came from a trace row and what happened to that row's verdict. */
+
+export const RECHECK_REASONS: { code: RecheckReasonCode; label: string; hint: string }[] = [
+ { code: "UNRELATED", label: "판정이 바뀌었지만 이 가설과 관계없음", hint: "이 가설은 바뀐 부분과 상관이 없다고 봅니다." },
+ { code: "STILL_MATCHES", label: "새 결과도 이 가설과 맞음(근거 확인함)", hint: "바뀐 결과를 직접 보고, 가설이 그대로 맞는다고 확인했습니다." },
+ { code: "NEEDS_RESEARCH", label: "다시 조사가 필요함", hint: "이 가설은 계속 '이전 근거 기준'으로 남고, 새로 조사해야 한다고 적어 둡니다." },
+ { code: "OTHER", label: "기타", hint: "위에 없는 이유입니다. 이유를 글로 적어야 합니다." },
+];
+const reasonLabel = (code: RecheckReasonCode) => RECHECK_REASONS.find(item => item.code === code)?.label ?? "이유 기록됨";
+
+/** Free words are needed for "other", and to keep a link across a met/failed swap; asking for more research needs none. */
+export const noteRequired = (reason: RecheckReasonCode, flipped: boolean) => reason === "OTHER" || (flipped && reason !== "NEEDS_RESEARCH");
+
+const verdictText = (link: HypothesisLink, state: string | null) => (state === null ? "" : verdictLook(link.subject_kind, state).text);
+
+/** The line for a hypothesis whose verdict changed; null when it is current or a person kept it. */
+export function linkChangeLine(link: HypothesisLink): string | null {
+ if (link.state !== "STALE") return null;
+ if (link.change === "SUBJECT_MISSING") return `이전 근거 기준 — 이 가설이 나온 추적표 줄이 지금은 없습니다(이전: ${verdictText(link, link.link_state)})`;
+ if (link.change === "EVIDENCE_ONLY") return `이전 근거 기준 — 판정 상태는 같고 근거만 바뀌었습니다(판정: ${verdictText(link, link.current_state)})`;
+ const swap = link.change === "FLIPPED" ? " 충족과 미달이 뒤바뀌었습니다." : "";
+ return `이전 근거 기준 — 판정이 바뀌었습니다(이전: ${verdictText(link, link.link_state)} → 지금: ${verdictText(link, link.current_state)})${swap}`;
+}
+
+/** Who looked at this change and why, when somebody did. */
+export function recheckLine(link: HypothesisLink): string | null {
+ const event = link.recheck;
+ if (!event) return null;
+ const who = `${actorLabel(event.actor_id)} · ${timeText(event.created_at)}`;
+ if (link.state === "RECHECKED") return `다시 확인됨 — ${reasonLabel(event.reason_code)} · ${who}${event.note ? `: ${event.note}` : ""}`;
+ return `이유 기록: ${reasonLabel(event.reason_code)} · ${who}. 이 가설은 이전 근거 기준으로 남습니다.`;
+}
+
+/** Old hypotheses of the same row that stand on the same new verdict, so one decision can cover them. */
+export function linkSiblings(link: HypothesisLink, all: HypothesisLink[]): HypothesisLink[] {
+ return all.filter(item => item.hypothesis_id !== link.hypothesis_id && item.state === "STALE" && item.subject_kind === link.subject_kind
+ && item.subject_id === link.subject_id && item.current_verdict_revision === link.current_verdict_revision);
+}
+
+export function staleCount(links: HypothesisLink[] | undefined, kind: string, subjectId: string): number {
+ return (links ?? []).filter(item => item.state === "STALE" && item.subject_kind === kind && item.subject_id === subjectId).length;
+}
+
+/** The note on an action that rests on hypotheses whose verdict changed; null when none of them did. */
+export function staleActionNote(hypothesisIds: string[] | undefined, links: HypothesisLink[] | undefined): string | null {
+ const stale = (links ?? []).filter(item => item.state === "STALE" && (hypothesisIds ?? []).includes(item.hypothesis_id));
+ if (stale.length === 0) return null;
+ const same = stale.every(item => item.change === "EVIDENCE_ONLY") ? "(판정 상태는 같고 근거만 바뀜)" : "";
+ return `이 행동이 기대는 가설 ${stale.length}개가 이전 근거 기준입니다${same}. 사람이 가설을 다시 확인하기 전에는 이 행동의 승인 요청과 결정, 실행이 막힙니다.`;
+}
diff --git a/apps/web/src/components/hypothesisMatrix.ts b/apps/web/src/components/hypothesisMatrix.ts
new file mode 100644
index 0000000..32c8ddf
--- /dev/null
+++ b/apps/web/src/components/hypothesisMatrix.ts
@@ -0,0 +1,23 @@
+import type { HypothesisRow, Relation } from "./hypothesisView";
+import { relationOf } from "./hypothesisView";
+
+export type MatrixLine = { ref: string; index: number; cells: Relation[]; splits: boolean };
+
+/**
+ * One line per selected source. A line "splits" the hypotheses when at least two of its cells carry different
+ * evaluated relations (support, counter, not applicable); an unevaluated cell is not a relation, so it never splits.
+ */
+export function matrixLines(rows: HypothesisRow[], selected: string[]): MatrixLine[] {
+ return selected.map((ref, index) => {
+ const cells = rows.map(row => relationOf(row, ref));
+ return { ref, index, cells, splits: new Set(cells.filter(cell => cell !== "UNEVALUATED")).size > 1 };
+ });
+}
+
+/** One sentence under the table about counter-evidence, or null when the table has counter cells to speak for themselves. */
+export function noCounterNote(rows: HypothesisRow[], lines: MatrixLine[]): string | null {
+ if (lines.some(line => line.cells.includes("COUNTER"))) return null;
+ return rows.some(row => row.counterTerminal)
+ ? "반박 근거를 찾지 못함. 반박이 없다는 것이 지지된다는 뜻은 아닙니다."
+ : "반대 근거를 아직 찾아보지 않았습니다. 반박이 없다는 것이 지지된다는 뜻은 아닙니다.";
+}
diff --git a/apps/web/src/components/hypothesisReview.ts b/apps/web/src/components/hypothesisReview.ts
new file mode 100644
index 0000000..6cb540e
--- /dev/null
+++ b/apps/web/src/components/hypothesisReview.ts
@@ -0,0 +1,37 @@
+import { objectList, objectValue, stringValues, textValue } from "../api/presentation";
+
+/** The model's review of each hypothesis, as the result records it, and what the screen derives from it. */
+export type ReviewRelation = "SUPPORTED" | "QUALIFIED" | "UNSUPPORTED" | "INCONCLUSIVE";
+export type ReviewView = { relation: ReviewRelation; explanation: string; gaps: string[]; evidenceRefs: string[] };
+
+const RELATION_WORD: Record = { SUPPORTED: "지지", QUALIFIED: "조건부 지지", UNSUPPORTED: "불지지", INCONCLUSIVE: "결론 불가" };
+const isRelation = (value: string): value is ReviewRelation => Object.hasOwn(RELATION_WORD, value);
+export const reviewWord = (relation: ReviewRelation) => RELATION_WORD[relation];
+
+/** Review decisions by hypothesis id. A decision with an unknown relation is left out, never guessed. */
+export function reviewDecisions(result: Record): Map {
+ const found = new Map();
+ for (const decision of objectList(objectValue(result.hypothesis_review).decisions)) {
+ const relation = textValue(decision.relation);
+ const id = textValue(decision.hypothesis_id);
+ if (!id || !isRelation(relation)) continue;
+ found.set(id, { relation, explanation: textValue(decision.explanation), gaps: stringValues(decision.gaps), evidenceRefs: stringValues(decision.evidence_refs) });
+ }
+ return found;
+}
+
+/**
+ * True when a review was recorded and none of its decisions supports a hypothesis (not even conditionally).
+ * No recorded review says nothing: an old record is not "unconfirmed", it is unreviewed.
+ */
+export function causeUnconfirmed(result: Record): boolean {
+ const decisions = Array.from(reviewDecisions(result).values());
+ return decisions.length > 0 && !decisions.some(item => item.relation === "SUPPORTED" || item.relation === "QUALIFIED");
+}
+
+const STATUS_WORD: Record = {
+ DRAFT: "초안", GROUNDED_CANDIDATE: "근거가 있는 후보", COUNTEREVIDENCE_CHECKED: "반대 근거를 확인함", PREDICTION_BOUND: "예상 관찰을 정함",
+ TESTABLE: "시험할 수 있음", EMPIRICALLY_UPDATED: "시험 결과를 반영함", INCONCLUSIVE: "결론을 내지 못함", ELIMINATED_WITHIN_SCOPE: "이 범위에서는 배제됨",
+};
+/** Plain words for a hypothesis status; a status this screen does not know is not shown. */
+export const hypothesisStatusWord = (status: string): string | null => STATUS_WORD[status] ?? null;
diff --git a/apps/web/src/components/hypothesisView.ts b/apps/web/src/components/hypothesisView.ts
index 24cdff8..3bc2435 100644
--- a/apps/web/src/components/hypothesisView.ts
+++ b/apps/web/src/components/hypothesisView.ts
@@ -1,4 +1,5 @@
import { objectList, objectValue, stringValues, textValue } from "../api/presentation";
+import { reviewDecisions, type ReviewView } from "./hypothesisReview";
export type HypothesisRow = {
id: string;
@@ -12,8 +13,20 @@ export type HypothesisRow = {
/** Terminal of the counter-evidence search, or null when no search was recorded. */
counterTerminal: string | null;
tests: Record[];
+ /** What the observation was, as the result recorded it; "" for an older record without it. */
+ observed: string;
+ assumptions: string[];
+ predicted: string[];
+ missing: string[];
+ status: string;
+ /** What each distinguishing test expects if another explanation is right: the signals to drop this hypothesis. */
+ refutationSignals: string[];
+ /** The model's review of this hypothesis, or null when the result carries none. */
+ review: ReviewView | null;
+ /** What the generator said would make it give this hypothesis up (contract v3); an unconfirmed suggestion, empty for older results. */
+ aiRefutation: string[];
};
-export type Relation = "SUPPORT" | "COUNTER" | "UNEVALUATED";
+export type Relation = "SUPPORT" | "COUNTER" | "NOT_APPLICABLE" | "UNEVALUATED";
const unique = (values: string[]) => Array.from(new Set(values));
@@ -25,19 +38,27 @@ export function selectedEvidence(result: Record): string[] | nu
export function hypothesisRows(result: Record): HypothesisRow[] {
const selected = selectedEvidence(result);
+ const reviews = reviewDecisions(result);
return objectList(objectValue(result.portfolio).hypotheses).map(h => {
// The server records support as evidence_refs; older results used support_evidence_refs.
const support = unique([...stringValues(h.evidence_refs), ...stringValues(h.support_evidence_refs)]);
const counter = unique(stringValues(h.counterevidence_refs));
const review = objectValue(h.critical_review ?? objectValue(h.generation_details).critical_review);
- const linked = new Set([...support, ...counter]);
- return {
- id: textValue(h.hypothesis_id), statement: textValue(h.statement), uncertainty: textValue(h.uncertainty), support, counter,
- unevaluated: selected ? selected.filter(ref => !linked.has(ref)).length : null,
+ const id = textValue(h.hypothesis_id);
+ const judged = reviews.get(id) ?? null;
+ const tests = objectList(h.discriminating_tests);
+ const row: HypothesisRow = {
+ id, statement: textValue(h.statement), uncertainty: textValue(h.uncertainty), support, counter,
+ unevaluated: null,
counterQueries: stringValues(h.counterevidence_queries),
counterTerminal: textValue(review.terminal) || null,
- tests: objectList(h.discriminating_tests),
+ tests, observed: textValue(h.observed_problem), assumptions: stringValues(h.assumptions), predicted: stringValues(h.predicted_observations),
+ missing: stringValues(h.missing_evidence), status: textValue(h.status) || textValue(objectValue(h.generation_details).candidate_status),
+ refutationSignals: tests.map(test => textValue(test.expected_if_alternative)).filter(Boolean), review: judged,
+ aiRefutation: stringValues(h.refutation_conditions),
};
+ row.unevaluated = selected ? selected.filter(ref => relationOf(row, ref) === "UNEVALUATED").length : null;
+ return row;
});
}
@@ -50,7 +71,9 @@ export function counterSearchLine(result: Record): string | nul
return `반대 근거 탐색: ${searched.length}/${rows.length}개 가설 · 반박 ${searched.reduce((sum, row) => sum + row.counter.length, 0)}`;
}
+/** A source the review cited for this hypothesis and judged "not supporting" is shown as not applicable; a source never looked at stays unevaluated. */
export function relationOf(row: HypothesisRow, ref: string): Relation {
if (row.counter.includes(ref)) return "COUNTER";
- return row.support.includes(ref) ? "SUPPORT" : "UNEVALUATED";
+ if (row.support.includes(ref)) return "SUPPORT";
+ return row.review?.relation === "UNSUPPORTED" && row.review.evidenceRefs.includes(ref) ? "NOT_APPLICABLE" : "UNEVALUATED";
}
diff --git a/apps/web/src/components/judgmentRecordText.test.ts b/apps/web/src/components/judgmentRecordText.test.ts
new file mode 100644
index 0000000..ae3df55
--- /dev/null
+++ b/apps/web/src/components/judgmentRecordText.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it } from "vitest";
+import type { ClosureEvent, ClosureRow } from "../api/judgmentRecords";
+import { CLOSURE_KINDS, CLOSURE_NOTE, closureDisplay, ELIMINATION_NOTE, eliminationLine, eventKindLabel, eventLines, isMet, MATCH_KINDS, previousEvents } from "./judgmentRecordText";
+
+const BANNED = /통과|해결|승인 완료|검증 완료/;
+const LEAK = /HOLD_|FAIL_|PASS_|FIX_APPLIED|HUMAN_CLOSED|WAIVER_RECORDED|CONDITION_CHANGED|SINGLE|REPEATED|\bnull\b|undefined/;
+
+const event = (patch: Partial = {}): ClosureEvent => ({
+ event_id: "e1", subject_kind: "CRITERION", subject_id: "C-1", kind: "FIX_APPLIED", basis_ref: "ECN-12", note: "", scope: null, verdict_revision: "r".repeat(64),
+ verdict_digest: "d", verdict_state: "FAIL_COMPUTED", actor_id: "human:local-user", created_at: "2026-10-06T01:02:03+00:00", ...patch,
+});
+const row = (patch: Partial = {}, last: Partial = {}): ClosureRow => ({
+ subject_kind: "CRITERION", subject_id: "C-1", current_state: "FAIL_COMPUTED", effect_confirmed: false, effect_verdict_revision: null, verdict_changed_since: false, events: [event(last)], ...patch,
+});
+
+describe("the elimination mark", () => {
+ it("says once for one result and repeated for results of two tests, and nothing otherwise", () => {
+ expect(eliminationLine("SINGLE")).toBe("배제(결과 1건)");
+ expect(eliminationLine("REPEATED")).toBe("배제(반복 확인)");
+ expect(eliminationLine(null)).toBeNull();
+ });
+ it("says it is not a deletion and never gives a probability or a verdict on the cause", () => {
+ expect(ELIMINATION_NOTE).toContain("지워지지 않고");
+ for (const line of [ELIMINATION_NOTE, ...MATCH_KINDS.map(item => item.label)]) { expect(line).not.toMatch(BANNED); expect(line).not.toMatch(/\d\s*%|확률|원인은/); }
+ });
+});
+
+describe("the four closures a person can record", () => {
+ it("are named as the request names them, and none says passed, solved or approved-and-done", () => {
+ expect(CLOSURE_KINDS.map(item => item.label)).toEqual(["수정 반영됨(효과 미확인)", "사람 확인 종결(시험 결과 없음)", "편차·면제 승인(원 기준 미충족 유지)", "운용 조건 변경"]);
+ for (const item of CLOSURE_KINDS) { expect(item.label).not.toMatch(BANNED); expect(item.hint).not.toMatch(BANNED); }
+ expect(CLOSURE_NOTE).toContain("THOTH가 면제나 승인을 내리지 않으며");
+ });
+});
+
+describe("what is shown beside a row's verdict", () => {
+ it("writes one record as the same lines the top mark uses, so an older record reads the same way", () => {
+ const one = event({ note: "배선을 바꿨다" });
+ const lines = eventLines("CRITERION", one);
+ expect(lines).toEqual(["원래 판정: 기준 미달", expect.stringContaining("기록 · "), "근거 문서: ECN-12", "배선을 바꿨다"]);
+ expect(closureDisplay(row({}, { note: "배선을 바꿨다" })).lines.slice(0, 4)).toEqual(lines);
+ expect(eventLines("CRITERION", event()).length).toBe(3);
+ expect(eventKindLabel(event({ kind: "CONDITION_CHANGED", scope: "50 m 이하 안개" }))).toBe("운용 조건 변경(조건 범위: 50 m 이하 안개)");
+ expect(eventKindLabel(event({ kind: "CONDITION_CHANGED", scope: null }))).toBe("운용 조건 변경(조건 범위: 미기재)");
+ expect(eventKindLabel(event({ kind: "HUMAN_CLOSED" }))).toBe("사람 확인 종결(시험 결과 없음)");
+ });
+ it("keeps the older records newest first and none when there is only one", () => {
+ expect(previousEvents(row())).toEqual([]);
+ const events = [event({ event_id: "e1", basis_ref: "A" }), event({ event_id: "e2", basis_ref: "B" }), event({ event_id: "e3", basis_ref: "C" })];
+ expect(previousEvents(row({ events })).map(item => item.event_id)).toEqual(["e2", "e1"]);
+ });
+ it("shows a recorded fix with the original verdict, who and when, and its document", () => {
+ const shown = closureDisplay(row({}, { note: "배선을 바꿨다" }));
+ expect(shown.label).toBe("수정 반영됨(효과 미확인)");
+ expect(shown.lines).toContain("원래 판정: 기준 미달");
+ expect(shown.lines.join("\n")).toContain("근거 문서: ECN-12");
+ expect(shown.lines).toContain("배선을 바꿨다");
+ });
+ it("keeps the original not-met state beside a waiver and says THOTH did not grant it", () => {
+ const shown = closureDisplay(row({}, { kind: "WAIVER_RECORDED" }));
+ expect(shown.label).toBe("편차·면제 승인(원 기준 미충족 유지)");
+ expect(shown.lines).toContain("원래 판정: 기준 미달");
+ expect(shown.lines.join("\n")).toContain("THOTH가 면제를 내린 것이 아닙니다");
+ });
+ it("names the operating condition's range", () => {
+ expect(closureDisplay(row({}, { kind: "CONDITION_CHANGED", scope: "50 m 이하 안개" })).label).toBe("운용 조건 변경(조건 범위: 50 m 이하 안개)");
+ });
+ it("calls the effect confirmed only when the rules did, and says so", () => {
+ const shown = closureDisplay(row({ effect_confirmed: true, current_state: "PASS_COMPUTED", verdict_changed_since: true }));
+ expect(shown.label).toBe("효과 확인됨");
+ expect(shown.lines[0]).toContain("규칙이 계산한 값");
+ expect(shown.lines.join("\n")).not.toContain("이 기록 뒤에 판정이 바뀌었습니다");
+ expect(closureDisplay(row()).label).not.toBe("효과 확인됨");
+ });
+ it("says the verdict moved on after the record", () => {
+ expect(closureDisplay(row({ verdict_changed_since: true })).lines.join("\n")).toContain("이 기록 뒤에 판정이 바뀌었습니다");
+ });
+ it("never shows a code or a forbidden claim", () => {
+ const rows = [row(), row({}, { kind: "HUMAN_CLOSED" }), row({}, { kind: "WAIVER_RECORDED" }), row({}, { kind: "CONDITION_CHANGED", scope: "x" }), row({ effect_confirmed: true })];
+ for (const item of rows) for (const line of [closureDisplay(item).label, ...closureDisplay(item).lines]) { expect(line).not.toMatch(BANNED); expect(line).not.toMatch(LEAK); }
+ });
+ it("counts only a met verdict as met", () => {
+ expect([isMet("PASS_COMPUTED"), isMet("PASS"), isMet("FAIL_COMPUTED"), isMet("HOLD_NO_RESULT"), isMet(null)]).toEqual([true, true, false, false, false]);
+ });
+});
diff --git a/apps/web/src/components/judgmentRecordText.ts b/apps/web/src/components/judgmentRecordText.ts
new file mode 100644
index 0000000..9792458
--- /dev/null
+++ b/apps/web/src/components/judgmentRecordText.ts
@@ -0,0 +1,73 @@
+import type { ClosureEvent, ClosureKind, ClosureRow, DiscriminationItem, MatchKind } from "../api/judgmentRecords";
+import { actorLabel, timeText, verdictLook } from "./traceText";
+
+/** Plain words for what a person recorded: a test result, a refutation condition, or a closure of a trace row that was not met. */
+
+export const MATCH_KINDS: { code: MatchKind; label: string }[] = [
+ { code: "THIS_HYPOTHESIS", label: "이 가설과 맞음" },
+ { code: "ALTERNATIVE", label: "다른 설명과 맞음" },
+ { code: "NEITHER", label: "둘 다 아님" },
+ { code: "UNDETERMINED", label: "판단 불가" },
+];
+export const matchLabel = (code: MatchKind) => MATCH_KINDS.find(item => item.code === code)?.label ?? "기록됨";
+
+/** The mark on a hypothesis whose test results fit the other explanation. Never a deletion; the results can be recorded again. */
+export function eliminationLine(state: DiscriminationItem["elimination"]): string | null {
+ if (state === "SINGLE") return "배제(결과 1건)";
+ if (state === "REPEATED") return "배제(반복 확인)";
+ return null;
+}
+export const ELIMINATION_NOTE = "기록된 시험 결과가 다른 설명과 맞았다는 표시입니다. 가설은 지워지지 않고, 결과를 다시 기록하면 달라집니다.";
+
+const STANDING_WORDS: Record = { FITS: "이 가설과 맞음", AGAINST_ONCE: "다른 설명과 맞음(1건)", AGAINST_REPEATED: "다른 설명과 맞음(반복)", MIXED: "엇갈림" };
+/** The line on a hypothesis about the test results people recorded for it; null when none counts. */
+export const standingLine = (standing: DiscriminationItem["standing"]) => (standing && STANDING_WORDS[standing] ? `기록된 시험 결과: ${STANDING_WORDS[standing]}` : null);
+export const STANDING_NOTE = "사람이 기록한 시험 결과를 읽은 것이며, 봉인된 시험으로 얻은 검증 상태를 바꾸지 않습니다.";
+const againstStanding = (standing: DiscriminationItem["standing"]) => standing === "AGAINST_ONCE" || standing === "AGAINST_REPEATED";
+const AGAINST_NOTE = "이 행동이 기대는 가설이 기록된 시험 결과에서 다른 설명과 맞았습니다. 이 안내는 승인과 실행을 막지 않습니다. 시험 결과를 다시 살펴보고 판단하세요.";
+/** The note on an action that rests on a hypothesis the recorded results went against; information only, nothing is held back. */
+export function standingActionNote(hypothesisIds: string[] | undefined, items: DiscriminationItem[] | undefined): string | null {
+ const found = (items ?? []).some(item => againstStanding(item.standing) && (hypothesisIds ?? []).includes(item.hypothesis_id));
+ return found ? AGAINST_NOTE : null;
+}
+
+export const CLOSURE_KINDS: { code: ClosureKind; label: string; hint: string }[] = [
+ { code: "FIX_APPLIED", label: "수정 반영됨(효과 미확인)", hint: "고친 내용을 반영했지만 새 결과로 효과를 확인하지는 않았습니다. 새 결과가 들어와 이 줄이 충족으로 바뀌면 규칙이 '효과 확인됨'을 계산합니다." },
+ { code: "HUMAN_CLOSED", label: "사람 확인 종결(시험 결과 없음)", hint: "시험 결과 없이 사람이 확인해 닫았습니다." },
+ { code: "WAIVER_RECORDED", label: "편차·면제 승인(원 기준 미충족 유지)", hint: "시스템 밖에서 내려진 편차·면제 승인을 기록합니다. 원래 기준은 미충족으로 남습니다." },
+ { code: "CONDITION_CHANGED", label: "운용 조건 변경", hint: "기준이 적용되는 운용 조건이 바뀐 것을 기록합니다. 어느 조건 범위인지 적어야 합니다." },
+];
+export const CLOSURE_NOTE = "사람이 시스템 밖에서 내린 처분을 적어 두는 기록입니다. THOTH가 면제나 승인을 내리지 않으며, 이 줄의 판정(규칙이 계산한 값)은 그대로입니다.";
+const EFFECT_LABEL = "효과 확인됨";
+
+const MET = new Set(["PASS_COMPUTED", "PASS"]);
+export const isMet = (state: string | null | undefined) => state != null && MET.has(state);
+
+/** What one recorded closure is called: its kind, with the condition's range for a changed operating condition. */
+export function eventKindLabel(event: ClosureEvent): string {
+ return event.kind === "CONDITION_CHANGED" ? `운용 조건 변경(조건 범위: ${event.scope ?? "미기재"})` : CLOSURE_KINDS.find(item => item.code === event.kind)?.label ?? "처분 기록됨";
+}
+
+/** One record as short lines: the verdict it was written against, who and when, its document and note. */
+export function eventLines(subjectKind: ClosureRow["subject_kind"], event: ClosureEvent): string[] {
+ const lines = [`원래 판정: ${verdictLook(subjectKind, event.verdict_state).text}`, `${actorLabel(event.actor_id)} 기록 · ${timeText(event.created_at)}`, `근거 문서: ${event.basis_ref}`];
+ if (event.note) lines.push(event.note);
+ return lines;
+}
+
+/** The records before the latest one, newest first. */
+export function previousEvents(row: ClosureRow): ClosureEvent[] {
+ return row.events.slice(0, -1).reverse();
+}
+
+/** What to show beside a row's verdict: a label and short lines. The effect-confirmed mark is the rule's; the others are a person's. */
+export function closureDisplay(row: ClosureRow): { label: string; lines: string[] } {
+ const latest = row.events[row.events.length - 1];
+ const label = row.effect_confirmed ? EFFECT_LABEL : eventKindLabel(latest);
+ const lines: string[] = [];
+ if (row.effect_confirmed) lines.push("수정을 기록한 뒤 들어온 새 결과로 이 줄이 충족으로 바뀌었습니다(규칙이 계산한 값).");
+ lines.push(...eventLines(row.subject_kind, latest));
+ if (!row.effect_confirmed && latest.kind === "WAIVER_RECORDED") lines.push("시스템 밖에서 내려진 처분을 적어 둔 기록입니다. THOTH가 면제를 내린 것이 아닙니다.");
+ if (!row.effect_confirmed && row.verdict_changed_since) lines.push("이 기록 뒤에 판정이 바뀌었습니다. 기록은 그때의 판정에 대한 것입니다.");
+ return { label, lines };
+}
diff --git a/apps/web/src/components/lessonText.ts b/apps/web/src/components/lessonText.ts
new file mode 100644
index 0000000..bdae0b6
--- /dev/null
+++ b/apps/web/src/components/lessonText.ts
@@ -0,0 +1,25 @@
+import type { LessonItem } from "../api/lessons";
+import { CLOSURE_KINDS, eliminationLine, matchLabel } from "./judgmentRecordText";
+import { actorLabel, timeText, verdictLook } from "./traceText";
+
+/** Plain words for a lesson: what a rule computed or a person recorded earlier, in the same condition. Never advice, never a count of how often. */
+
+export const LESSON_NOTE = "같은 기준, 같은 측정 단위, 같은 조건, 같은 규칙 개정에서 이전에 규칙이 계산했거나 사람이 기록한 결과입니다. 참고로만 보이며 시험의 순서에 쓰이지 않습니다. 이번에도 같은 결과가 나온다는 뜻이 아닙니다.";
+
+export function withheldLine(stale: number, refuted: number): string | null {
+ if (stale === 0 && refuted === 0) return null;
+ const parts = [stale > 0 ? "낡음 " + stale + "개" : "", refuted > 0 ? "반박됨 " + refuted + "개" : ""].filter(Boolean).join(" · ");
+ return "원본 기록은 남아 있습니다. 근거가 바뀌었거나 반대 결과가 나와 뺀 교훈은 보이지 않습니다(" + parts + ").";
+}
+
+export function lessonDisplay(item: LessonItem): { title: string; lines: string[] } {
+ const observed = (item.detail.observations ?? []).map(entry => "관찰: " + entry.observation + (entry.evidence_refs.length > 0 ? " (근거 참조 " + entry.evidence_refs.join(", ") + ")" : ""));
+ const who = actorLabel(item.actor_id) + " 기록 · " + timeText(item.created_at);
+ const original = item.detail.original_state ? ["원래 판정: " + verdictLook(item.subject_kind, item.detail.original_state).text] : [];
+ const basis = item.detail.basis_ref ? ["근거 문서: " + item.detail.basis_ref] : [];
+ if (item.kind === "TEST_RESULT") return { title: "이전 시험 결과: " + matchLabel(item.outcome as never), lines: [...observed, who] };
+ if (item.kind === "ELIMINATION") return { title: "이전 조사에서 한 가설이 " + (eliminationLine(item.outcome as never) ?? "배제") + "로 표시되었습니다", lines: [...observed, who] };
+ if (item.kind === "EFFECT_CONFIRMED") return { title: "수정을 반영한 뒤 새 결과로 이 줄이 충족으로 바뀐 적이 있습니다(규칙이 계산한 값)", lines: [...basis, ...original] };
+ const label = CLOSURE_KINDS.find(entry => entry.code === item.detail.closure_kind)?.label ?? "처분 기록";
+ return { title: item.detail.closure_kind === "CONDITION_CHANGED" ? label + "(조건 범위: " + (item.detail.scope ?? "미기재") + ")" : label, lines: [...basis, ...original, who] };
+}
diff --git a/apps/web/src/components/plainWording.ts b/apps/web/src/components/plainWording.ts
new file mode 100644
index 0000000..a637239
--- /dev/null
+++ b/apps/web/src/components/plainWording.ts
@@ -0,0 +1,83 @@
+/** Wording for what the program itself records (the sufficiency reasons and the built-in criteria), in plain Korean.
+ * The recorded text is English and stays as it is; the screen shows these sentences and folds the original away. */
+
+const reasons: Record = {
+ "decision scope is explicit": "무엇을 판단할지 분명합니다.",
+ "decision question is empty": "무엇을 판단할지 질문이 비어 있습니다.",
+ "official evaluator-ready criterion exists": "공식 기준이 판정에 쓸 수 있게 갖춰져 있습니다.",
+ "criteria exist but official evaluator inputs are incomplete": "기준은 있지만 공식 판정에 필요한 입력이 아직 다 갖춰지지 않았습니다.",
+ "no criterion candidate exists": "기준으로 삼을 만한 후보가 없습니다.",
+ "verified supporting evidence exists": "확인된 뒷받침 근거가 있습니다.",
+ "evidence is extracted but not support-verified": "근거는 뽑았지만 뒷받침하는지 아직 확인하지 않았습니다.",
+ "no cutoff-eligible evidence exists": "기준시점 안에서 쓸 수 있는 근거가 없습니다.",
+ "comparison conditions are confirmed": "비교 조건이 확인됐습니다.",
+ "comparison conditions conflict": "비교 조건이 서로 맞지 않습니다.",
+ "comparison conditions have not been confirmed": "비교 조건이 같은지 아직 확인하지 못했습니다.",
+ "counterevidence pass is recorded": "반대 근거를 찾아본 기록이 있습니다.",
+ "counterevidence has not been checked": "반대 근거는 아직 찾아보지 않았습니다.",
+ "measurement implementation is verified": "측정 방식이 올바른지 확인됐습니다.",
+ "measurement implementation failed verification": "측정 방식이 올바른지 확인했지만 맞지 않았습니다.",
+ "measurement implementation is not verified": "측정 방식이 올바른지 아직 확인하지 못했습니다.",
+ "expert-confirmed semantics are bound to an official criterion": "기준의 뜻을 전문가가 확인해 공식 기준에 연결했습니다.",
+ "criterion semantics still require expert confirmation": "기준이 정확히 무엇을 뜻하는지 전문가의 확인이 더 필요합니다.",
+ "no criterion semantics are available": "기준이 무엇을 뜻하는지 알 수 있는 자료가 없습니다.",
+};
+
+/** The Korean sentence for one recorded reason, or null when it is not one the program knows. */
+export function plainReason(text: string): string | null {
+ return reasons[text.trim()] ?? null;
+}
+
+const HANGUL = /[가-힣]/;
+const LATIN_WORD = /[A-Za-z]{3,}/;
+
+/** Text that people can read as it is: Korean (or only numbers and signs). English that nobody has worded is set aside. */
+export function isReadable(text: string): boolean {
+ return HANGUL.test(text) || !LATIN_WORD.test(text);
+}
+
+export function splitReadable(items: string[]): { plain: string[]; technical: string[] } {
+ const plain: string[] = [];
+ const technical: string[] = [];
+ for (const item of items) {
+ const known = plainReason(item);
+ if (known) plain.push(known);
+ else if (isReadable(item)) plain.push(item);
+ else technical.push(item);
+ }
+ return { plain, technical };
+}
+
+/** The built-in criteria (task profiles) record a short English target and question. */
+const builtIn: Record = {
+ answer: { title: "요청한 대상과 위치", question: "요청한 대상, 항목, 원문 위치와 앞뒤 맥락을 확인했나?", english: "Requested target, fields, source locator and adjacent context" },
+ time: { title: "시점과 자료의 적용 가능성", question: "요청한 시점에 이 자료를 쓸 수 있나?", english: "Requested time and source applicability" },
+ comparison: { title: "비교 조건", question: "두 기록의 방법, 조건, 단위, 기간이 같은가?", english: "Both records' method, conditions, units and period" },
+ conversion: { title: "환산 근거", question: "필요한 환산에 공식 근거와 유효성이 있나?", english: "Authority and validity of any required conversion" },
+ observation: { title: "문제와 관찰 범위", question: "문제와 관찰한 범위가 분명한가?", english: "Problem and observed scope" },
+ counterevidence: { title: "반대 근거", question: "설명을 가려낼 예측과 반대 근거를 확인했나?", english: "Discriminating predictions and counterevidence" },
+ test: { title: "적용할 시험", question: "원인으로 올리기 전에 적용할 시험이 있나?", english: "Applicable test profile before causal promotion" },
+};
+
+export type CriterionText = { title: string | null; question: string | null; technical: string[] };
+
+/** What a criteria row shows: a title and a question in Korean, with codes, ids and untranslated English kept for the folded technical part. */
+export function criterionText(target: string, question: string): CriterionText {
+ const known = builtIn[target];
+ const technical: string[] = [];
+ let title: string | null = null;
+ let shownQuestion: string | null = null;
+ if (known) {
+ title = known.title;
+ if (question === known.english) shownQuestion = known.question;
+ } else if (HANGUL.test(target)) {
+ title = target;
+ } else if (target) {
+ technical.push(target);
+ }
+ if (shownQuestion === null) {
+ if (isReadable(question)) shownQuestion = question || null;
+ else technical.push(question);
+ }
+ return { title, question: shownQuestion, technical };
+}
diff --git a/apps/web/src/components/resultUsage.ts b/apps/web/src/components/resultUsage.ts
index c67c844..13372cc 100644
--- a/apps/web/src/components/resultUsage.ts
+++ b/apps/web/src/components/resultUsage.ts
@@ -27,6 +27,9 @@ export function resultUsageLine(status: ResearchStatus | undefined, operationId:
const reuse = status.request?.operation_id === operationId ? status.stage_reuse : undefined;
const reused = reuse && reuse.reused > 0 ? ` · 완료된 ${reuse.reused}단계 재사용 · 새로 부른 단계 ${reuse.new}` : "";
const retried = usage.auto_retries ? ` · 자동 재시도 ${usage.auto_retries}회(연결 끊김)` : "";
- return `이 조사: 토큰 ${tokens} · ${timeText(status, operationId, usage.wall_ms)}${reused}${retried} · 추정 비용 미확인`;
+ // The totals already include these calls; the share is shown so it is not a hidden cost.
+ const apart = Object.values(usage.by_purpose ?? {})
+ .map(part => ` · 그중 ${part.label} ${part.total_tokens == null ? "토큰 미확인" : `토큰 ${part.total_tokens.toLocaleString()}`}`).join("");
+ return `이 조사: 토큰 ${tokens} · ${timeText(status, operationId, usage.wall_ms)}${reused}${retried}${apart} · 추정 비용 미확인`;
}
diff --git a/apps/web/src/components/sameHypothesisText.ts b/apps/web/src/components/sameHypothesisText.ts
new file mode 100644
index 0000000..2430ab5
--- /dev/null
+++ b/apps/web/src/components/sameHypothesisText.ts
@@ -0,0 +1,37 @@
+import type { HypothesisLink } from "../api/hypothesisLink";
+import type { SameView } from "../api/hypothesisSame";
+import { timeText } from "./traceText";
+
+/** Plain words for hypotheses a person marked as the same one across investigations. A reference only, never an addition of counts. */
+
+export const SAME_BUTTON = "다른 조사의 같은 가설로 표시";
+export const SAME_NOTE = "사람이 같은 가설이라고 표시한 것입니다. 이전 조사의 결과는 참고로만 보이고, 이 가설의 배제나 시험 순서에 더해지지 않습니다.";
+export const SAME_TAG = "다른 조사의 같은 가설";
+export const NO_RESULTS = "기록된 시험 결과 없음";
+
+export function sameRefusal(message: string): string {
+ if (message.includes("SAME_ALREADY_LINKED")) return "이미 같은 가설로 표시되어 있습니다.";
+ if (message.includes("SAME_NOT_INVESTIGATION")) return "조사에서 나온 가설끼리만 같은 가설로 표시할 수 있습니다.";
+ if (message.includes("SAME_NOT_LINKED")) return "이미 연결이 끊어져 있습니다. 화면을 새로 읽어 주세요.";
+ return "표시하지 못했습니다. 잠시 뒤 다시 시도해 주세요.";
+}
+
+const clip = (text: string, max: number) => (text.length > max ? text.slice(0, max - 1) + "…" : text);
+
+/** The other hypotheses of the project that came from an investigation, the same trace row first; none already in this group, never itself. */
+export function sameCandidates(hypothesisId: string, links: HypothesisLink[] | undefined, same: SameView | undefined): HypothesisLink[] {
+ const mine = new Set(same?.groups.find(group => group.includes(hypothesisId)) ?? []);
+ const own = (links ?? []).find(item => item.hypothesis_id === hypothesisId);
+ const others = (links ?? []).filter(item => item.hypothesis_id !== hypothesisId && !mine.has(item.hypothesis_id));
+ const rowOf = (item: HypothesisLink) => own !== undefined && item.subject_kind === own.subject_kind && item.subject_id === own.subject_id;
+ return [...others.filter(rowOf), ...others.filter(item => !rowOf(item))];
+}
+
+export const candidateLabel = (item: HypothesisLink) => clip(item.statement, 60) + " · " + (item.subject_title || item.subject_id);
+
+/** The line about a hypothesis marked as the same one: what it says and when it was investigated. */
+export function memberLine(same: SameView, id: string): string {
+ const member = same.members[id] ?? {};
+ const when = member.investigated_at ? timeText(member.investigated_at) + " 조사" : "조사 날짜 미확인";
+ return clip(member.statement ?? "읽을 수 없는 가설", 80) + " · " + when;
+}
diff --git a/apps/web/src/components/statusLabels.ts b/apps/web/src/components/statusLabels.ts
index 91da4b2..f74c968 100644
--- a/apps/web/src/components/statusLabels.ts
+++ b/apps/web/src/components/statusLabels.ts
@@ -72,6 +72,7 @@ const coverageTerms: Record = {
RESEARCH_GAP: "조사 공백", APPLIED: "판단 반영됨", NOT_ASSESSED: "미평가", INCONCLUSIVE: "결론을 내지 못함",
QUALIFIES: "조건부로 해당", SUPPORTS: "뒷받침", CONTRADICTS: "충돌", "answer:HOLD": "답변을 보류시킨 요인",
APPLICABLE: "적용됨", COVERAGE_UNAVAILABLE: "평가기준 정보 없음", ASSESSMENT_UNAVAILABLE: "평가 결과 없음",
+ INSUFFICIENT: "근거 부족", REFUTES: "반박", IRRELEVANT: "관련 없음",
};
export function coverageTermLabel(value: string): string { return coverageTerms[value] ?? value; }
const actionFamilies: Record = {
diff --git a/apps/web/src/components/testActionDraftText.ts b/apps/web/src/components/testActionDraftText.ts
new file mode 100644
index 0000000..ea65c1e
--- /dev/null
+++ b/apps/web/src/components/testActionDraftText.ts
@@ -0,0 +1,45 @@
+import { statusLabel } from "./statusLabels";
+
+/** Plain words for a draft made from a test: it is a request being prepared, never an approval. */
+
+const REASONS: Record = {
+ POLICY_UNDEFINED: "효과를 아직 선언하지 않아 보호된 행동으로 분류했습니다. 효과를 선언해 고치기 전에는 사람의 판단이 필요합니다.",
+ APPROVAL_REQUIRED: "밖으로 쓰거나 기준을 바꾸는 행동이라 사람의 승인이 필요합니다.",
+ PROHIBITED: "금지된 효과가 선언되어 실행할 수 없는 행동입니다.",
+};
+const OPEN = "선언한 효과로는 별도의 승인 없이 쓸 수 있는 등급입니다.";
+
+// The rule's own answer, in plain words. A state or role this screen does not know is left out rather than shown as a code.
+const POLICY: Record = {
+ APPROVAL_REQUIRED: "승인 필요", POLICY_UNDEFINED: "효과를 확인해야 함", PROHIBITED: "실행할 수 없음",
+ AUTO_ALLOWED: "별도 승인 없이 가능", PREAUTHORIZED: "미리 허용된 범위",
+};
+const ROLE: Record = {
+ "project-owner": "프로젝트 책임자", "safety-owner": "안전 책임자", "external-interface-owner": "외부 연동 책임자",
+ "effect-owner": "효과 확인 책임자", "institution-authority": "기관 권한자", "sandbox-owner": "격리 환경 책임자",
+};
+
+function ruleLine(action: Record): string | null {
+ const policy = typeof action.policy_state === "string" ? POLICY[action.policy_state] : undefined;
+ if (!policy) return null;
+ const roles = Array.isArray(action.required_roles) ? action.required_roles.map(role => ROLE[String(role)] ?? "담당 책임자") : [];
+ return roles.length > 0 ? `규칙상: ${policy} · 필요한 사람: ${[...new Set(roles)].join(", ")}` : `규칙상: ${policy}`;
+}
+
+export function draftReadyLines(action: Record): string[] {
+ const tier = typeof action.risk_tier === "string" ? action.risk_tier : "";
+ const policy = typeof action.policy_state === "string" ? action.policy_state : "";
+ const rule = ruleLine(action);
+ return [
+ "행동 요청을 준비했습니다(초안). 아직 승인이 아닙니다.",
+ `위험 등급: ${statusLabel(tier) ?? "확인 필요"}`,
+ ...(rule ? [rule] : []),
+ REASONS[policy] ?? OPEN,
+ ];
+}
+
+export function draftRefusal(message: string): string {
+ return message.includes("HYPOTHESIS_BASIS_CHANGED")
+ ? "이 가설은 이전 근거 기준이라 행동 요청을 만들 수 없습니다. 먼저 가설을 다시 확인해 주세요."
+ : "행동 요청을 준비하지 못했습니다. 잠시 뒤 다시 시도해 주세요.";
+}
diff --git a/apps/web/src/components/testOrderInput.ts b/apps/web/src/components/testOrderInput.ts
new file mode 100644
index 0000000..b9bc455
--- /dev/null
+++ b/apps/web/src/components/testOrderInput.ts
@@ -0,0 +1,65 @@
+import type { DiscriminationItem, TestResult } from "../api/judgmentRecords";
+import { objectList, stringValues, textValue } from "../api/presentation";
+import type { HypothesisRow } from "./hypothesisView";
+import type { RankInput, RankItem } from "./testRanking";
+import { EXCLUDED_TITLE, groupTitle } from "./testOrderText";
+import { rankItems } from "./testRanking";
+
+export type TestLabel = { statement: string; procedure: string; cost: number | null; recorded: TestResult | undefined };
+
+const costOf = (value: unknown): number | null => {
+ if (value === null || value === undefined || value === "") return null;
+ const number = Number(value);
+ return Number.isFinite(number) ? number : null;
+};
+
+/**
+ * The tests of a row's hypotheses, as the ranking takes them. The risk is the rule's, which a test does not have until an action request
+ * is drafted from it, so it is left undeclared (counted as protected); the model's own tier is never read. Nothing here knows what the
+ * other hypotheses expect from a test, so the split is not countable until a table says so.
+ */
+export function testOrderInput(rows: HypothesisRow[], actions: Record[], discrimination: DiscriminationItem[] | undefined): { input: RankInput; labels: Map } {
+ const labels = new Map();
+ const items: RankItem[] = [];
+ for (const row of rows) {
+ const known = discrimination?.find(item => item.hypothesis_id === row.id);
+ row.tests.forEach((test, at) => {
+ const id = row.id + "::" + (textValue(test.test_id) || "#" + at);
+ const recorded = known?.results.find(item => item.test_id === textValue(test.test_id));
+ labels.set(id, { statement: row.statement, procedure: textValue(test.procedure_candidate), cost: costOf(test.estimated_cost), recorded });
+ items.push({ id, kind: "TEST", hypothesisId: row.id || null, riskTier: null, cost: costOf(test.estimated_cost), reversibility: textValue(test.reversibility) || null,
+ executable: test.executable !== false, resultRecorded: recorded !== undefined, predictions: null, labels: labelsOf(test) });
+ });
+ }
+ return { labels, input: {
+ hypotheses: rows.filter(row => row.id).map(row => ({ id: row.id, elimination: discrimination?.find(item => item.hypothesis_id === row.id)?.elimination ?? null })),
+ actions: actions.map((action, at) => ({ id: textValue(action.action_id) || "#" + at, hypothesisIds: [...stringValues(action.hypothesis_ids), ...stringValues(action.hypothesis_refs)] })),
+ items } };
+}
+
+/** The action candidates of a result, as the ranking takes them: the rule's risk, the estimate when there is one, no split to count. */
+export function actionOrderItems(actions: Record[]): RankItem[] {
+ return actions.map((action, at) => ({ id: textValue(action.action_id) || "#" + at, kind: "ACTION", hypothesisId: null, riskTier: textValue(action.risk_tier) || null, cost: costOf(action.estimated_cost),
+ reversibility: textValue(action.reversibility) || null, executable: true, resultRecorded: false, predictions: null }));
+}
+
+/** The action cards in the order of the rule, each with the heading to print above it (none when there is only one kind of group). */
+export function orderedActions(actions: Record[]): { action: Record; heading: string | null; key: string }[] {
+ const items = actionOrderItems(actions);
+ const byId = new Map(items.map((item, at) => [item.id, actions[at]]));
+ const ranked = rankItems({ hypotheses: [], actions: [], items });
+ const sections = ranked.groups.length + (ranked.excluded.length > 0 ? 1 : 0);
+ const out: { action: Record; heading: string | null; key: string }[] = [];
+ for (const group of ranked.groups) group.items.forEach((entry, at) => out.push({ action: byId.get(entry.id)!, key: entry.id, heading: sections > 1 && at === 0 ? groupTitle(group.group, "ACTION") : null }));
+ ranked.excluded.forEach((id, at) => out.push({ action: byId.get(id)!, key: id, heading: at === 0 ? EXCLUDED_TITLE : null }));
+ return out;
+}
+/** The result each hypothesis is said to expect from a test (contract v3), by hypothesis id; null when the result carries no table. */
+function labelsOf(test: Record): Record | null {
+ const table: Record = {};
+ for (const row of objectList(test.expected_by_hypothesis)) {
+ const id = textValue(row.hypothesis_id), expected = textValue(row.expected);
+ if (id && expected && !(id in table)) table[id] = expected;
+ }
+ return Object.keys(table).length > 0 ? table : null;
+}
diff --git a/apps/web/src/components/testOrderText.ts b/apps/web/src/components/testOrderText.ts
new file mode 100644
index 0000000..9db1256
--- /dev/null
+++ b/apps/web/src/components/testOrderText.ts
@@ -0,0 +1,26 @@
+import type { GroupId, Ranked } from "./testRanking";
+
+/** Plain words for the order of tests and actions. No score and no probability is ever shown; only whole-number counts of candidates. */
+
+export const ORDER_NOTE = "순서는 점수가 아니라 규칙입니다. 결과에 따라 남는 원인 후보가 적은 시험부터, 같으면 위험이 낮은 것, 비용이 낮은 것, 되돌릴 수 있는 것, 만든 순서로 놓습니다. 원인을 아직 모른다는 칸은 어떤 결과에서도 남습니다. 그럴듯함은 순서에 쓰지 않습니다(근거 상태는 가설 카드에서 따로 봅니다). 한 시험을 하고 나면 그 뒤 순서는 결과에 따라 바뀝니다.";
+
+const TITLES: Record = {
+ READY: { test: "먼저 해 볼 시험", action: "먼저 볼 행동", hint: "결과가 어느 쪽이어도 남는 원인 후보가 가장 적은 시험부터 놓았습니다." },
+ COST_UNKNOWN: { test: "비용을 아직 모르는 시험", action: "비용을 아직 모르는 행동", hint: "같은 규칙으로 놓았지만 비용 추정이 없어 따로 두었습니다. 비용 0으로 보지 않습니다." },
+ NO_DECISION_CHANGE: { test: "결과가 달라도 할 일이 같은 시험", action: "결과가 달라도 할 일이 같은 행동", hint: "어떤 결과가 나와도 이어지는 행동이 같아서 아래로 내렸습니다." },
+ NOT_EXECUTABLE: { test: "지금은 할 수 없는 시험", action: "지금은 할 수 없는 행동", hint: "지금은 실행할 수 없습니다." },
+ UNCOUNTABLE: { test: "얼마나 가르는지 아직 셀 수 없는 시험", action: "순서대로 본 행동", hint: "다른 가설이 이 시험에서 무엇을 낼지 적혀 있지 않아 가르는 정도를 셀 수 없습니다. 위험, 비용, 되돌림 순으로만 놓았습니다." },
+ RESULT_RECORDED: { test: "결과를 이미 기록한 시험", action: "결과를 이미 기록한 행동", hint: "이미 한 시험이라 다음에 할 시험으로 내세우지 않습니다." },
+ HYPOTHESIS_ELIMINATED: { test: "배제된 가설의 시험", action: "배제된 가설의 행동", hint: "반복한 시험 결과로 배제된 가설의 시험입니다. 후보 수에서는 뺐고 기록은 남아 있습니다." },
+};
+export const groupTitle = (group: GroupId, kind: "TEST" | "ACTION") => TITLES[group][kind === "TEST" ? "test" : "action"];
+export const groupHint = (group: GroupId) => TITLES[group].hint;
+export const EXCLUDED_TITLE = "순서에서 뺀 행동(금지 수준)";
+export const excludedTestsLine = (count: number) => "금지 수준이라 순서에서 뺀 시험이 " + count + "개 있습니다.";
+
+/** What the test does to the candidates, in whole numbers only. */
+export function reductionLine(entry: Ranked, candidates: number): string {
+ if (!entry.counted) return "다른 가설이 이 시험에서 무엇을 낼지 적혀 있지 않아, 후보를 하나만 줄이는 시험으로 셉니다.";
+ if (entry.worst >= candidates) return "이 시험만으로는 원인 후보를 줄이지 못합니다.";
+ return "이 시험은 결과에 따라 원인 후보를 최대 " + entry.worst + "개로 줄입니다.";
+}
diff --git a/apps/web/src/components/testRanking.cases.test.ts b/apps/web/src/components/testRanking.cases.test.ts
new file mode 100644
index 0000000..7e38a7e
--- /dev/null
+++ b/apps/web/src/components/testRanking.cases.test.ts
@@ -0,0 +1,65 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+import { GROUP_ORDER, rankItems, type GroupId, type RankInput } from "./testRanking";
+
+type Case = { id: string; about: string; hypotheses: RankInput["hypotheses"]; actions: RankInput["actions"]; items: RankInput["items"];
+ expected: { candidates: number; groups: { group: GroupId; ids: string[] }[]; worst: Record; tie: string[]; excluded: string[]; eliminatedOnce?: string[] } };
+const document = JSON.parse(readFileSync(new URL("../../../../tests/fixtures/hypothesis_eval/ranking_cases.json", import.meta.url), "utf8")) as { schema_version: string; notice: string; cases: Case[] };
+
+const REQUIRED = ["order-balanced-split", "order-confirm-only", "order-cost-unknown", "order-tie", "order-infeasible", "order-no-decision-change", "noisy-single-result",
+ "order-multi-cause", "result-recorded", "repeated-elimination"];
+const outcome = (item: Case) => rankItems({ hypotheses: item.hypotheses, actions: item.actions, items: item.items });
+
+describe("the ranking cases", () => {
+ it("are synthetic, name the agreed situations, and each says what it is about", () => {
+ expect(document.schema_version).toBe("hypothesis-ranking-cases/1");
+ expect(document.notice).toContain("SYNTHETIC");
+ const ids = document.cases.map(item => item.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ for (const id of REQUIRED) expect(ids).toContain(id);
+ for (const item of document.cases) expect(item.about.length).toBeGreaterThan(20);
+ });
+
+ for (const item of document.cases) {
+ it("gives the expected order for " + item.id, () => {
+ const result = outcome(item);
+ expect(result.candidates).toBe(item.expected.candidates);
+ expect(result.groups.map(group => ({ group: group.group, ids: group.items.map(entry => entry.id) }))).toEqual(item.expected.groups);
+ expect(result.excluded).toEqual(item.expected.excluded);
+ const all = result.groups.flatMap(group => group.items);
+ for (const [id, worst] of Object.entries(item.expected.worst)) expect(all.find(entry => entry.id === id)?.worst).toBe(worst);
+ expect(all.filter(entry => entry.tie).map(entry => entry.id).sort()).toEqual([...item.expected.tie].sort());
+ expect(all.filter(entry => entry.eliminatedOnce).map(entry => entry.id)).toEqual(item.expected.eliminatedOnce ?? []);
+ expect(outcome(item)).toEqual(result); // the same input gives the same order
+ });
+ }
+
+ it("never puts an unknown cost before a known one of the same split and risk, a blocked or recorded test in the main list, or a forbidden test anywhere", () => {
+ for (const item of document.cases) {
+ const result = outcome(item);
+ const byId = new Map(item.items.map(entry => [entry.id, entry]));
+ for (const group of result.groups) {
+ group.items.slice(1).forEach((entry, at) => {
+ const before = group.items[at];
+ const [a, b] = [byId.get(before.id)!, byId.get(entry.id)!];
+ if (before.worst === entry.worst && (a.riskTier ?? "R3") === (b.riskTier ?? "R3")) expect(a.cost === null && b.cost !== null).toBe(false);
+ });
+ if (group.group === "READY") for (const entry of group.items) {
+ const source = byId.get(entry.id)!;
+ expect(source.executable && !source.resultRecorded && source.riskTier !== "R4" && source.cost !== null).toBe(true);
+ }
+ }
+ const placed = new Set(result.groups.flatMap(group => group.items.map(entry => entry.id)));
+ for (const id of result.excluded) { expect(placed.has(id)).toBe(false); expect(byId.get(id)!.riskTier).toBe("R4"); }
+ expect(placed.size + result.excluded.length).toBe(item.items.length);
+ }
+ });
+
+ it("lists the groups in the agreed order", () => {
+ expect(GROUP_ORDER).toEqual(["READY", "COST_UNKNOWN", "NO_DECISION_CHANGE", "UNCOUNTABLE", "NOT_EXECUTABLE", "RESULT_RECORDED", "HYPOTHESIS_ELIMINATED"]);
+ for (const item of document.cases) {
+ const order = outcome(item).groups.map(group => GROUP_ORDER.indexOf(group.group));
+ expect([...order].sort((a, b) => a - b)).toEqual(order);
+ }
+ });
+});
diff --git a/apps/web/src/components/testRanking.ts b/apps/web/src/components/testRanking.ts
new file mode 100644
index 0000000..f70048e
--- /dev/null
+++ b/apps/web/src/components/testRanking.ts
@@ -0,0 +1,103 @@
+/**
+ * The order of discriminating tests and action candidates for the hypotheses of one trace row. A pure rule, no score and no probability:
+ * tests that leave the fewest cause candidates whichever way the result goes come first; ties go to the lower rule risk, then the lower
+ * cost, then a test that can be undone, then the order they were made in. What cannot be ranked that way is set apart in named groups.
+ * Nothing here reads a lesson, a count of past results or how plausible a hypothesis looks.
+ */
+
+export type Outcome = "WITH_TEST" | "WITH_ALTERNATIVE";
+export type RankItem = {
+ id: string; kind: "TEST" | "ACTION";
+ /** The hypothesis a test is about (it expects its own result if that hypothesis is right); null for an action. */
+ hypothesisId: string | null;
+ /** The rule's risk tier; null when no effect was ever declared, which is counted as protected. R4 is left out of the list. */
+ riskTier: string | null;
+ /** The estimated cost when known; null is "unknown", never zero. */
+ cost: number | null;
+ reversibility: string | null;
+ executable: boolean;
+ resultRecorded: boolean;
+ /** For every other candidate, which result it expects. Null (or missing a candidate) means the split cannot be counted. */
+ predictions: Record | null;
+ /**
+ * The result each hypothesis expects from this test, as a short label (the tested hypothesis included); the same label is the same
+ * result and "모름" is not known. When given it is used instead of `predictions`. A candidate with no entry means the split cannot be counted.
+ */
+ labels?: Record | null;
+};
+export type RankHypothesis = { id: string; elimination: "SINGLE" | "REPEATED" | null };
+export type RankInput = { hypotheses: RankHypothesis[]; actions: { id: string; hypothesisIds: string[] }[]; items: RankItem[] };
+
+export type GroupId = "READY" | "COST_UNKNOWN" | "NO_DECISION_CHANGE" | "NOT_EXECUTABLE" | "UNCOUNTABLE" | "RESULT_RECORDED" | "HYPOTHESIS_ELIMINATED";
+// A test that cannot be run now comes after the ones that can, even those whose split cannot be counted.
+export const GROUP_ORDER: GroupId[] = ["READY", "COST_UNKNOWN", "NO_DECISION_CHANGE", "UNCOUNTABLE", "NOT_EXECUTABLE", "RESULT_RECORDED", "HYPOTHESIS_ELIMINATED"];
+export type Ranked = {
+ id: string; group: GroupId;
+ /** The most cause candidates that can be left, whichever result comes; for a split that cannot be counted, candidates minus one (at least one). */
+ worst: number;
+ counted: boolean; tie: boolean; costUnknown: boolean;
+ /** The test's hypothesis has one result that fits the other explanation: it stays in the count and is only marked. */
+ eliminatedOnce: boolean;
+};
+export type RankOutput = { groups: { group: GroupId; items: Ranked[] }[]; excluded: string[]; candidates: number };
+
+const RISK: Record = { R0: 0, R1: 1, R2: 2, R3: 3 };
+const riskRank = (tier: string | null) => (tier === null ? 3 : RISK[tier] ?? 3);
+const reversibilityRank = (value: string | null) => (value === "FULL" ? 0 : value === "PARTIAL" ? 1 : 2);
+const compareKeys = (a: number[], b: number[]) => {
+ const at = a.findIndex((value, position) => value !== b[position]);
+ return at === -1 ? 0 : a[at] - b[at];
+};
+const UNKNOWN_LABEL = "모름";
+const normalized = (label: string) => label.trim().replace(/\s+/g, " ").toLowerCase();
+
+/** The candidates left under each possible result of a test; null when the table does not cover every candidate. A hypothesis that does not know stays under every result. */
+function outcomeClasses(item: RankItem, candidates: string[], tester: string | null): string[][] | null {
+ if (item.kind !== "TEST" || tester === null || !candidates.includes(tester)) return null;
+ const others = candidates.filter(id => id !== tester);
+ if (item.labels) {
+ const table = item.labels;
+ if (!candidates.every(id => id in table)) return null;
+ const unknown = candidates.filter(id => normalized(table[id]) === normalized(UNKNOWN_LABEL));
+ const byLabel = new Map();
+ for (const id of candidates) if (!unknown.includes(id)) byLabel.set(normalized(table[id]), [...(byLabel.get(normalized(table[id])) ?? []), id]);
+ return byLabel.size === 0 ? [unknown] : [...byLabel.values()].map(members => [...members, ...unknown]);
+ }
+ const table = item.predictions;
+ if (table === null || !others.every(id => id in table)) return null;
+ return [[tester, ...others.filter(id => table[id] === "WITH_TEST")], others.filter(id => table[id] === "WITH_ALTERNATIVE")];
+}
+
+/** Rank tests and actions; the same input always gives the same output. */
+export function rankItems(input: RankInput): RankOutput {
+ const eliminated = new Set(input.hypotheses.filter(item => item.elimination === "REPEATED").map(item => item.id));
+ const once = new Set(input.hypotheses.filter(item => item.elimination === "SINGLE").map(item => item.id));
+ const candidates = input.hypotheses.filter(item => !eliminated.has(item.id)).map(item => item.id);
+ const actionsFor = (ids: string[]) => new Set(input.actions.filter(action => action.hypothesisIds.some(id => ids.includes(id))).map(action => action.id));
+ const sameSet = (a: Set, b: Set) => a.size === b.size && [...a].every(id => b.has(id));
+ const excluded: string[] = [];
+ const placed: { ranked: Ranked; key: number[] }[] = [];
+ input.items.forEach((item, index) => {
+ if (item.riskTier === "R4") { excluded.push(item.id); return; }
+ const tester = item.hypothesisId;
+ const classes = outcomeClasses(item, candidates, tester);
+ const counted = classes !== null;
+ const worst = classes ? Math.max(...classes.map(members => members.length)) : Math.max(1, candidates.length - 1);
+ const neutral = classes !== null && input.actions.length > 0 && classes.every(members => sameSet(actionsFor(members), actionsFor(classes[0])));
+ const group: GroupId = tester !== null && eliminated.has(tester) ? "HYPOTHESIS_ELIMINATED"
+ : item.resultRecorded ? "RESULT_RECORDED"
+ : !item.executable ? "NOT_EXECUTABLE"
+ : neutral ? "NO_DECISION_CHANGE"
+ : !counted ? "UNCOUNTABLE"
+ : item.cost === null ? "COST_UNKNOWN" : "READY";
+ placed.push({ key: [worst, riskRank(item.riskTier), item.cost ?? Number.POSITIVE_INFINITY, reversibilityRank(item.reversibility), index],
+ ranked: { id: item.id, group, worst, counted, tie: false, costUnknown: item.cost === null, eliminatedOnce: tester !== null && once.has(tester) } });
+ });
+ const groups = GROUP_ORDER.map(group => {
+ const members = placed.filter(entry => entry.ranked.group === group).sort((a, b) => compareKeys(a.key, b.key));
+ const same = (a: number[], b: number[]) => a.slice(0, 4).every((value, at) => value === b[at]);
+ members.forEach((entry, at) => { entry.ranked.tie = (at > 0 && same(entry.key, members[at - 1].key)) || (at < members.length - 1 && same(entry.key, members[at + 1].key)); });
+ return { group, items: members.map(entry => entry.ranked) };
+ }).filter(entry => entry.items.length > 0);
+ return { groups, excluded, candidates: candidates.length };
+}
diff --git a/apps/web/src/components/traceDemo.fixture.json b/apps/web/src/components/traceDemo.fixture.json
new file mode 100644
index 0000000..215c1a2
--- /dev/null
+++ b/apps/web/src/components/traceDemo.fixture.json
@@ -0,0 +1 @@
+{"phase1":{"record_digest":"b211df557abe45c80a13ae04e72ca603254c339d1d54330494fe40a6296a2f71","set_digest":"c21049d475ad4010ca3440cdfa6ee8e01391fc02097a9b9c03942f6936b024ff","items":[{"item_id":"SYN-REQ-RAD-001","item_key":"k-req","kind":"REQUIREMENT","title":"Synthetic radar detects a target and avoids false tracks in dry, rain and fog (invented example)","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-DET-DRY","item_key":"k-c0","kind":"CRITERION","title":"Detection rate in dry weather","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-DET-FOG","item_key":"k-c4","kind":"CRITERION","title":"Detection rate in fog","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-DET-RAIN","item_key":"k-c2","kind":"CRITERION","title":"Detection rate in rain","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-FA-DRY","item_key":"k-c1","kind":"CRITERION","title":"False tracks per minute in dry weather","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-FA-FOG","item_key":"k-c5","kind":"CRITERION","title":"False tracks per minute in fog","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-FA-RAIN","item_key":"k-c3","kind":"CRITERION","title":"False tracks per minute in rain","fields":{},"source_span_refs":[]},{"item_id":"SYN-TC-DRY","item_key":"k-t0","kind":"TEST_CASE","title":"Dry weather run","fields":{},"source_span_refs":[]},{"item_id":"SYN-TC-FOG","item_key":"k-t2","kind":"TEST_CASE","title":"Fog run","fields":{},"source_span_refs":[]},{"item_id":"SYN-TC-RAIN","item_key":"k-t1","kind":"TEST_CASE","title":"Rain run","fields":{},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-DET-DRY","item_key":"k-SYN-RES-SYN-C-DET-DRY","kind":"RESULT","title":"","fields":{},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-FA-DRY","item_key":"k-SYN-RES-SYN-C-FA-DRY","kind":"RESULT","title":"","fields":{},"source_span_refs":[]}],"links":[{"link_id":"L-prod-SYN-RES-SYN-C-DET-DRY","from_id":"SYN-TC-DRY","to_id":"SYN-RES-SYN-C-DET-DRY","relation":"PRODUCES"},{"link_id":"L-prod-SYN-RES-SYN-C-FA-DRY","from_id":"SYN-TC-DRY","to_id":"SYN-RES-SYN-C-FA-DRY","relation":"PRODUCES"},{"link_id":"L-ref-SYN-C-DET-DRY","from_id":"SYN-C-DET-DRY","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-DET-FOG","from_id":"SYN-C-DET-FOG","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-DET-RAIN","from_id":"SYN-C-DET-RAIN","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-FA-DRY","from_id":"SYN-C-FA-DRY","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-FA-FOG","from_id":"SYN-C-FA-FOG","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-FA-RAIN","from_id":"SYN-C-FA-RAIN","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ver-SYN-C-DET-DRY","from_id":"SYN-C-DET-DRY","to_id":"SYN-TC-DRY","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-DET-FOG","from_id":"SYN-C-DET-FOG","to_id":"SYN-TC-FOG","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-DET-RAIN","from_id":"SYN-C-DET-RAIN","to_id":"SYN-TC-RAIN","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-FA-DRY","from_id":"SYN-C-FA-DRY","to_id":"SYN-TC-DRY","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-FA-FOG","from_id":"SYN-C-FA-FOG","to_id":"SYN-TC-FOG","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-FA-RAIN","from_id":"SYN-C-FA-RAIN","to_id":"SYN-TC-RAIN","relation":"VERIFIED_BY"}],"rules":[{"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"criterion_id":"SYN-C-DET-DRY","measure":"detection_rate","comparator":">=","threshold":"0.90","unit":"ratio","condition":"weather=dry","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"criterion_id":"SYN-C-DET-FOG","measure":"detection_rate","comparator":">=","threshold":"0.90","unit":"ratio","condition":"weather=fog","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"criterion_id":"SYN-C-DET-RAIN","measure":"detection_rate","comparator":">=","threshold":"0.90","unit":"ratio","condition":"weather=rain","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"criterion_id":"SYN-C-FA-DRY","measure":"false_track_rate","comparator":"<=","threshold":"0.50","unit":"per_min","condition":"weather=dry","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"criterion_id":"SYN-C-FA-FOG","measure":"false_track_rate","comparator":"<=","threshold":"0.50","unit":"per_min","condition":"weather=fog","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"criterion_id":"SYN-C-FA-RAIN","measure":"false_track_rate","comparator":"<=","threshold":"0.50","unit":"per_min","condition":"weather=rain","rounding":{"digits":2,"mode":"HALF_UP"},"required":true}],"results":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1,"criterion_id":"SYN-C-DET-DRY","condition":"weather=dry","value":"0.95","raw_value":null,"unit":"ratio","numerator":19,"denominator":20,"observed_at":"2026-01-01T00:00:00Z","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"]},{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1,"criterion_id":"SYN-C-FA-DRY","condition":"weather=dry","value":"0.30","raw_value":null,"unit":"per_min","numerator":3,"denominator":10,"observed_at":"2026-01-01T00:00:00Z","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"]}],"policy":{"name":"LATEST_PER_CONDITION","version":1},"verdicts":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"condition":"weather=dry","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"condition":"weather=dry","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"condition":"weather=fog","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"condition":"weather=fog","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"08eff1bb3f9e3321ce7d93ab37ebd9847bfbd881ddd83776e0e530fe017a43f3","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"parent_digest":null,"verdict_digest":"aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","revision_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"08eff1bb3f9e3321ce7d93ab37ebd9847bfbd881ddd83776e0e530fe017a43f3","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"parent_digest":null,"verdict_digest":"aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","revision_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"condition":"weather=dry","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"condition":"weather=dry","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"condition":"weather=fog","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"condition":"weather=fog","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-RAIN","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"a216e13f983667a3cbe5639c6ed14f2fb7ef79a950586192a073205d8f1178f2","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"condition":"weather=rain","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"parent_digest":null,"verdict_digest":"2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6","revision_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-RAIN","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"a216e13f983667a3cbe5639c6ed14f2fb7ef79a950586192a073205d8f1178f2","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"condition":"weather=rain","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"parent_digest":null,"verdict_digest":"2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6","revision_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33"}],"history_total":1},{"subject_kind":"REQUIREMENT","subject_id":"SYN-REQ-RAD-001","state":"HOLD_INCOMPLETE","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"7eaf96565538864bcbfb3ad0d8f36b02eeedb89a8a55e26d0df5cd97a7dcaaf7","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6"]},"selection":null,"conditions":{"required":true,"rule_id":null,"rule_revision":null,"condition":null,"unit":null,"comparator":null,"threshold":null,"rounding":null,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_NO_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:HOLD_NO_RESULT"],"human":[]},"parent_digest":null,"verdict_digest":"7a63a4565ab76211c1fc6d3603a35a1c4e94c2bf5261a617eabf37f162005e85","revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"REQUIREMENT","subject_id":"SYN-REQ-RAD-001","state":"HOLD_INCOMPLETE","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"7eaf96565538864bcbfb3ad0d8f36b02eeedb89a8a55e26d0df5cd97a7dcaaf7","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6"]},"selection":null,"conditions":{"required":true,"rule_id":null,"rule_revision":null,"condition":null,"unit":null,"comparator":null,"threshold":null,"rounding":null,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_NO_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:HOLD_NO_RESULT"],"human":[]},"parent_digest":null,"verdict_digest":"7a63a4565ab76211c1fc6d3603a35a1c4e94c2bf5261a617eabf37f162005e85","revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005"}],"history_total":1}],"confirmations":[],"pending_changes":[]},"afterConfirm":{"confirmations":[{"actor_id":"human:local-user","confirmed_at":"2026-10-02T16:11:18.512000Z","rationale":"1단계 결과 확인","verdict_revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005"}],"items":[{"fields":{},"item_id":"SYN-REQ-RAD-001","item_key":"k-req","kind":"REQUIREMENT","source_span_refs":[],"title":"Synthetic radar detects a target and avoids false tracks in dry, rain and fog (invented example)"},{"fields":{},"item_id":"SYN-C-DET-DRY","item_key":"k-c0","kind":"CRITERION","source_span_refs":[],"title":"Detection rate in dry weather"},{"fields":{},"item_id":"SYN-C-DET-FOG","item_key":"k-c4","kind":"CRITERION","source_span_refs":[],"title":"Detection rate in fog"},{"fields":{},"item_id":"SYN-C-DET-RAIN","item_key":"k-c2","kind":"CRITERION","source_span_refs":[],"title":"Detection rate in rain"},{"fields":{},"item_id":"SYN-C-FA-DRY","item_key":"k-c1","kind":"CRITERION","source_span_refs":[],"title":"False tracks per minute in dry weather"},{"fields":{},"item_id":"SYN-C-FA-FOG","item_key":"k-c5","kind":"CRITERION","source_span_refs":[],"title":"False tracks per minute in fog"},{"fields":{},"item_id":"SYN-C-FA-RAIN","item_key":"k-c3","kind":"CRITERION","source_span_refs":[],"title":"False tracks per minute in rain"},{"fields":{},"item_id":"SYN-TC-DRY","item_key":"k-t0","kind":"TEST_CASE","source_span_refs":[],"title":"Dry weather run"},{"fields":{},"item_id":"SYN-TC-FOG","item_key":"k-t2","kind":"TEST_CASE","source_span_refs":[],"title":"Fog run"},{"fields":{},"item_id":"SYN-TC-RAIN","item_key":"k-t1","kind":"TEST_CASE","source_span_refs":[],"title":"Rain run"},{"fields":{},"item_id":"SYN-RES-SYN-C-DET-DRY","item_key":"k-SYN-RES-SYN-C-DET-DRY","kind":"RESULT","source_span_refs":[],"title":""},{"fields":{},"item_id":"SYN-RES-SYN-C-FA-DRY","item_key":"k-SYN-RES-SYN-C-FA-DRY","kind":"RESULT","source_span_refs":[],"title":""}],"links":[{"from_id":"SYN-TC-DRY","link_id":"L-prod-SYN-RES-SYN-C-DET-DRY","relation":"PRODUCES","to_id":"SYN-RES-SYN-C-DET-DRY"},{"from_id":"SYN-TC-DRY","link_id":"L-prod-SYN-RES-SYN-C-FA-DRY","relation":"PRODUCES","to_id":"SYN-RES-SYN-C-FA-DRY"},{"from_id":"SYN-C-DET-DRY","link_id":"L-ref-SYN-C-DET-DRY","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-DET-FOG","link_id":"L-ref-SYN-C-DET-FOG","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-DET-RAIN","link_id":"L-ref-SYN-C-DET-RAIN","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-FA-DRY","link_id":"L-ref-SYN-C-FA-DRY","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-FA-FOG","link_id":"L-ref-SYN-C-FA-FOG","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-FA-RAIN","link_id":"L-ref-SYN-C-FA-RAIN","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-DET-DRY","link_id":"L-ver-SYN-C-DET-DRY","relation":"VERIFIED_BY","to_id":"SYN-TC-DRY"},{"from_id":"SYN-C-DET-FOG","link_id":"L-ver-SYN-C-DET-FOG","relation":"VERIFIED_BY","to_id":"SYN-TC-FOG"},{"from_id":"SYN-C-DET-RAIN","link_id":"L-ver-SYN-C-DET-RAIN","relation":"VERIFIED_BY","to_id":"SYN-TC-RAIN"},{"from_id":"SYN-C-FA-DRY","link_id":"L-ver-SYN-C-FA-DRY","relation":"VERIFIED_BY","to_id":"SYN-TC-DRY"},{"from_id":"SYN-C-FA-FOG","link_id":"L-ver-SYN-C-FA-FOG","relation":"VERIFIED_BY","to_id":"SYN-TC-FOG"},{"from_id":"SYN-C-FA-RAIN","link_id":"L-ver-SYN-C-FA-RAIN","relation":"VERIFIED_BY","to_id":"SYN-TC-RAIN"}],"pending_changes":[],"policy":{"name":"LATEST_PER_CONDITION","version":1},"record_digest":"bcfd97b752502ce6a07b5e1e980a98e8c7ec641d88201af7ce0a60b58ede6cc7","results":[{"condition":"weather=dry","criterion_id":"SYN-C-DET-DRY","denominator":20,"numerator":19,"observed_at":"2026-01-01T00:00:00Z","raw_value":null,"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1,"source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"],"unit":"ratio","value":"0.95"},{"condition":"weather=dry","criterion_id":"SYN-C-FA-DRY","denominator":10,"numerator":3,"observed_at":"2026-01-01T00:00:00Z","raw_value":null,"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1,"source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"unit":"per_min","value":"0.30"}],"rules":[{"comparator":">=","condition":"weather=dry","criterion_id":"SYN-C-DET-DRY","measure":"detection_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"threshold":"0.90","unit":"ratio"},{"comparator":">=","condition":"weather=fog","criterion_id":"SYN-C-DET-FOG","measure":"detection_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"threshold":"0.90","unit":"ratio"},{"comparator":">=","condition":"weather=rain","criterion_id":"SYN-C-DET-RAIN","measure":"detection_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"threshold":"0.90","unit":"ratio"},{"comparator":"<=","condition":"weather=dry","criterion_id":"SYN-C-FA-DRY","measure":"false_track_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"threshold":"0.50","unit":"per_min"},{"comparator":"<=","condition":"weather=fog","criterion_id":"SYN-C-FA-FOG","measure":"false_track_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"threshold":"0.50","unit":"per_min"},{"comparator":"<=","condition":"weather=rain","criterion_id":"SYN-C-FA-RAIN","measure":"false_track_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"threshold":"0.50","unit":"per_min"}],"set_digest":"c21049d475ad4010ca3440cdfa6ee8e01391fc02097a9b9c03942f6936b024ff","verdicts":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=dry","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"threshold":"0.90","unit":"ratio"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":[],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=dry","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"threshold":"0.90","unit":"ratio"},"parent_digest":null,"reasons":{"computed":[],"human":[]},"revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"PASS_COMPUTED","subject_id":"SYN-C-DET-DRY","subject_kind":"CRITERION","verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04"}],"revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"PASS_COMPUTED","subject_id":"SYN-C-DET-DRY","subject_kind":"CRITERION","verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=fog","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"threshold":"0.90","unit":"ratio"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=fog","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"threshold":"0.90","unit":"ratio"},"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-DET-FOG","subject_kind":"CRITERION","verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40"}],"revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-DET-FOG","subject_kind":"CRITERION","verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"08eff1bb3f9e3321ce7d93ab37ebd9847bfbd881ddd83776e0e530fe017a43f3","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"threshold":"0.90","unit":"ratio"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"08eff1bb3f9e3321ce7d93ab37ebd9847bfbd881ddd83776e0e530fe017a43f3","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"threshold":"0.90","unit":"ratio"},"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"revision_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-DET-RAIN","subject_kind":"CRITERION","verdict_digest":"aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0"}],"revision_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-DET-RAIN","subject_kind":"CRITERION","verdict_digest":"aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=dry","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"threshold":"0.50","unit":"per_min"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":[],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=dry","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"threshold":"0.50","unit":"per_min"},"parent_digest":null,"reasons":{"computed":[],"human":[]},"revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"PASS_COMPUTED","subject_id":"SYN-C-FA-DRY","subject_kind":"CRITERION","verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874"}],"revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"PASS_COMPUTED","subject_id":"SYN-C-FA-DRY","subject_kind":"CRITERION","verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=fog","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"threshold":"0.50","unit":"per_min"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=fog","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"threshold":"0.50","unit":"per_min"},"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-FA-FOG","subject_kind":"CRITERION","verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433"}],"revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-FA-FOG","subject_kind":"CRITERION","verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"a216e13f983667a3cbe5639c6ed14f2fb7ef79a950586192a073205d8f1178f2","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"threshold":"0.50","unit":"per_min"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"a216e13f983667a3cbe5639c6ed14f2fb7ef79a950586192a073205d8f1178f2","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"threshold":"0.50","unit":"per_min"},"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"revision_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-FA-RAIN","subject_kind":"CRITERION","verdict_digest":"2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6"}],"revision_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-FA-RAIN","subject_kind":"CRITERION","verdict_digest":"2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6"],"evidence_text_preserved":false,"input_digest":"7eaf96565538864bcbfb3ad0d8f36b02eeedb89a8a55e26d0df5cd97a7dcaaf7","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":null,"condition":null,"required":true,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"],"rounding":null,"rule_id":null,"rule_revision":null,"threshold":null,"unit":null},"confirmations":[{"actor_id":"human:local-user","confirmed_at":"2026-10-02T16:11:18.512000Z","rationale":"1단계 결과 확인","verdict_revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005"}],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_NO_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:HOLD_NO_RESULT"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6"],"evidence_text_preserved":false,"input_digest":"7eaf96565538864bcbfb3ad0d8f36b02eeedb89a8a55e26d0df5cd97a7dcaaf7","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":null,"condition":null,"required":true,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"],"rounding":null,"rule_id":null,"rule_revision":null,"threshold":null,"unit":null},"parent_digest":null,"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_NO_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:HOLD_NO_RESULT"],"human":[]},"revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","selection":null,"state":"HOLD_INCOMPLETE","subject_id":"SYN-REQ-RAD-001","subject_kind":"REQUIREMENT","verdict_digest":"7a63a4565ab76211c1fc6d3603a35a1c4e94c2bf5261a617eabf37f162005e85"}],"revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","selection":null,"state":"HOLD_INCOMPLETE","subject_id":"SYN-REQ-RAD-001","subject_kind":"REQUIREMENT","verdict_digest":"7a63a4565ab76211c1fc6d3603a35a1c4e94c2bf5261a617eabf37f162005e85"}]},"rainCsv":"format,row_type,base_set_digest,item_id,link_id,rule_id,result_id,target_kind,target_id,item_key,kind,title,fields_json,source_span_refs,from_id,to_id,relation,criterion_id,rule_revision,measure,comparator,threshold,unit,condition,rounding_digits,rounding_mode,required,result_revision,value,raw_value,numerator,denominator,observed_at\r\nthoth-trace-csv/1,ITEM,c21049d475ad4010ca3440cdfa6ee8e01391fc02097a9b9c03942f6936b024ff,SYN-RES-SYN-C-DET-RAIN,,,,,,k-SYN-RES-SYN-C-DET-RAIN,RESULT,,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,c21049d475ad4010ca3440cdfa6ee8e01391fc02097a9b9c03942f6936b024ff,SYN-RES-SYN-C-FA-RAIN,,,,,,k-SYN-RES-SYN-C-FA-RAIN,RESULT,,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,c21049d475ad4010ca3440cdfa6ee8e01391fc02097a9b9c03942f6936b024ff,,L-prod-SYN-RES-SYN-C-DET-RAIN,,,,,,,,,,SYN-TC-RAIN,SYN-RES-SYN-C-DET-RAIN,PRODUCES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,c21049d475ad4010ca3440cdfa6ee8e01391fc02097a9b9c03942f6936b024ff,,L-prod-SYN-RES-SYN-C-FA-RAIN,,,,,,,,,,SYN-TC-RAIN,SYN-RES-SYN-C-FA-RAIN,PRODUCES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,RESULT,c21049d475ad4010ca3440cdfa6ee8e01391fc02097a9b9c03942f6936b024ff,,,,SYN-RES-SYN-C-DET-RAIN,,,,,,,\"[\"\"31_RESULT_RAIN_SYNTHETIC.yaml#detection\"\"]\",,,,SYN-C-DET-RAIN,,,,,ratio,weather=rain,,,,1,0.80,,16,20,2026-01-02T00:00:00.000Z\r\nthoth-trace-csv/1,RESULT,c21049d475ad4010ca3440cdfa6ee8e01391fc02097a9b9c03942f6936b024ff,,,,SYN-RES-SYN-C-FA-RAIN,,,,,,,\"[\"\"31_RESULT_RAIN_SYNTHETIC.yaml#false_track\"\"]\",,,,SYN-C-FA-RAIN,,,,,per_min,weather=rain,,,,1,0.70,,7,10,2026-01-02T00:00:00.000Z\r\n","rainPreview":{"preview_id":"481a76cd6a4909f369e26040b670b3659be90cb028e041d09c6b27cb5ed5968c","input_sha256":"edee70faa74048744f8348f93d3744452365d23c08394167d94be7bf563bcc7a","mode":"UPDATE","current_set_digest":"c21049d475ad4010ca3440cdfa6ee8e01391fc02097a9b9c03942f6936b024ff","applicable":true,"changes":{"added":[{"kind":"ITEM","id":"SYN-RES-SYN-C-DET-RAIN","row":2,"fields":[]},{"kind":"ITEM","id":"SYN-RES-SYN-C-FA-RAIN","row":3,"fields":[]},{"kind":"LINK","id":"L-prod-SYN-RES-SYN-C-DET-RAIN","row":4,"fields":[]},{"kind":"LINK","id":"L-prod-SYN-RES-SYN-C-FA-RAIN","row":5,"fields":[]},{"kind":"RESULT","id":"SYN-RES-SYN-C-DET-RAIN","row":6,"fields":[]},{"kind":"RESULT","id":"SYN-RES-SYN-C-FA-RAIN","row":7,"fields":[]}],"updated":[],"deleted":[],"unchanged":0},"conflicts":[],"losses":["evidence_text: only the positions of evidence (source_span_refs) are kept, not the text","verdict_history: computed verdicts and their revisions are recomputed after an import","confirmations: who confirmed which verdict stays in THOTH and is not in the file","selection_policy: the policy that picks among results stays as set in THOTH","pending_changes: which verdicts are out of date is worked out again by THOTH"],"spreadsheet_suspects":[]},"rainApply":{"applied":true,"counts":{"added":6,"deleted":0,"updated":0},"record_digest":"18086e48d16a2fe8abe59e821cfe0408b8c9f19935093950220ce2ca258d3bf1","set_digest":"05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a","trace":{"confirmations":[{"actor_id":"human:local-user","confirmed_at":"2026-10-02T16:11:18.512000Z","rationale":"1단계 결과 확인","verdict_revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005"}],"items":[{"fields":{},"item_id":"SYN-REQ-RAD-001","item_key":"k-req","kind":"REQUIREMENT","source_span_refs":[],"title":"Synthetic radar detects a target and avoids false tracks in dry, rain and fog (invented example)"},{"fields":{},"item_id":"SYN-C-DET-DRY","item_key":"k-c0","kind":"CRITERION","source_span_refs":[],"title":"Detection rate in dry weather"},{"fields":{},"item_id":"SYN-C-DET-FOG","item_key":"k-c4","kind":"CRITERION","source_span_refs":[],"title":"Detection rate in fog"},{"fields":{},"item_id":"SYN-C-DET-RAIN","item_key":"k-c2","kind":"CRITERION","source_span_refs":[],"title":"Detection rate in rain"},{"fields":{},"item_id":"SYN-C-FA-DRY","item_key":"k-c1","kind":"CRITERION","source_span_refs":[],"title":"False tracks per minute in dry weather"},{"fields":{},"item_id":"SYN-C-FA-FOG","item_key":"k-c5","kind":"CRITERION","source_span_refs":[],"title":"False tracks per minute in fog"},{"fields":{},"item_id":"SYN-C-FA-RAIN","item_key":"k-c3","kind":"CRITERION","source_span_refs":[],"title":"False tracks per minute in rain"},{"fields":{},"item_id":"SYN-TC-DRY","item_key":"k-t0","kind":"TEST_CASE","source_span_refs":[],"title":"Dry weather run"},{"fields":{},"item_id":"SYN-TC-FOG","item_key":"k-t2","kind":"TEST_CASE","source_span_refs":[],"title":"Fog run"},{"fields":{},"item_id":"SYN-TC-RAIN","item_key":"k-t1","kind":"TEST_CASE","source_span_refs":[],"title":"Rain run"},{"fields":{},"item_id":"SYN-RES-SYN-C-DET-DRY","item_key":"k-SYN-RES-SYN-C-DET-DRY","kind":"RESULT","source_span_refs":[],"title":""},{"fields":{},"item_id":"SYN-RES-SYN-C-FA-DRY","item_key":"k-SYN-RES-SYN-C-FA-DRY","kind":"RESULT","source_span_refs":[],"title":""},{"fields":{},"item_id":"SYN-RES-SYN-C-DET-RAIN","item_key":"k-SYN-RES-SYN-C-DET-RAIN","kind":"RESULT","source_span_refs":[],"title":""},{"fields":{},"item_id":"SYN-RES-SYN-C-FA-RAIN","item_key":"k-SYN-RES-SYN-C-FA-RAIN","kind":"RESULT","source_span_refs":[],"title":""}],"links":[{"from_id":"SYN-TC-DRY","link_id":"L-prod-SYN-RES-SYN-C-DET-DRY","relation":"PRODUCES","to_id":"SYN-RES-SYN-C-DET-DRY"},{"from_id":"SYN-TC-DRY","link_id":"L-prod-SYN-RES-SYN-C-FA-DRY","relation":"PRODUCES","to_id":"SYN-RES-SYN-C-FA-DRY"},{"from_id":"SYN-C-DET-DRY","link_id":"L-ref-SYN-C-DET-DRY","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-DET-FOG","link_id":"L-ref-SYN-C-DET-FOG","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-DET-RAIN","link_id":"L-ref-SYN-C-DET-RAIN","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-FA-DRY","link_id":"L-ref-SYN-C-FA-DRY","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-FA-FOG","link_id":"L-ref-SYN-C-FA-FOG","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-FA-RAIN","link_id":"L-ref-SYN-C-FA-RAIN","relation":"REFINES","to_id":"SYN-REQ-RAD-001"},{"from_id":"SYN-C-DET-DRY","link_id":"L-ver-SYN-C-DET-DRY","relation":"VERIFIED_BY","to_id":"SYN-TC-DRY"},{"from_id":"SYN-C-DET-FOG","link_id":"L-ver-SYN-C-DET-FOG","relation":"VERIFIED_BY","to_id":"SYN-TC-FOG"},{"from_id":"SYN-C-DET-RAIN","link_id":"L-ver-SYN-C-DET-RAIN","relation":"VERIFIED_BY","to_id":"SYN-TC-RAIN"},{"from_id":"SYN-C-FA-DRY","link_id":"L-ver-SYN-C-FA-DRY","relation":"VERIFIED_BY","to_id":"SYN-TC-DRY"},{"from_id":"SYN-C-FA-FOG","link_id":"L-ver-SYN-C-FA-FOG","relation":"VERIFIED_BY","to_id":"SYN-TC-FOG"},{"from_id":"SYN-C-FA-RAIN","link_id":"L-ver-SYN-C-FA-RAIN","relation":"VERIFIED_BY","to_id":"SYN-TC-RAIN"},{"from_id":"SYN-TC-RAIN","link_id":"L-prod-SYN-RES-SYN-C-DET-RAIN","relation":"PRODUCES","to_id":"SYN-RES-SYN-C-DET-RAIN"},{"from_id":"SYN-TC-RAIN","link_id":"L-prod-SYN-RES-SYN-C-FA-RAIN","relation":"PRODUCES","to_id":"SYN-RES-SYN-C-FA-RAIN"}],"pending_changes":[],"policy":{"name":"LATEST_PER_CONDITION","version":1},"record_digest":"18086e48d16a2fe8abe59e821cfe0408b8c9f19935093950220ce2ca258d3bf1","results":[{"condition":"weather=dry","criterion_id":"SYN-C-DET-DRY","denominator":20,"numerator":19,"observed_at":"2026-01-01T00:00:00Z","raw_value":null,"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1,"source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"],"unit":"ratio","value":"0.95"},{"condition":"weather=dry","criterion_id":"SYN-C-FA-DRY","denominator":10,"numerator":3,"observed_at":"2026-01-01T00:00:00Z","raw_value":null,"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1,"source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"unit":"per_min","value":"0.30"},{"condition":"weather=rain","criterion_id":"SYN-C-DET-RAIN","denominator":20,"numerator":16,"observed_at":"2026-01-02T00:00:00Z","raw_value":null,"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1,"source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"],"unit":"ratio","value":"0.80"},{"condition":"weather=rain","criterion_id":"SYN-C-FA-RAIN","denominator":10,"numerator":7,"observed_at":"2026-01-02T00:00:00Z","raw_value":null,"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1,"source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"unit":"per_min","value":"0.70"}],"rules":[{"comparator":">=","condition":"weather=dry","criterion_id":"SYN-C-DET-DRY","measure":"detection_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"threshold":"0.90","unit":"ratio"},{"comparator":">=","condition":"weather=fog","criterion_id":"SYN-C-DET-FOG","measure":"detection_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"threshold":"0.90","unit":"ratio"},{"comparator":">=","condition":"weather=rain","criterion_id":"SYN-C-DET-RAIN","measure":"detection_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"threshold":"0.90","unit":"ratio"},{"comparator":"<=","condition":"weather=dry","criterion_id":"SYN-C-FA-DRY","measure":"false_track_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"threshold":"0.50","unit":"per_min"},{"comparator":"<=","condition":"weather=fog","criterion_id":"SYN-C-FA-FOG","measure":"false_track_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"threshold":"0.50","unit":"per_min"},{"comparator":"<=","condition":"weather=rain","criterion_id":"SYN-C-FA-RAIN","measure":"false_track_rate","required":true,"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"threshold":"0.50","unit":"per_min"}],"set_digest":"05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a","verdicts":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=dry","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"threshold":"0.90","unit":"ratio"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":[],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=dry","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"threshold":"0.90","unit":"ratio"},"parent_digest":null,"reasons":{"computed":[],"human":[]},"revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"PASS_COMPUTED","subject_id":"SYN-C-DET-DRY","subject_kind":"CRITERION","verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04"}],"revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"PASS_COMPUTED","subject_id":"SYN-C-DET-DRY","subject_kind":"CRITERION","verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=fog","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"threshold":"0.90","unit":"ratio"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=fog","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"threshold":"0.90","unit":"ratio"},"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-DET-FOG","subject_kind":"CRITERION","verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40"}],"revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-DET-FOG","subject_kind":"CRITERION","verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"29ebb5796de1d21cccabec91664d151003b3bea2c1c81d9d7064b14c42d7349b","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"]},"cause":{"changed":[{"after":"0.80 ratio @weather=rain","after_revision":1,"before":null,"before_revision":null,"criterion_ids":["SYN-C-DET-RAIN"],"field":"*","kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","requirement_ids":["SYN-REQ-RAD-001"]}],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"threshold":"0.90","unit":"ratio"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":2,"parent_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260","reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-DET-RAIN:value=0.80 need >= 0.90 ratio"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"29ebb5796de1d21cccabec91664d151003b3bea2c1c81d9d7064b14c42d7349b","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"]},"cause":{"changed":[{"after":"0.80 ratio @weather=rain","after_revision":1,"before":null,"before_revision":null,"criterion_ids":["SYN-C-DET-RAIN"],"field":"*","kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","requirement_ids":["SYN-REQ-RAD-001"]}],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"threshold":"0.90","unit":"ratio"},"parent_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260","reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-DET-RAIN:value=0.80 need >= 0.90 ratio"],"human":[]},"revision_digest":"ea5211313d09ae6003bad84e111dacf3df9f63feb8164d7299240ca927175469","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"FAIL_COMPUTED","subject_id":"SYN-C-DET-RAIN","subject_kind":"CRITERION","verdict_digest":"aba4509f9bce067cb4eaf4ca0e8a68dcccb4a499627f8ef8240fed0ab49ae1d8"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"08eff1bb3f9e3321ce7d93ab37ebd9847bfbd881ddd83776e0e530fe017a43f3","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":">=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"threshold":"0.90","unit":"ratio"},"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"revision_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-DET-RAIN","subject_kind":"CRITERION","verdict_digest":"aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0"}],"revision_digest":"ea5211313d09ae6003bad84e111dacf3df9f63feb8164d7299240ca927175469","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"FAIL_COMPUTED","subject_id":"SYN-C-DET-RAIN","subject_kind":"CRITERION","verdict_digest":"aba4509f9bce067cb4eaf4ca0e8a68dcccb4a499627f8ef8240fed0ab49ae1d8"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=dry","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"threshold":"0.50","unit":"per_min"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":[],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=dry","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"threshold":"0.50","unit":"per_min"},"parent_digest":null,"reasons":{"computed":[],"human":[]},"revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"PASS_COMPUTED","subject_id":"SYN-C-FA-DRY","subject_kind":"CRITERION","verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874"}],"revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"PASS_COMPUTED","subject_id":"SYN-C-FA-DRY","subject_kind":"CRITERION","verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=fog","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"threshold":"0.50","unit":"per_min"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":1,"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=fog","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"threshold":"0.50","unit":"per_min"},"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-FA-FOG","subject_kind":"CRITERION","verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433"}],"revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-FA-FOG","subject_kind":"CRITERION","verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"536f7301092050fbf960a792dc289bb2f2040ace4b829ba7bdf6a671000da90f","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[{"after":"0.70 per_min @weather=rain","after_revision":1,"before":null,"before_revision":null,"criterion_ids":["SYN-C-FA-RAIN"],"field":"*","kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","requirement_ids":["SYN-REQ-RAD-001"]}],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"threshold":"0.50","unit":"per_min"},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":2,"parent_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-FA-RAIN:value=0.70 need <= 0.50 per_min"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"536f7301092050fbf960a792dc289bb2f2040ace4b829ba7bdf6a671000da90f","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[{"after":"0.70 per_min @weather=rain","after_revision":1,"before":null,"before_revision":null,"criterion_ids":["SYN-C-FA-RAIN"],"field":"*","kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","requirement_ids":["SYN-REQ-RAD-001"]}],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"threshold":"0.50","unit":"per_min"},"parent_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-FA-RAIN:value=0.70 need <= 0.50 per_min"],"human":[]},"revision_digest":"75f7b8d559a935782e8e50d1e904ebfbe52f412175ea0c6fda3797016ee81a71","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"FAIL_COMPUTED","subject_id":"SYN-C-FA-RAIN","subject_kind":"CRITERION","verdict_digest":"ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":[],"evidence_text_preserved":false,"input_digest":"a216e13f983667a3cbe5639c6ed14f2fb7ef79a950586192a073205d8f1178f2","source_span_refs":[]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":"<=","condition":"weather=rain","required":true,"required_criteria":[],"rounding":{"digits":2,"mode":"HALF_UP"},"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"threshold":"0.50","unit":"per_min"},"parent_digest":null,"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"revision_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","selection":{"candidates":[],"chosen":[],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"HOLD_NO_RESULT","subject_id":"SYN-C-FA-RAIN","subject_kind":"CRITERION","verdict_digest":"2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6"}],"revision_digest":"75f7b8d559a935782e8e50d1e904ebfbe52f412175ea0c6fda3797016ee81a71","selection":{"candidates":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"excluded":[],"policy":{"name":"LATEST_PER_CONDITION","version":1}},"state":"FAIL_COMPUTED","subject_id":"SYN-C-FA-RAIN","subject_kind":"CRITERION","verdict_digest":"ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"basis":{"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aba4509f9bce067cb4eaf4ca0e8a68dcccb4a499627f8ef8240fed0ab49ae1d8","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"],"evidence_text_preserved":false,"input_digest":"3eb078dea44d1d9d5a5d33db8feb0dfa1b131f61600035069f6db9f334d87b6e","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track","31_RESULT_RAIN_SYNTHETIC.yaml#detection","31_RESULT_RAIN_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[{"after":"0.80 ratio @weather=rain","after_revision":1,"before":null,"before_revision":null,"criterion_ids":["SYN-C-DET-RAIN"],"field":"*","kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","requirement_ids":["SYN-REQ-RAD-001"]},{"after":"0.70 per_min @weather=rain","after_revision":1,"before":null,"before_revision":null,"criterion_ids":["SYN-C-FA-RAIN"],"field":"*","kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","requirement_ids":["SYN-REQ-RAD-001"]}],"trigger":"CSV_IMPORT"},"conditions":{"comparator":null,"condition":null,"required":true,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"],"rounding":null,"rule_id":null,"rule_revision":null,"threshold":null,"unit":null},"confirmations":[],"currentness":{"changed_dependencies":[],"state":"CURRENT"},"history_total":2,"parent_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:FAIL_COMPUTED","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:FAIL_COMPUTED"],"human":[]},"recent_history":[{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"basis":{"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aba4509f9bce067cb4eaf4ca0e8a68dcccb4a499627f8ef8240fed0ab49ae1d8","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"],"evidence_text_preserved":false,"input_digest":"3eb078dea44d1d9d5a5d33db8feb0dfa1b131f61600035069f6db9f334d87b6e","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track","31_RESULT_RAIN_SYNTHETIC.yaml#detection","31_RESULT_RAIN_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[{"after":"0.80 ratio @weather=rain","after_revision":1,"before":null,"before_revision":null,"criterion_ids":["SYN-C-DET-RAIN"],"field":"*","kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","requirement_ids":["SYN-REQ-RAD-001"]},{"after":"0.70 per_min @weather=rain","after_revision":1,"before":null,"before_revision":null,"criterion_ids":["SYN-C-FA-RAIN"],"field":"*","kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","requirement_ids":["SYN-REQ-RAD-001"]}],"trigger":"CSV_IMPORT"},"conditions":{"comparator":null,"condition":null,"required":true,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"],"rounding":null,"rule_id":null,"rule_revision":null,"threshold":null,"unit":null},"parent_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:FAIL_COMPUTED","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:FAIL_COMPUTED"],"human":[]},"revision_digest":"9f07fbe8fc4199213951545610c906f83574d4fd4b1217fb7ff19a193766c794","selection":null,"state":"FAIL_WITH_INCOMPLETE_COVERAGE","subject_id":"SYN-REQ-RAD-001","subject_kind":"REQUIREMENT","verdict_digest":"234d1145ba1006f0a01ea0e7ea618b150a641d1dd7a81c066e58cc0b933e695b"},{"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"basis":{"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6"],"evidence_text_preserved":false,"input_digest":"7eaf96565538864bcbfb3ad0d8f36b02eeedb89a8a55e26d0df5cd97a7dcaaf7","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track"]},"cause":{"changed":[],"trigger":"CSV_IMPORT"},"conditions":{"comparator":null,"condition":null,"required":true,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"],"rounding":null,"rule_id":null,"rule_revision":null,"threshold":null,"unit":null},"parent_digest":null,"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_NO_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:HOLD_NO_RESULT"],"human":[]},"revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","selection":null,"state":"HOLD_INCOMPLETE","subject_id":"SYN-REQ-RAD-001","subject_kind":"REQUIREMENT","verdict_digest":"7a63a4565ab76211c1fc6d3603a35a1c4e94c2bf5261a617eabf37f162005e85"}],"revision_digest":"9f07fbe8fc4199213951545610c906f83574d4fd4b1217fb7ff19a193766c794","selection":null,"state":"FAIL_WITH_INCOMPLETE_COVERAGE","subject_id":"SYN-REQ-RAD-001","subject_kind":"REQUIREMENT","verdict_digest":"234d1145ba1006f0a01ea0e7ea618b150a641d1dd7a81c066e58cc0b933e695b"}]},"verdict_changes":[{"after":"FAIL_COMPUTED","before":"HOLD_NO_RESULT","subject_id":"SYN-C-DET-RAIN","subject_kind":"CRITERION"},{"after":"FAIL_COMPUTED","before":"HOLD_NO_RESULT","subject_id":"SYN-C-FA-RAIN","subject_kind":"CRITERION"},{"after":"FAIL_WITH_INCOMPLETE_COVERAGE","before":"HOLD_INCOMPLETE","subject_id":"SYN-REQ-RAD-001","subject_kind":"REQUIREMENT"}]},"phase2":{"record_digest":"18086e48d16a2fe8abe59e821cfe0408b8c9f19935093950220ce2ca258d3bf1","set_digest":"05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a","items":[{"item_id":"SYN-REQ-RAD-001","item_key":"k-req","kind":"REQUIREMENT","title":"Synthetic radar detects a target and avoids false tracks in dry, rain and fog (invented example)","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-DET-DRY","item_key":"k-c0","kind":"CRITERION","title":"Detection rate in dry weather","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-DET-FOG","item_key":"k-c4","kind":"CRITERION","title":"Detection rate in fog","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-DET-RAIN","item_key":"k-c2","kind":"CRITERION","title":"Detection rate in rain","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-FA-DRY","item_key":"k-c1","kind":"CRITERION","title":"False tracks per minute in dry weather","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-FA-FOG","item_key":"k-c5","kind":"CRITERION","title":"False tracks per minute in fog","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-FA-RAIN","item_key":"k-c3","kind":"CRITERION","title":"False tracks per minute in rain","fields":{},"source_span_refs":[]},{"item_id":"SYN-TC-DRY","item_key":"k-t0","kind":"TEST_CASE","title":"Dry weather run","fields":{},"source_span_refs":[]},{"item_id":"SYN-TC-FOG","item_key":"k-t2","kind":"TEST_CASE","title":"Fog run","fields":{},"source_span_refs":[]},{"item_id":"SYN-TC-RAIN","item_key":"k-t1","kind":"TEST_CASE","title":"Rain run","fields":{},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-DET-DRY","item_key":"k-SYN-RES-SYN-C-DET-DRY","kind":"RESULT","title":"","fields":{},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-FA-DRY","item_key":"k-SYN-RES-SYN-C-FA-DRY","kind":"RESULT","title":"","fields":{},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-DET-RAIN","item_key":"k-SYN-RES-SYN-C-DET-RAIN","kind":"RESULT","title":"","fields":{},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-FA-RAIN","item_key":"k-SYN-RES-SYN-C-FA-RAIN","kind":"RESULT","title":"","fields":{},"source_span_refs":[]}],"links":[{"link_id":"L-prod-SYN-RES-SYN-C-DET-DRY","from_id":"SYN-TC-DRY","to_id":"SYN-RES-SYN-C-DET-DRY","relation":"PRODUCES"},{"link_id":"L-prod-SYN-RES-SYN-C-FA-DRY","from_id":"SYN-TC-DRY","to_id":"SYN-RES-SYN-C-FA-DRY","relation":"PRODUCES"},{"link_id":"L-ref-SYN-C-DET-DRY","from_id":"SYN-C-DET-DRY","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-DET-FOG","from_id":"SYN-C-DET-FOG","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-DET-RAIN","from_id":"SYN-C-DET-RAIN","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-FA-DRY","from_id":"SYN-C-FA-DRY","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-FA-FOG","from_id":"SYN-C-FA-FOG","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-FA-RAIN","from_id":"SYN-C-FA-RAIN","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ver-SYN-C-DET-DRY","from_id":"SYN-C-DET-DRY","to_id":"SYN-TC-DRY","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-DET-FOG","from_id":"SYN-C-DET-FOG","to_id":"SYN-TC-FOG","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-DET-RAIN","from_id":"SYN-C-DET-RAIN","to_id":"SYN-TC-RAIN","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-FA-DRY","from_id":"SYN-C-FA-DRY","to_id":"SYN-TC-DRY","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-FA-FOG","from_id":"SYN-C-FA-FOG","to_id":"SYN-TC-FOG","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-FA-RAIN","from_id":"SYN-C-FA-RAIN","to_id":"SYN-TC-RAIN","relation":"VERIFIED_BY"},{"link_id":"L-prod-SYN-RES-SYN-C-DET-RAIN","from_id":"SYN-TC-RAIN","to_id":"SYN-RES-SYN-C-DET-RAIN","relation":"PRODUCES"},{"link_id":"L-prod-SYN-RES-SYN-C-FA-RAIN","from_id":"SYN-TC-RAIN","to_id":"SYN-RES-SYN-C-FA-RAIN","relation":"PRODUCES"}],"rules":[{"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"criterion_id":"SYN-C-DET-DRY","measure":"detection_rate","comparator":">=","threshold":"0.90","unit":"ratio","condition":"weather=dry","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"criterion_id":"SYN-C-DET-FOG","measure":"detection_rate","comparator":">=","threshold":"0.90","unit":"ratio","condition":"weather=fog","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"criterion_id":"SYN-C-DET-RAIN","measure":"detection_rate","comparator":">=","threshold":"0.90","unit":"ratio","condition":"weather=rain","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"criterion_id":"SYN-C-FA-DRY","measure":"false_track_rate","comparator":"<=","threshold":"0.50","unit":"per_min","condition":"weather=dry","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"criterion_id":"SYN-C-FA-FOG","measure":"false_track_rate","comparator":"<=","threshold":"0.50","unit":"per_min","condition":"weather=fog","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"criterion_id":"SYN-C-FA-RAIN","measure":"false_track_rate","comparator":"<=","threshold":"0.50","unit":"per_min","condition":"weather=rain","rounding":{"digits":2,"mode":"HALF_UP"},"required":true}],"results":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1,"criterion_id":"SYN-C-DET-DRY","condition":"weather=dry","value":"0.95","raw_value":null,"unit":"ratio","numerator":19,"denominator":20,"observed_at":"2026-01-01T00:00:00Z","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"]},{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1,"criterion_id":"SYN-C-FA-DRY","condition":"weather=dry","value":"0.30","raw_value":null,"unit":"per_min","numerator":3,"denominator":10,"observed_at":"2026-01-01T00:00:00Z","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"]},{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1,"criterion_id":"SYN-C-DET-RAIN","condition":"weather=rain","value":"0.80","raw_value":null,"unit":"ratio","numerator":16,"denominator":20,"observed_at":"2026-01-02T00:00:00Z","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"]},{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1,"criterion_id":"SYN-C-FA-RAIN","condition":"weather=rain","value":"0.70","raw_value":null,"unit":"per_min","numerator":7,"denominator":10,"observed_at":"2026-01-02T00:00:00Z","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#false_track"]}],"policy":{"name":"LATEST_PER_CONDITION","version":1},"verdicts":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"condition":"weather=dry","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"condition":"weather=dry","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"condition":"weather=fog","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"condition":"weather=fog","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"FAIL_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"*","before":null,"after":"0.80 ratio @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"29ebb5796de1d21cccabec91664d151003b3bea2c1c81d9d7064b14c42d7349b","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-DET-RAIN:value=0.80 need >= 0.90 ratio"],"human":[]},"parent_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260","verdict_digest":"aba4509f9bce067cb4eaf4ca0e8a68dcccb4a499627f8ef8240fed0ab49ae1d8","revision_digest":"ea5211313d09ae6003bad84e111dacf3df9f63feb8164d7299240ca927175469","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"FAIL_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"*","before":null,"after":"0.80 ratio @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"29ebb5796de1d21cccabec91664d151003b3bea2c1c81d9d7064b14c42d7349b","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-DET-RAIN:value=0.80 need >= 0.90 ratio"],"human":[]},"parent_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260","verdict_digest":"aba4509f9bce067cb4eaf4ca0e8a68dcccb4a499627f8ef8240fed0ab49ae1d8","revision_digest":"ea5211313d09ae6003bad84e111dacf3df9f63feb8164d7299240ca927175469"},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"08eff1bb3f9e3321ce7d93ab37ebd9847bfbd881ddd83776e0e530fe017a43f3","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"parent_digest":null,"verdict_digest":"aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","revision_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260"}],"history_total":2},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"condition":"weather=dry","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"condition":"weather=dry","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"condition":"weather=fog","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"condition":"weather=fog","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-RAIN","state":"FAIL_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","field":"*","before":null,"after":"0.70 per_min @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-FA-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"536f7301092050fbf960a792dc289bb2f2040ace4b829ba7bdf6a671000da90f","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"condition":"weather=rain","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-FA-RAIN:value=0.70 need <= 0.50 per_min"],"human":[]},"parent_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","verdict_digest":"ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea","revision_digest":"75f7b8d559a935782e8e50d1e904ebfbe52f412175ea0c6fda3797016ee81a71","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-RAIN","state":"FAIL_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","field":"*","before":null,"after":"0.70 per_min @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-FA-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"536f7301092050fbf960a792dc289bb2f2040ace4b829ba7bdf6a671000da90f","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"condition":"weather=rain","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-FA-RAIN:value=0.70 need <= 0.50 per_min"],"human":[]},"parent_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","verdict_digest":"ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea","revision_digest":"75f7b8d559a935782e8e50d1e904ebfbe52f412175ea0c6fda3797016ee81a71"},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-RAIN","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"a216e13f983667a3cbe5639c6ed14f2fb7ef79a950586192a073205d8f1178f2","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"condition":"weather=rain","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"parent_digest":null,"verdict_digest":"2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6","revision_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33"}],"history_total":2},{"subject_kind":"REQUIREMENT","subject_id":"SYN-REQ-RAD-001","state":"FAIL_WITH_INCOMPLETE_COVERAGE","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"*","before":null,"after":"0.80 ratio @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","field":"*","before":null,"after":"0.70 per_min @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-FA-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"3eb078dea44d1d9d5a5d33db8feb0dfa1b131f61600035069f6db9f334d87b6e","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track","31_RESULT_RAIN_SYNTHETIC.yaml#detection","31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aba4509f9bce067cb4eaf4ca0e8a68dcccb4a499627f8ef8240fed0ab49ae1d8","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"]},"selection":null,"conditions":{"required":true,"rule_id":null,"rule_revision":null,"condition":null,"unit":null,"comparator":null,"threshold":null,"rounding":null,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:FAIL_COMPUTED","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:FAIL_COMPUTED"],"human":[]},"parent_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","verdict_digest":"234d1145ba1006f0a01ea0e7ea618b150a641d1dd7a81c066e58cc0b933e695b","revision_digest":"9f07fbe8fc4199213951545610c906f83574d4fd4b1217fb7ff19a193766c794","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"REQUIREMENT","subject_id":"SYN-REQ-RAD-001","state":"FAIL_WITH_INCOMPLETE_COVERAGE","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"*","before":null,"after":"0.80 ratio @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","field":"*","before":null,"after":"0.70 per_min @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-FA-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"3eb078dea44d1d9d5a5d33db8feb0dfa1b131f61600035069f6db9f334d87b6e","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track","31_RESULT_RAIN_SYNTHETIC.yaml#detection","31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aba4509f9bce067cb4eaf4ca0e8a68dcccb4a499627f8ef8240fed0ab49ae1d8","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"]},"selection":null,"conditions":{"required":true,"rule_id":null,"rule_revision":null,"condition":null,"unit":null,"comparator":null,"threshold":null,"rounding":null,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:FAIL_COMPUTED","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:FAIL_COMPUTED"],"human":[]},"parent_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","verdict_digest":"234d1145ba1006f0a01ea0e7ea618b150a641d1dd7a81c066e58cc0b933e695b","revision_digest":"9f07fbe8fc4199213951545610c906f83574d4fd4b1217fb7ff19a193766c794"},{"subject_kind":"REQUIREMENT","subject_id":"SYN-REQ-RAD-001","state":"HOLD_INCOMPLETE","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"7eaf96565538864bcbfb3ad0d8f36b02eeedb89a8a55e26d0df5cd97a7dcaaf7","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6"]},"selection":null,"conditions":{"required":true,"rule_id":null,"rule_revision":null,"condition":null,"unit":null,"comparator":null,"threshold":null,"rounding":null,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_NO_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:HOLD_NO_RESULT"],"human":[]},"parent_digest":null,"verdict_digest":"7a63a4565ab76211c1fc6d3603a35a1c4e94c2bf5261a617eabf37f162005e85","revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005"}],"history_total":2}],"confirmations":[{"verdict_revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","actor_id":"human:local-user","confirmed_at":"2026-10-02T16:11:18.512000Z","rationale":"1단계 결과 확인"}],"pending_changes":[]},"staleBasePreview":{"preview_id":"f2136b8f9990d5830ba20441a728bd7a9514b235ccd557c65ca530d78c97588e","input_sha256":"7ccce9542473ae78adf427634279bed68906934ba412419c6b55d16b98a5bb4c","mode":"UPDATE","current_set_digest":"05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a","applicable":false,"changes":{"added":[],"updated":[],"deleted":[],"unchanged":0},"conflicts":[{"code":"STALE_BASE","row":null,"ref":null,"detail":"the file was made from set c21049d475ad, the trace is now 05d78ab9d6ef"}],"losses":["evidence_text: only the positions of evidence (source_span_refs) are kept, not the text","verdict_history: computed verdicts and their revisions are recomputed after an import","confirmations: who confirmed which verdict stays in THOTH and is not in the file","selection_policy: the policy that picks among results stays as set in THOTH","pending_changes: which verdicts are out of date is worked out again by THOTH"],"spreadsheet_suspects":[]},"exported":{"format":"thoth-trace-csv/1","filename":"thoth-trace.csv","csv_text":"format,row_type,base_set_digest,item_id,link_id,rule_id,result_id,target_kind,target_id,item_key,kind,title,fields_json,source_span_refs,from_id,to_id,relation,criterion_id,rule_revision,measure,comparator,threshold,unit,condition,rounding_digits,rounding_mode,required,result_revision,value,raw_value,numerator,denominator,observed_at\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-REQ-RAD-001,,,,,,k-req,REQUIREMENT,\"Synthetic radar detects a target and avoids false tracks in dry, rain and fog (invented example)\",,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-C-DET-DRY,,,,,,k-c0,CRITERION,Detection rate in dry weather,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-C-DET-FOG,,,,,,k-c4,CRITERION,Detection rate in fog,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-C-DET-RAIN,,,,,,k-c2,CRITERION,Detection rate in rain,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-C-FA-DRY,,,,,,k-c1,CRITERION,False tracks per minute in dry weather,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-C-FA-FOG,,,,,,k-c5,CRITERION,False tracks per minute in fog,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-C-FA-RAIN,,,,,,k-c3,CRITERION,False tracks per minute in rain,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-TC-DRY,,,,,,k-t0,TEST_CASE,Dry weather run,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-TC-FOG,,,,,,k-t2,TEST_CASE,Fog run,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-TC-RAIN,,,,,,k-t1,TEST_CASE,Rain run,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-RES-SYN-C-DET-DRY,,,,,,k-SYN-RES-SYN-C-DET-DRY,RESULT,,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-RES-SYN-C-DET-RAIN,,,,,,k-SYN-RES-SYN-C-DET-RAIN,RESULT,,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-RES-SYN-C-FA-DRY,,,,,,k-SYN-RES-SYN-C-FA-DRY,RESULT,,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,ITEM,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,SYN-RES-SYN-C-FA-RAIN,,,,,,k-SYN-RES-SYN-C-FA-RAIN,RESULT,,,,,,,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-prod-SYN-RES-SYN-C-DET-DRY,,,,,,,,,,SYN-TC-DRY,SYN-RES-SYN-C-DET-DRY,PRODUCES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-prod-SYN-RES-SYN-C-DET-RAIN,,,,,,,,,,SYN-TC-RAIN,SYN-RES-SYN-C-DET-RAIN,PRODUCES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-prod-SYN-RES-SYN-C-FA-DRY,,,,,,,,,,SYN-TC-DRY,SYN-RES-SYN-C-FA-DRY,PRODUCES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-prod-SYN-RES-SYN-C-FA-RAIN,,,,,,,,,,SYN-TC-RAIN,SYN-RES-SYN-C-FA-RAIN,PRODUCES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ref-SYN-C-DET-DRY,,,,,,,,,,SYN-C-DET-DRY,SYN-REQ-RAD-001,REFINES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ref-SYN-C-DET-FOG,,,,,,,,,,SYN-C-DET-FOG,SYN-REQ-RAD-001,REFINES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ref-SYN-C-DET-RAIN,,,,,,,,,,SYN-C-DET-RAIN,SYN-REQ-RAD-001,REFINES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ref-SYN-C-FA-DRY,,,,,,,,,,SYN-C-FA-DRY,SYN-REQ-RAD-001,REFINES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ref-SYN-C-FA-FOG,,,,,,,,,,SYN-C-FA-FOG,SYN-REQ-RAD-001,REFINES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ref-SYN-C-FA-RAIN,,,,,,,,,,SYN-C-FA-RAIN,SYN-REQ-RAD-001,REFINES,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ver-SYN-C-DET-DRY,,,,,,,,,,SYN-C-DET-DRY,SYN-TC-DRY,VERIFIED_BY,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ver-SYN-C-DET-FOG,,,,,,,,,,SYN-C-DET-FOG,SYN-TC-FOG,VERIFIED_BY,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ver-SYN-C-DET-RAIN,,,,,,,,,,SYN-C-DET-RAIN,SYN-TC-RAIN,VERIFIED_BY,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ver-SYN-C-FA-DRY,,,,,,,,,,SYN-C-FA-DRY,SYN-TC-DRY,VERIFIED_BY,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ver-SYN-C-FA-FOG,,,,,,,,,,SYN-C-FA-FOG,SYN-TC-FOG,VERIFIED_BY,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,LINK,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,L-ver-SYN-C-FA-RAIN,,,,,,,,,,SYN-C-FA-RAIN,SYN-TC-RAIN,VERIFIED_BY,,,,,,,,,,,,,,,,\r\nthoth-trace-csv/1,RULE,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,R-SYN-C-DET-DRY,,,,,,,,,,,,SYN-C-DET-DRY,1,detection_rate,>=,0.90,ratio,weather=dry,2,HALF_UP,true,,,,,,\r\nthoth-trace-csv/1,RULE,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,R-SYN-C-DET-FOG,,,,,,,,,,,,SYN-C-DET-FOG,1,detection_rate,>=,0.90,ratio,weather=fog,2,HALF_UP,true,,,,,,\r\nthoth-trace-csv/1,RULE,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,R-SYN-C-DET-RAIN,,,,,,,,,,,,SYN-C-DET-RAIN,1,detection_rate,>=,0.90,ratio,weather=rain,2,HALF_UP,true,,,,,,\r\nthoth-trace-csv/1,RULE,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,R-SYN-C-FA-DRY,,,,,,,,,,,,SYN-C-FA-DRY,1,false_track_rate,<=,0.50,per_min,weather=dry,2,HALF_UP,true,,,,,,\r\nthoth-trace-csv/1,RULE,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,R-SYN-C-FA-FOG,,,,,,,,,,,,SYN-C-FA-FOG,1,false_track_rate,<=,0.50,per_min,weather=fog,2,HALF_UP,true,,,,,,\r\nthoth-trace-csv/1,RULE,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,R-SYN-C-FA-RAIN,,,,,,,,,,,,SYN-C-FA-RAIN,1,false_track_rate,<=,0.50,per_min,weather=rain,2,HALF_UP,true,,,,,,\r\nthoth-trace-csv/1,RESULT,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,,SYN-RES-SYN-C-DET-DRY,,,,,,,\"[\"\"30_RESULT_DRY_SYNTHETIC.yaml#detection\"\"]\",,,,SYN-C-DET-DRY,,,,,ratio,weather=dry,,,,1,0.95,,19,20,2026-01-01T00:00:00.000Z\r\nthoth-trace-csv/1,RESULT,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,,SYN-RES-SYN-C-DET-RAIN,,,,,,,\"[\"\"31_RESULT_RAIN_SYNTHETIC.yaml#detection\"\"]\",,,,SYN-C-DET-RAIN,,,,,ratio,weather=rain,,,,1,0.80,,16,20,2026-01-02T00:00:00.000Z\r\nthoth-trace-csv/1,RESULT,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,,SYN-RES-SYN-C-FA-DRY,,,,,,,\"[\"\"30_RESULT_DRY_SYNTHETIC.yaml#false_track\"\"]\",,,,SYN-C-FA-DRY,,,,,per_min,weather=dry,,,,1,0.30,,3,10,2026-01-01T00:00:00.000Z\r\nthoth-trace-csv/1,RESULT,05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a,,,,SYN-RES-SYN-C-FA-RAIN,,,,,,,\"[\"\"31_RESULT_RAIN_SYNTHETIC.yaml#false_track\"\"]\",,,,SYN-C-FA-RAIN,,,,,per_min,weather=rain,,,,1,0.70,,7,10,2026-01-02T00:00:00.000Z\r\n","sha256":"9a1455acd35b2cab1644c04eda69ea01902bb3a3ccf4be207b6dbf6f181842ae","set_digest":"05d78ab9d6ef5a60c180205370d46fa5005391e39b633d49ecfbec4fb5b2d20a","loss_manifest":["evidence_text: only the positions of evidence (source_span_refs) are kept, not the text","verdict_history: computed verdicts and their revisions are recomputed after an import","confirmations: who confirmed which verdict stays in THOTH and is not in the file","selection_policy: the policy that picks among results stays as set in THOTH","pending_changes: which verdicts are out of date is worked out again by THOTH"]},"many":{"record_digest":"a1d5d714866ec602a91e51ae16ca363379c7938aa5e731221cfc6f51545dc184","set_digest":"c005b0743c780608bada70898aa5a3f9549b35f8593fb39d3e2331d3800ab18f","items":[{"item_id":"SYN-REQ-RAD-001","item_key":"k-req","kind":"REQUIREMENT","title":"Synthetic radar detects a target and avoids false tracks in dry, rain and fog (invented example)","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-DET-DRY","item_key":"k-c0","kind":"CRITERION","title":"Detection rate in dry weather","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-DET-FOG","item_key":"k-c4","kind":"CRITERION","title":"Detection rate in fog","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-DET-RAIN","item_key":"k-c2","kind":"CRITERION","title":"Detection rate in rain","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-FA-DRY","item_key":"k-c1","kind":"CRITERION","title":"False tracks per minute in dry weather","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-FA-FOG","item_key":"k-c5","kind":"CRITERION","title":"False tracks per minute in fog","fields":{},"source_span_refs":[]},{"item_id":"SYN-C-FA-RAIN","item_key":"k-c3","kind":"CRITERION","title":"False tracks per minute in rain","fields":{},"source_span_refs":[]},{"item_id":"SYN-TC-DRY","item_key":"k-t0","kind":"TEST_CASE","title":"Dry weather run","fields":{},"source_span_refs":[]},{"item_id":"SYN-TC-FOG","item_key":"k-t2","kind":"TEST_CASE","title":"Fog run","fields":{},"source_span_refs":[]},{"item_id":"SYN-TC-RAIN","item_key":"k-t1","kind":"TEST_CASE","title":"Rain run","fields":{},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-DET-DRY","item_key":"k-SYN-RES-SYN-C-DET-DRY","kind":"RESULT","title":"","fields":{},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-FA-DRY","item_key":"k-SYN-RES-SYN-C-FA-DRY","kind":"RESULT","title":"","fields":{},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-DET-RAIN","item_key":"k-SYN-RES-SYN-C-DET-RAIN","kind":"RESULT","title":"","fields":{},"source_span_refs":[]},{"item_id":"SYN-RES-SYN-C-FA-RAIN","item_key":"k-SYN-RES-SYN-C-FA-RAIN","kind":"RESULT","title":"","fields":{},"source_span_refs":[]}],"links":[{"link_id":"L-prod-SYN-RES-SYN-C-DET-DRY","from_id":"SYN-TC-DRY","to_id":"SYN-RES-SYN-C-DET-DRY","relation":"PRODUCES"},{"link_id":"L-prod-SYN-RES-SYN-C-FA-DRY","from_id":"SYN-TC-DRY","to_id":"SYN-RES-SYN-C-FA-DRY","relation":"PRODUCES"},{"link_id":"L-ref-SYN-C-DET-DRY","from_id":"SYN-C-DET-DRY","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-DET-FOG","from_id":"SYN-C-DET-FOG","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-DET-RAIN","from_id":"SYN-C-DET-RAIN","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-FA-DRY","from_id":"SYN-C-FA-DRY","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-FA-FOG","from_id":"SYN-C-FA-FOG","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ref-SYN-C-FA-RAIN","from_id":"SYN-C-FA-RAIN","to_id":"SYN-REQ-RAD-001","relation":"REFINES"},{"link_id":"L-ver-SYN-C-DET-DRY","from_id":"SYN-C-DET-DRY","to_id":"SYN-TC-DRY","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-DET-FOG","from_id":"SYN-C-DET-FOG","to_id":"SYN-TC-FOG","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-DET-RAIN","from_id":"SYN-C-DET-RAIN","to_id":"SYN-TC-RAIN","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-FA-DRY","from_id":"SYN-C-FA-DRY","to_id":"SYN-TC-DRY","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-FA-FOG","from_id":"SYN-C-FA-FOG","to_id":"SYN-TC-FOG","relation":"VERIFIED_BY"},{"link_id":"L-ver-SYN-C-FA-RAIN","from_id":"SYN-C-FA-RAIN","to_id":"SYN-TC-RAIN","relation":"VERIFIED_BY"},{"link_id":"L-prod-SYN-RES-SYN-C-DET-RAIN","from_id":"SYN-TC-RAIN","to_id":"SYN-RES-SYN-C-DET-RAIN","relation":"PRODUCES"},{"link_id":"L-prod-SYN-RES-SYN-C-FA-RAIN","from_id":"SYN-TC-RAIN","to_id":"SYN-RES-SYN-C-FA-RAIN","relation":"PRODUCES"}],"rules":[{"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"criterion_id":"SYN-C-DET-DRY","measure":"detection_rate","comparator":">=","threshold":"0.90","unit":"ratio","condition":"weather=dry","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"criterion_id":"SYN-C-DET-FOG","measure":"detection_rate","comparator":">=","threshold":"0.90","unit":"ratio","condition":"weather=fog","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"criterion_id":"SYN-C-DET-RAIN","measure":"detection_rate","comparator":">=","threshold":"0.90","unit":"ratio","condition":"weather=rain","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"criterion_id":"SYN-C-FA-DRY","measure":"false_track_rate","comparator":"<=","threshold":"0.50","unit":"per_min","condition":"weather=dry","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"criterion_id":"SYN-C-FA-FOG","measure":"false_track_rate","comparator":"<=","threshold":"0.50","unit":"per_min","condition":"weather=fog","rounding":{"digits":2,"mode":"HALF_UP"},"required":true},{"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"criterion_id":"SYN-C-FA-RAIN","measure":"false_track_rate","comparator":"<=","threshold":"0.50","unit":"per_min","condition":"weather=rain","rounding":{"digits":2,"mode":"HALF_UP"},"required":true}],"results":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1,"criterion_id":"SYN-C-DET-DRY","condition":"weather=dry","value":"0.95","raw_value":null,"unit":"ratio","numerator":19,"denominator":20,"observed_at":"2026-01-01T00:00:00Z","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"]},{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1,"criterion_id":"SYN-C-FA-DRY","condition":"weather=dry","value":"0.30","raw_value":null,"unit":"per_min","numerator":3,"denominator":10,"observed_at":"2026-01-01T00:00:00Z","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"]},{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":5,"criterion_id":"SYN-C-DET-RAIN","condition":"weather=rain","value":"0.73","raw_value":null,"unit":"ratio","numerator":16,"denominator":20,"observed_at":"2026-01-02T00:00:00Z","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"]},{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1,"criterion_id":"SYN-C-FA-RAIN","condition":"weather=rain","value":"0.70","raw_value":null,"unit":"per_min","numerator":7,"denominator":10,"observed_at":"2026-01-02T00:00:00Z","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#false_track"]}],"policy":{"name":"LATEST_PER_CONDITION","version":1},"verdicts":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"condition":"weather=dry","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"5aad7c362e41897c81581f8ec40aab1d6edf261df61d8591074d871def26f82c","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-DRY","rule_revision":1,"condition":"weather=dry","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","revision_digest":"08165743b9cf1c41328e5ef21b147fafc8e35cb18ffbb88e144caf19003f5597"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"condition":"weather=fog","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-FOG","rule_revision":1,"condition":"weather=fog","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","revision_digest":"89238e56a614389e883ab6d166156d54b53d62bc3efecc8f2b961ba54458ec39"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"HOLD_INVALID_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"result_revision","before":"4","after":"5","before_revision":4,"after_revision":5,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"value","before":"0.72","after":"0.73","before_revision":4,"after_revision":5,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"a27270f0eedce47ba0170b4f0d3965e42912c1b84a149c36af6da210933de8e5","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":5}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":5}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:19.113000Z"},"reasons":{"computed":["VALUE_DISAGREES_WITH_COUNTS:SYN-RES-SYN-C-DET-RAIN"],"human":[]},"parent_digest":"acc0dd92a42886ba4115fcf2a9a97e0d861cac6eb96df8a43266d7e8e71c85e2","verdict_digest":"2d75fb1dee075b40faa79e572689bc5ec83a69b822eeefd83ecc404b66293af6","revision_digest":"8cddb51c2c669746d8902a7494400a9173f2b634492922cad95141fa03a9955e","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"HOLD_INVALID_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"result_revision","before":"4","after":"5","before_revision":4,"after_revision":5,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"value","before":"0.72","after":"0.73","before_revision":4,"after_revision":5,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"a27270f0eedce47ba0170b4f0d3965e42912c1b84a149c36af6da210933de8e5","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":5}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":5}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:19.113000Z"},"reasons":{"computed":["VALUE_DISAGREES_WITH_COUNTS:SYN-RES-SYN-C-DET-RAIN"],"human":[]},"parent_digest":"acc0dd92a42886ba4115fcf2a9a97e0d861cac6eb96df8a43266d7e8e71c85e2","verdict_digest":"2d75fb1dee075b40faa79e572689bc5ec83a69b822eeefd83ecc404b66293af6","revision_digest":"8cddb51c2c669746d8902a7494400a9173f2b634492922cad95141fa03a9955e"},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"HOLD_INVALID_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"result_revision","before":"3","after":"4","before_revision":3,"after_revision":4,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"value","before":"0.71","after":"0.72","before_revision":3,"after_revision":4,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"72da4288279788f6013fa28e90bcc31d25dea3bce0a6384f3ee13cb56f4bffc7","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":4}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":4}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.992000Z"},"reasons":{"computed":["VALUE_DISAGREES_WITH_COUNTS:SYN-RES-SYN-C-DET-RAIN"],"human":[]},"parent_digest":"eb71cc0500d2815af9a73ccd1cfb4bb86966de608c0e453aec14e8d8e0f63c10","verdict_digest":"314fda9f8924c05350f628174cbc3ebb447fed03d6bd84adf601ce8a1ea14751","revision_digest":"acc0dd92a42886ba4115fcf2a9a97e0d861cac6eb96df8a43266d7e8e71c85e2"},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"HOLD_INVALID_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"result_revision","before":"2","after":"3","before_revision":2,"after_revision":3,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"value","before":"0.70","after":"0.71","before_revision":2,"after_revision":3,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"38c678e678d9a85033bd45a3c8738732828e75f8494462e314f2ca16a5d8f0dd","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":3}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":3}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.868000Z"},"reasons":{"computed":["VALUE_DISAGREES_WITH_COUNTS:SYN-RES-SYN-C-DET-RAIN"],"human":[]},"parent_digest":"2c320f9d6cc4c1667c075d6c9f4570d6b13cc0391f0be8dc8e2c5d439cfae0ea","verdict_digest":"d820faf9dd0b4dba29a528980bdc682356c9c5139374c93717f78fbc54137e4b","revision_digest":"eb71cc0500d2815af9a73ccd1cfb4bb86966de608c0e453aec14e8d8e0f63c10"}],"history_total":6},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"condition":"weather=dry","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-DRY","state":"PASS_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"bed99f4b98d03e3ac3bb294452f00f3ede73c037f932f4a0533a11450ff764c2","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-DRY","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-DRY","rule_revision":1,"condition":"weather=dry","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":[],"human":[]},"parent_digest":null,"verdict_digest":"6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","revision_digest":"5d5c9d3cecace3425a7bf052f50ae92880dbbd75458bcd2fd736fc151ac9fb49"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"condition":"weather=fog","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-FOG","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"89542c8233f9cd9c31005ef3480e54c7a83dec3807a49b42b1b04b8fd723c1cc","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-FOG","rule_revision":1,"condition":"weather=fog","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=fog"],"human":[]},"parent_digest":null,"verdict_digest":"ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","revision_digest":"b005ac28add992e6eb3c8f3b8a8bd0c570de970366f4e2242f64a05af29c7800"}],"history_total":1},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-RAIN","state":"FAIL_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","field":"*","before":null,"after":"0.70 per_min @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-FA-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"536f7301092050fbf960a792dc289bb2f2040ace4b829ba7bdf6a671000da90f","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"condition":"weather=rain","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-FA-RAIN:value=0.70 need <= 0.50 per_min"],"human":[]},"parent_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","verdict_digest":"ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea","revision_digest":"75f7b8d559a935782e8e50d1e904ebfbe52f412175ea0c6fda3797016ee81a71","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-RAIN","state":"FAIL_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-FA-RAIN","field":"*","before":null,"after":"0.70 per_min @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-FA-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"536f7301092050fbf960a792dc289bb2f2040ace4b829ba7bdf6a671000da90f","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-FA-RAIN","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"condition":"weather=rain","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-FA-RAIN:value=0.70 need <= 0.50 per_min"],"human":[]},"parent_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33","verdict_digest":"ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea","revision_digest":"75f7b8d559a935782e8e50d1e904ebfbe52f412175ea0c6fda3797016ee81a71"},{"subject_kind":"CRITERION","subject_id":"SYN-C-FA-RAIN","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"a216e13f983667a3cbe5639c6ed14f2fb7ef79a950586192a073205d8f1178f2","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-FA-RAIN","rule_revision":1,"condition":"weather=rain","unit":"per_min","comparator":"<=","threshold":"0.50","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"parent_digest":null,"verdict_digest":"2b43547d142e4fda2d166e3741ca7b0198fb2cd1c8f18dbccb3293467a4a85f6","revision_digest":"604e8355744a5666000bee49886b372f3de61d4cf6174b5205dc46fdaf2e9d33"}],"history_total":2},{"subject_kind":"REQUIREMENT","subject_id":"SYN-REQ-RAD-001","state":"FAIL_WITH_INCOMPLETE_COVERAGE","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"result_revision","before":"4","after":"5","before_revision":4,"after_revision":5,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"value","before":"0.72","after":"0.73","before_revision":4,"after_revision":5,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"4950dc93efd45400c2254f22021e9fd49030237ec96719673f8c8ebc03b12397","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track","31_RESULT_RAIN_SYNTHETIC.yaml#detection","31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","2d75fb1dee075b40faa79e572689bc5ec83a69b822eeefd83ecc404b66293af6","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"]},"selection":null,"conditions":{"required":true,"rule_id":null,"rule_revision":null,"condition":null,"unit":null,"comparator":null,"threshold":null,"rounding":null,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:19.113000Z"},"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_INVALID_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:FAIL_COMPUTED"],"human":[]},"parent_digest":"aa8ea706e85d587fb49afa53f5c2b2cb29689ed310df78d1b4c95dd29757487e","verdict_digest":"6eec4d8d1d72d9019c75432e76c0ead69b3e162584c41888cf6fe660f97b2125","revision_digest":"ac1444fefde19353116ad0da8e121d9da35ec9844d38369a77fa53725c01a2e0","currentness":{"state":"CURRENT","changed_dependencies":[]},"confirmations":[],"recent_history":[{"subject_kind":"REQUIREMENT","subject_id":"SYN-REQ-RAD-001","state":"FAIL_WITH_INCOMPLETE_COVERAGE","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"result_revision","before":"4","after":"5","before_revision":4,"after_revision":5,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"value","before":"0.72","after":"0.73","before_revision":4,"after_revision":5,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"4950dc93efd45400c2254f22021e9fd49030237ec96719673f8c8ebc03b12397","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track","31_RESULT_RAIN_SYNTHETIC.yaml#detection","31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","2d75fb1dee075b40faa79e572689bc5ec83a69b822eeefd83ecc404b66293af6","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"]},"selection":null,"conditions":{"required":true,"rule_id":null,"rule_revision":null,"condition":null,"unit":null,"comparator":null,"threshold":null,"rounding":null,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:19.113000Z"},"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_INVALID_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:FAIL_COMPUTED"],"human":[]},"parent_digest":"aa8ea706e85d587fb49afa53f5c2b2cb29689ed310df78d1b4c95dd29757487e","verdict_digest":"6eec4d8d1d72d9019c75432e76c0ead69b3e162584c41888cf6fe660f97b2125","revision_digest":"ac1444fefde19353116ad0da8e121d9da35ec9844d38369a77fa53725c01a2e0"},{"subject_kind":"REQUIREMENT","subject_id":"SYN-REQ-RAD-001","state":"FAIL_WITH_INCOMPLETE_COVERAGE","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"result_revision","before":"3","after":"4","before_revision":3,"after_revision":4,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"value","before":"0.71","after":"0.72","before_revision":3,"after_revision":4,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"e9e57239b9fffb2547cf726c08d4e05c7277f6748c7ddc0c149105b20acf1a38","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track","31_RESULT_RAIN_SYNTHETIC.yaml#detection","31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","314fda9f8924c05350f628174cbc3ebb447fed03d6bd84adf601ce8a1ea14751","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"]},"selection":null,"conditions":{"required":true,"rule_id":null,"rule_revision":null,"condition":null,"unit":null,"comparator":null,"threshold":null,"rounding":null,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.992000Z"},"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_INVALID_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:FAIL_COMPUTED"],"human":[]},"parent_digest":"a2089935665220df2fc32ab1464bd205f972eba3b5599fd94c39efe29aabf9e3","verdict_digest":"eeb3e9c47740e6b900a43a90b4c8ca93cec09f6240410b7969fe086e7cd2db60","revision_digest":"aa8ea706e85d587fb49afa53f5c2b2cb29689ed310df78d1b4c95dd29757487e"},{"subject_kind":"REQUIREMENT","subject_id":"SYN-REQ-RAD-001","state":"FAIL_WITH_INCOMPLETE_COVERAGE","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"result_revision","before":"2","after":"3","before_revision":2,"after_revision":3,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"value","before":"0.70","after":"0.71","before_revision":2,"after_revision":3,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"81617c390af53efe7fd546c67557bf5fdd5134dae683a472a1eedfc5c984c287","source_span_refs":["30_RESULT_DRY_SYNTHETIC.yaml#detection","30_RESULT_DRY_SYNTHETIC.yaml#false_track","31_RESULT_RAIN_SYNTHETIC.yaml#detection","31_RESULT_RAIN_SYNTHETIC.yaml#false_track"],"evidence_text_preserved":false,"child_verdict_digests":["606b68ead8f9e1118fdd270028f4f8dbdc69a5689bf51dbc189eb4d10e3cda04","c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40","d820faf9dd0b4dba29a528980bdc682356c9c5139374c93717f78fbc54137e4b","6f9b53de38f82d5a9fddc062579ff1d1cbf778bf8173ab9ac115c52f9fcc1874","ad9829248189c502d08e922b06a7a66bbdb9fe45410d1499ad23f096f76c6433","ad3ea426009196af6e8f14dbbc3e7e01fe0fc7684a24aa85fc02ad471f1186ea"]},"selection":null,"conditions":{"required":true,"rule_id":null,"rule_revision":null,"condition":null,"unit":null,"comparator":null,"threshold":null,"rounding":null,"required_criteria":["SYN-C-DET-DRY","SYN-C-DET-FOG","SYN-C-DET-RAIN","SYN-C-FA-DRY","SYN-C-FA-FOG","SYN-C-FA-RAIN"]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.868000Z"},"reasons":{"computed":["SYN-C-DET-FOG:HOLD_NO_RESULT","SYN-C-DET-RAIN:HOLD_INVALID_RESULT","SYN-C-FA-FOG:HOLD_NO_RESULT","SYN-C-FA-RAIN:FAIL_COMPUTED"],"human":[]},"parent_digest":"044b1047512b9226b187650ee6cb24198db18a17ede0cc2451a422d87c1d24da","verdict_digest":"28db0d9093e9540f803e420153d1c4444f1f76dcf77543b28c68df3f2c282126","revision_digest":"a2089935665220df2fc32ab1464bd205f972eba3b5599fd94c39efe29aabf9e3"}],"history_total":6}],"confirmations":[{"verdict_revision_digest":"d6c35c5d388a728ee575007275b25902cde0c595a475c37bf7e1142abcbba005","actor_id":"human:local-user","confirmed_at":"2026-10-02T16:11:18.512000Z","rationale":"1단계 결과 확인"}],"pending_changes":[]},"manyOlder":{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","history_total":6,"revisions":[{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"HOLD_INVALID_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"result_revision","before":"1","after":"2","before_revision":1,"after_revision":2,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]},{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"value","before":"0.80","after":"0.70","before_revision":1,"after_revision":2,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"af67b2bd1b27f3a75190e34b065ef9f7a633f35194219beb5ed3e076f416431b","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":2}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":2}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.746000Z"},"reasons":{"computed":["VALUE_DISAGREES_WITH_COUNTS:SYN-RES-SYN-C-DET-RAIN"],"human":[]},"parent_digest":"ea5211313d09ae6003bad84e111dacf3df9f63feb8164d7299240ca927175469","verdict_digest":"0ac17bb5810ee4e1f05018ef25661cd6659725a899b7ff00480ac7cc2769b5a1","revision_digest":"2c320f9d6cc4c1667c075d6c9f4570d6b13cc0391f0be8dc8e2c5d439cfae0ea"},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"FAIL_COMPUTED","cause":{"trigger":"CSV_IMPORT","changed":[{"kind":"RESULT","ref_id":"SYN-RES-SYN-C-DET-RAIN","field":"*","before":null,"after":"0.80 ratio @weather=rain","before_revision":null,"after_revision":1,"criterion_ids":["SYN-C-DET-RAIN"],"requirement_ids":["SYN-REQ-RAD-001"]}]},"basis":{"input_digest":"29ebb5796de1d21cccabec91664d151003b3bea2c1c81d9d7064b14c42d7349b","source_span_refs":["31_RESULT_RAIN_SYNTHETIC.yaml#detection"],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"chosen":[{"result_id":"SYN-RES-SYN-C-DET-RAIN","result_revision":1}],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.609000Z"},"reasons":{"computed":["THRESHOLD_NOT_MET:SYN-RES-SYN-C-DET-RAIN:value=0.80 need >= 0.90 ratio"],"human":[]},"parent_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260","verdict_digest":"aba4509f9bce067cb4eaf4ca0e8a68dcccb4a499627f8ef8240fed0ab49ae1d8","revision_digest":"ea5211313d09ae6003bad84e111dacf3df9f63feb8164d7299240ca927175469"},{"subject_kind":"CRITERION","subject_id":"SYN-C-DET-RAIN","state":"HOLD_NO_RESULT","cause":{"trigger":"CSV_IMPORT","changed":[]},"basis":{"input_digest":"08eff1bb3f9e3321ce7d93ab37ebd9847bfbd881ddd83776e0e530fe017a43f3","source_span_refs":[],"evidence_text_preserved":false,"child_verdict_digests":[]},"selection":{"policy":{"name":"LATEST_PER_CONDITION","version":1},"candidates":[],"chosen":[],"excluded":[]},"conditions":{"required":true,"rule_id":"R-SYN-C-DET-RAIN","rule_revision":1,"condition":"weather=rain","unit":"ratio","comparator":">=","threshold":"0.90","rounding":{"digits":2,"mode":"HALF_UP"},"required_criteria":[]},"actor":{"actor_id":"system:verification-trace","computed_at":"2026-10-02T16:11:18.389000Z"},"reasons":{"computed":["NO_RESULT:weather=rain"],"human":[]},"parent_digest":null,"verdict_digest":"aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0","revision_digest":"be76b7fffc60963343856a316151e675c9d1e8f71532786e8398dbed10b17260"}],"next_before_digest":null},"empty":{"record_digest":null,"set_digest":"642f3b54cb097968bf34372fb706b8258bfaa069fa3d9b857de5971276653cd5","items":[],"links":[],"rules":[],"results":[],"policy":null,"verdicts":[],"confirmations":[],"pending_changes":[]},"createPreview":{"preview_id":"78d35dd67638954f40502c6392cd66aff83b57f2ceaa407f3a9ed0138e6a83ad","input_sha256":"7ccce9542473ae78adf427634279bed68906934ba412419c6b55d16b98a5bb4c","mode":"CREATE","current_set_digest":null,"applicable":true,"changes":{"added":[{"kind":"ITEM","id":"SYN-REQ-RAD-001","row":2,"fields":[]},{"kind":"ITEM","id":"SYN-C-DET-DRY","row":3,"fields":[]},{"kind":"ITEM","id":"SYN-C-DET-FOG","row":4,"fields":[]},{"kind":"ITEM","id":"SYN-C-DET-RAIN","row":5,"fields":[]},{"kind":"ITEM","id":"SYN-C-FA-DRY","row":6,"fields":[]},{"kind":"ITEM","id":"SYN-C-FA-FOG","row":7,"fields":[]},{"kind":"ITEM","id":"SYN-C-FA-RAIN","row":8,"fields":[]},{"kind":"ITEM","id":"SYN-TC-DRY","row":9,"fields":[]},{"kind":"ITEM","id":"SYN-TC-FOG","row":10,"fields":[]},{"kind":"ITEM","id":"SYN-TC-RAIN","row":11,"fields":[]},{"kind":"ITEM","id":"SYN-RES-SYN-C-DET-DRY","row":12,"fields":[]},{"kind":"ITEM","id":"SYN-RES-SYN-C-FA-DRY","row":13,"fields":[]},{"kind":"LINK","id":"L-prod-SYN-RES-SYN-C-DET-DRY","row":14,"fields":[]},{"kind":"LINK","id":"L-prod-SYN-RES-SYN-C-FA-DRY","row":15,"fields":[]},{"kind":"LINK","id":"L-ref-SYN-C-DET-DRY","row":16,"fields":[]},{"kind":"LINK","id":"L-ref-SYN-C-DET-FOG","row":17,"fields":[]},{"kind":"LINK","id":"L-ref-SYN-C-DET-RAIN","row":18,"fields":[]},{"kind":"LINK","id":"L-ref-SYN-C-FA-DRY","row":19,"fields":[]},{"kind":"LINK","id":"L-ref-SYN-C-FA-FOG","row":20,"fields":[]},{"kind":"LINK","id":"L-ref-SYN-C-FA-RAIN","row":21,"fields":[]},{"kind":"LINK","id":"L-ver-SYN-C-DET-DRY","row":22,"fields":[]},{"kind":"LINK","id":"L-ver-SYN-C-DET-FOG","row":23,"fields":[]},{"kind":"LINK","id":"L-ver-SYN-C-DET-RAIN","row":24,"fields":[]},{"kind":"LINK","id":"L-ver-SYN-C-FA-DRY","row":25,"fields":[]},{"kind":"LINK","id":"L-ver-SYN-C-FA-FOG","row":26,"fields":[]},{"kind":"LINK","id":"L-ver-SYN-C-FA-RAIN","row":27,"fields":[]},{"kind":"RULE","id":"R-SYN-C-DET-DRY","row":28,"fields":[]},{"kind":"RULE","id":"R-SYN-C-DET-FOG","row":29,"fields":[]},{"kind":"RULE","id":"R-SYN-C-DET-RAIN","row":30,"fields":[]},{"kind":"RULE","id":"R-SYN-C-FA-DRY","row":31,"fields":[]},{"kind":"RULE","id":"R-SYN-C-FA-FOG","row":32,"fields":[]},{"kind":"RULE","id":"R-SYN-C-FA-RAIN","row":33,"fields":[]},{"kind":"RESULT","id":"SYN-RES-SYN-C-DET-DRY","row":34,"fields":[]},{"kind":"RESULT","id":"SYN-RES-SYN-C-FA-DRY","row":35,"fields":[]}],"updated":[],"deleted":[],"unchanged":0},"conflicts":[],"losses":["evidence_text: only the positions of evidence (source_span_refs) are kept, not the text","verdict_history: computed verdicts and their revisions are recomputed after an import","confirmations: who confirmed which verdict stays in THOTH and is not in the file","selection_policy: the policy that picks among results stays as set in THOTH","pending_changes: which verdicts are out of date is worked out again by THOTH"],"spreadsheet_suspects":[]}}
diff --git a/apps/web/src/components/traceEvaluation.test.ts b/apps/web/src/components/traceEvaluation.test.ts
new file mode 100644
index 0000000..f4f3b89
--- /dev/null
+++ b/apps/web/src/components/traceEvaluation.test.ts
@@ -0,0 +1,85 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+import type { TraceView, Verdict } from "../api/trace";
+import { reasonTags } from "./traceReasonTags";
+import { NEXT_STEP_DECIDERS, nextStep } from "./traceText";
+
+/**
+ * The tags and the next-check wording, scored on the synthetic cases the server tests also read
+ * (tests/fixtures/hypothesis_eval/cases.json). The Python side proves each stored view is what the rule
+ * computes and that the states and reason codes match the answer key; this side scores what the screen
+ * derives from that view. No model is involved.
+ */
+
+type Check = { kind: "CRITERION" | "REQUIREMENT"; subject_id: string; state: string; tags: string[]; next_step: boolean; stale: boolean };
+type Case = { id: string; title: string; view: TraceView; checks: Check[]; banned_phrases: string[]; synthetic_notice: string };
+const file = JSON.parse(readFileSync(new URL("../../../../tests/fixtures/hypothesis_eval/cases.json", import.meta.url), "utf8")) as { cases: Case[] };
+const LEAK = /HOLD_|FAIL_|PASS_|[A-Za-z]+_[A-Za-z]+/;
+
+/** The role each tag's own next check names (provisional role names). */
+const DECIDER_OF_TAG: Partial> = {
+ UNTESTED: NEXT_STEP_DECIDERS.test, UNLINKED: NEXT_STEP_DECIDERS.rule, CONDITION_MISMATCH: NEXT_STEP_DECIDERS.test,
+ UNIT: NEXT_STEP_DECIDERS.validity, DENOMINATOR: NEXT_STEP_DECIDERS.validity, VALUE: NEXT_STEP_DECIDERS.validity,
+};
+
+const verdictOf = (item: Case, check: Check) => {
+ const found = item.view.verdicts.filter(verdict => verdict.subject_kind === check.kind && verdict.subject_id === check.subject_id);
+ expect(found, item.id + " " + check.subject_id).toHaveLength(1);
+ return found[0] as Verdict;
+};
+const wordsOf = (item: Case, check: Check) => {
+ const verdict = verdictOf(item, check);
+ const stale = verdict.currentness.state === "STALE_BASIS";
+ const tags = reasonTags(check.kind, verdict, item.view);
+ const next = nextStep(check.kind, verdict.state, stale, tags.map(tag => tag.id));
+ return { verdict, stale, tags, next, text: [...tags.flatMap(tag => [tag.label, tag.hint]), ...(next ? next.lines.flatMap(item => [item.text, item.decider ?? ""]) : [])] };
+};
+
+describe("tags and next checks on the synthetic cases", () => {
+ it("has the agreed cases, all marked synthetic", () => {
+ expect(file.cases.length).toBeGreaterThanOrEqual(10);
+ for (const item of file.cases) expect(item.synthetic_notice).toContain("SYNTHETIC DEMO DATA");
+ });
+
+ for (const item of file.cases) {
+ it(item.id + ": " + item.title, () => {
+ for (const check of item.checks) {
+ const { stale, tags, next, text } = wordsOf(item, check);
+ expect(tags.map(tag => tag.id), check.subject_id).toEqual(check.tags);
+ expect(stale, check.subject_id).toBe(check.stale);
+ expect(next !== null, check.subject_id + " next check").toBe(check.next_step);
+ if (check.stale && next) expect(next.lines[0].text).toContain("이전 안내는 이전 근거 기준입니다");
+ // a held criterion with a tag that has its own check gets that check, decided by the named role
+ const own = tags.map(tag => DECIDER_OF_TAG[tag.id]).filter((item): item is string => item !== undefined);
+ if (check.kind === "CRITERION" && own.length > 0) {
+ const named = next!.lines.filter(item => item.decider !== null).map(item => item.decider);
+ expect(named, check.subject_id).toEqual(own);
+ }
+ for (const line of text) {
+ expect(line).not.toMatch(LEAK);
+ for (const banned of item.banned_phrases) expect(line).not.toContain(banned);
+ }
+ }
+ });
+ }
+
+ it("scores every case: tags exactly right, no passed line carries a tag or a next check, nothing unexplained is held", () => {
+ let checks = 0;
+ let tagMisses = 0;
+ let passedWithTag = 0;
+ let heldWithoutWords = 0;
+ for (const item of file.cases) {
+ for (const check of item.checks) {
+ const { verdict, tags, next } = wordsOf(item, check);
+ checks += 1;
+ if (tags.map(tag => tag.id).join() !== check.tags.join()) tagMisses += 1;
+ const met = verdict.state === "PASS_COMPUTED" || verdict.state === "PASS";
+ if (met && verdict.currentness.state === "CURRENT" && (tags.length > 0 || next !== null)) passedWithTag += 1;
+ const held = verdict.state.startsWith("HOLD_");
+ if (held && tags.length === 0 && check.kind === "CRITERION") heldWithoutWords += 1;
+ }
+ }
+ expect(checks).toBeGreaterThanOrEqual(25);
+ expect({ tagMisses, passedWithTag, heldWithoutWords }).toEqual({ tagMisses: 0, passedWithTag: 0, heldWithoutWords: 0 });
+ });
+});
diff --git a/apps/web/src/components/traceInvestigation.test.ts b/apps/web/src/components/traceInvestigation.test.ts
new file mode 100644
index 0000000..167f4a9
--- /dev/null
+++ b/apps/web/src/components/traceInvestigation.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, it } from "vitest";
+import type { TraceView, Verdict } from "../api/trace";
+import demo from "./traceDemo.fixture.json";
+import { canInvestigate, fillQuestion, investigationFor, threadForRow } from "./traceInvestigation";
+
+const BANNED = /재시험 필수|원인은|기준을 낮추|통과 가능|승인됨|종결|해결|면제|AI 추천/;
+// A unit the user wrote (per_min) is theirs to see; the rule's own codes are not.
+const LEAK = /HOLD_|FAIL_|PASS_|NO_RESULT|THRESHOLD|VALUE_|UNIT_MISMATCH|\bundefined\b|\bnull\b/;
+const phase1 = demo.phase1 as unknown as TraceView;
+const phase2 = demo.phase2 as unknown as TraceView;
+const verdict = (view: TraceView, kind: string, title: string) => {
+ const id = view.items.find(item => item.title === title)!.item_id;
+ return view.verdicts.find(item => item.subject_kind === kind && item.subject_id === id) as Verdict;
+};
+
+describe("which rows can start an investigation", () => {
+ it("offers it for held and failed rows and never for a met one, even a stale one", () => {
+ expect(canInvestigate("CRITERION", verdict(phase1, "CRITERION", "Detection rate in rain"))).toBe(true);
+ expect(canInvestigate("CRITERION", verdict(phase2, "CRITERION", "Detection rate in rain"))).toBe(true); // failed
+ expect(canInvestigate("REQUIREMENT", verdict(phase1, "REQUIREMENT", phase1.items.find(i => i.kind === "REQUIREMENT")!.title))).toBe(true);
+ const met = verdict(phase1, "CRITERION", "Detection rate in dry weather");
+ expect(canInvestigate("CRITERION", met)).toBe(false);
+ expect(canInvestigate("CRITERION", { ...met, currentness: { state: "STALE_BASIS", changed_dependencies: [] } })).toBe(false);
+ expect(canInvestigate("CRITERION", { ...met, state: "SOMETHING_NEW" })).toBe(false); // an unknown state gets no button
+ });
+});
+
+describe("the origin and the question a row gives", () => {
+ it("names the row and the revision it was looking at, and nothing the server writes", () => {
+ const rain = verdict(phase2, "CRITERION", "Detection rate in rain");
+ const found = investigationFor("project:radar", "CRITERION", rain, phase2)!;
+ expect(found.origin).toEqual({ kind: "TRACE_VERDICT", project_id: "project:radar", subject_kind: "CRITERION", subject_id: rain.subject_id, verdict_revision: rain.revision_digest });
+ });
+
+ it("asks in plain words for a failed row, with the measured value and the bar", () => {
+ const found = investigationFor("project:radar", "CRITERION", verdict(phase2, "CRITERION", "Detection rate in rain"), phase2)!;
+ expect(found.question).toContain("Detection rate in rain");
+ expect(found.question).toMatch(/기준 미달/);
+ expect(found.question).toMatch(/측정값 [0-9.]+이\(가\) 기준 [0-9.]+ 이상/);
+ expect(found.question).toContain("원인 후보와 그것을 가를 시험을 찾아 주세요.");
+ });
+
+ it("asks what is missing for a held row, in words", () => {
+ const found = investigationFor("project:radar", "CRITERION", verdict(phase1, "CRITERION", "Detection rate in fog"), phase1)!;
+ expect(found.question).toContain("보류");
+ expect(found.question).toContain("날씨: 안개 조건의 결과가 아직 없습니다.");
+ expect(found.question).toContain("판정하려면 무엇이 더 필요한지");
+ });
+
+ it("covers a requirement row too", () => {
+ const title = phase1.items.find(item => item.kind === "REQUIREMENT")!.title;
+ const found = investigationFor("project:radar", "REQUIREMENT", verdict(phase1, "REQUIREMENT", title), phase1)!;
+ expect(found.origin.subject_kind).toBe("REQUIREMENT");
+ expect(found.question).toContain(title);
+ });
+
+ it("gives nothing for a met row", () => {
+ expect(investigationFor("project:radar", "CRITERION", verdict(phase1, "CRITERION", "Detection rate in dry weather"), phase1)).toBeNull();
+ });
+
+ it("never shows an internal code, a forbidden claim or a probability", () => {
+ for (const view of [phase1, phase2]) {
+ for (const item of view.verdicts) {
+ const found = investigationFor("project:radar", item.subject_kind, item, view);
+ if (!found) continue;
+ expect(found.question).not.toMatch(BANNED);
+ expect(found.question).not.toMatch(LEAK);
+ expect(found.question).not.toMatch(/\d\s*%|확률/);
+ expect(found.question.length).toBeLessThan(2000);
+ }
+ }
+ });
+});
+
+describe("finding the thread of a row", () => {
+ const thread = (id: string, row: string | null, updated: string, revision = "r") => ({ thread_id: id, project_id: "p", cycle_id: "c", problem: "q", lifecycle: "OPEN",
+ execution_state: "IDLE", current_object_ids: [], working_head_digest: "d", updated_at: updated,
+ origin: row ? { subject_kind: "CRITERION", subject_id: row, verdict_revision: revision } : null });
+ it("picks the same row's thread, the newest one, and none for another row or an ordinary thread", () => {
+ const list = [thread("a", "C-1", "2026-10-01"), thread("b", "C-1", "2026-10-03"), thread("c", "C-2", "2026-10-05"), thread("d", null, "2026-10-06")];
+ expect(threadForRow(list, "CRITERION", "C-1")?.thread_id).toBe("b");
+ expect(threadForRow(list, "CRITERION", "C-2")?.thread_id).toBe("c");
+ expect(threadForRow(list, "CRITERION", "C-3")).toBeNull();
+ expect(threadForRow(list, "REQUIREMENT", "C-1")).toBeNull(); // kind matters
+ expect(threadForRow([{ ...list[3], origin: undefined }], "CRITERION", "C-1")).toBeNull(); // an old thread has no origin
+ });
+});
+
+describe("filling the question into a draft", () => {
+ it("fills an empty draft, keeps a draft that already has it, and puts the question after other text", () => {
+ expect(fillQuestion("", "Q")).toBe("Q");
+ expect(fillQuestion(" ", "Q")).toBe("Q");
+ expect(fillQuestion("Q", "Q")).toBe("Q");
+ expect(fillQuestion("내가 쓰던 글", "Q")).toBe("내가 쓰던 글\n\nQ");
+ });
+});
diff --git a/apps/web/src/components/traceInvestigation.ts b/apps/web/src/components/traceInvestigation.ts
new file mode 100644
index 0000000..42b85d3
--- /dev/null
+++ b/apps/web/src/components/traceInvestigation.ts
@@ -0,0 +1,43 @@
+import type { TraceView, Verdict } from "../api/trace";
+import type { WorkThread } from "../types";
+import { titleOf } from "./traceRows";
+import { reasonSentence, verdictLook } from "./traceText";
+
+/** What the client names: the row and the revision of its verdict it was looking at. The server writes the rest. */
+export type TraceOrigin = {
+ kind: "TRACE_VERDICT"; project_id: string; subject_kind: "CRITERION" | "REQUIREMENT"; subject_id: string; verdict_revision: string;
+};
+export type RowInvestigation = { origin: TraceOrigin; question: string };
+
+const SETTLED = new Set(["PASS_COMPUTED", "PASS"]);
+const kindOf = (kind: string): "CRITERION" | "REQUIREMENT" => (kind === "REQUIREMENT" ? "REQUIREMENT" : "CRITERION");
+
+/** A held or failed row has an investigation to start; a met row, even a stale one, and an unknown state have none. */
+export function canInvestigate(kind: string, verdict: Verdict): boolean {
+ return !SETTLED.has(verdict.state) && verdictLook(kind, verdict.state).intent !== "none";
+}
+
+/** The row's origin and the question to put in the composer; nothing is sent until the user sends it. */
+export function investigationFor(projectId: string, kind: string, verdict: Verdict, view: TraceView): RowInvestigation | null {
+ if (!canInvestigate(kind, verdict)) return null;
+ const name = titleOf(view);
+ const subject = kindOf(kind);
+ const reasons = verdict.reasons.computed.map(code => reasonSentence(code, name, verdict.conditions.condition));
+ const failed = !verdict.state.startsWith("HOLD");
+ const what = subject === "CRITERION" ? "기준" : "요구사항";
+ const ask = failed ? "원인 후보와 그것을 가를 시험을 찾아 주세요." : "판정하려면 무엇이 더 필요한지, 가능한 원인 후보와 그것을 가를 시험을 찾아 주세요.";
+ const question = [`「${name(verdict.subject_id)}」 ${what}의 판정이 "${verdictLook(kind, verdict.state).text}"입니다.`, ...reasons, ask].join(" ");
+ return { origin: { kind: "TRACE_VERDICT", project_id: projectId, subject_kind: subject, subject_id: verdict.subject_id, verdict_revision: verdict.revision_digest }, question };
+}
+
+/** The thread that was started from this row, the newest if there are several; null when none was. */
+export function threadForRow(threads: WorkThread[], kind: string, subjectId: string): WorkThread | null {
+ const found = threads.filter(thread => thread.origin?.subject_kind === kind && thread.origin.subject_id === subjectId);
+ return found.reduce((best, item) => (!best || (item.updated_at ?? "") > (best.updated_at ?? "") ? item : best), null);
+}
+
+/** The draft with the question in it: an empty draft becomes the question, other text is kept and the question follows it. */
+export function fillQuestion(draft: string, question: string): string {
+ if (!draft.trim()) return question;
+ return draft.includes(question) ? draft : `${draft}\n\n${question}`;
+}
diff --git a/apps/web/src/components/traceReasonTags.test.ts b/apps/web/src/components/traceReasonTags.test.ts
new file mode 100644
index 0000000..a32bf56
--- /dev/null
+++ b/apps/web/src/components/traceReasonTags.test.ts
@@ -0,0 +1,148 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+import type { TraceView, Verdict } from "../api/trace";
+import { REASON_CODE_TAGS, REASON_TAG_TEXT, reasonTags } from "./traceReasonTags";
+
+const view = (links: { from_id: string; to_id: string; relation: string }[] = []) =>
+ ({ links: links.map((link, index) => ({ link_id: "L" + index, ...link })), items: [], rules: [], results: [], verdicts: [] }) as unknown as TraceView;
+const LINKED = view([{ from_id: "C1", to_id: "T1", relation: "VERIFIED_BY" }]);
+
+function verdict(kind: "CRITERION" | "REQUIREMENT", state: string, computed: string[], extra: Record = {}): Verdict {
+ return {
+ subject_kind: kind, subject_id: "C1", state, reasons: { computed, human: [] },
+ selection: kind === "CRITERION" ? { policy: { name: "LATEST_PER_CONDITION", version: 1 }, candidates: [], chosen: [], excluded: [] } : null,
+ currentness: { state: "CURRENT", changed_dependencies: [] }, ...extra,
+ } as unknown as Verdict;
+}
+const ids = (kind: "CRITERION" | "REQUIREMENT", item: Verdict, v: TraceView = LINKED) => reasonTags(kind, item, v).map(tag => tag.id);
+
+describe("reason tags of a criterion", () => {
+ it("says untested when a test is linked and no result came, and unlinked when no test is linked", () => {
+ const none = verdict("CRITERION", "HOLD_NO_RESULT", ["NO_RESULT:weather=fog"]);
+ expect(ids("CRITERION", none)).toEqual(["UNTESTED"]);
+ expect(ids("CRITERION", none, view())).toEqual(["UNLINKED"]);
+ expect(ids("CRITERION", none, view([{ from_id: "OTHER", to_id: "T1", relation: "VERIFIED_BY" }]))).toEqual(["UNLINKED"]);
+ expect(ids("CRITERION", none, view([{ from_id: "C1", to_id: "R1", relation: "REFINES" }]))).toEqual(["UNLINKED"]);
+ });
+
+ it("says condition mismatch when results exist but every one was set aside for its condition", () => {
+ const mismatch = verdict("CRITERION", "HOLD_NO_RESULT", ["NO_RESULT:weather=rain"], {
+ selection: { policy: { name: "LATEST_PER_CONDITION", version: 1 }, candidates: [{ result_id: "R1", result_revision: 1 }], chosen: [], excluded: [{ result_id: "R1", result_revision: 1, reason: "CONDITION_MISMATCH" }] },
+ });
+ expect(ids("CRITERION", mismatch)).toEqual(["CONDITION_MISMATCH"]);
+ });
+
+ it.each([
+ ["UNIT_MISMATCH:R1:percent!=ratio", "UNIT"],
+ ["DENOMINATOR_NOT_POSITIVE:R1", "DENOMINATOR"],
+ ["VALUE_DISAGREES_WITH_COUNTS:R1", "DENOMINATOR"],
+ ["VALUE_NOT_A_NUMBER:R1", "VALUE"],
+ ["VALUE_MISSING:R1", "VALUE"],
+ ["VALUE_OUT_OF_RANGE:R1", "VALUE"],
+ ])("maps %s to %s", (code, tag) => {
+ expect(ids("CRITERION", verdict("CRITERION", "HOLD_INVALID_RESULT", [code]))).toEqual([tag]);
+ });
+
+ it("says no rule", () => {
+ expect(ids("CRITERION", verdict("CRITERION", "HOLD_NO_RULE", ["NO_RULE"]))).toEqual(["NO_RULE"]);
+ });
+
+ it("calls a miss valid only when nothing else made a result unusable", () => {
+ const miss = "THRESHOLD_NOT_MET:R1:value=0.80 need >= 0.90 ratio";
+ expect(ids("CRITERION", verdict("CRITERION", "FAIL_COMPUTED", [miss]))).toEqual(["VALID_MISS"]);
+ expect(ids("CRITERION", verdict("CRITERION", "FAIL_COMPUTED", [miss, miss]))).toEqual(["VALID_MISS"]);
+ expect(ids("CRITERION", verdict("CRITERION", "FAIL_COMPUTED", [miss, "UNIT_MISMATCH:R2:percent!=ratio"]))).toEqual(["UNIT"]);
+ });
+
+ it("can carry several tags on one line, in a fixed order", () => {
+ const both = verdict("CRITERION", "HOLD_INVALID_RESULT", ["VALUE_MISSING:R1", "UNIT_MISMATCH:R2:x!=y", "DENOMINATOR_NOT_POSITIVE:R3"]);
+ expect(ids("CRITERION", both)).toEqual(["UNIT", "DENOMINATOR", "VALUE"]);
+ });
+
+ it("makes no tag for a met line or for a code it does not know", () => {
+ expect(ids("CRITERION", verdict("CRITERION", "PASS_COMPUTED", []))).toEqual([]);
+ expect(ids("CRITERION", verdict("CRITERION", "HOLD_INVALID_RESULT", ["SOMETHING_NEW:R1"]))).toEqual([]);
+ expect(ids("CRITERION", verdict("CRITERION", "FAIL_COMPUTED", ["SOMETHING_NEW"]))).toEqual([]);
+ });
+});
+
+describe("reason tags of a requirement and of a changed basis", () => {
+ it.each(["HOLD_INCOMPLETE", "FAIL_WITH_INCOMPLETE_COVERAGE", "HOLD_NO_CRITERIA"])("marks %s as required criteria not complete", state => {
+ expect(ids("REQUIREMENT", verdict("REQUIREMENT", state, ["C1:HOLD_NO_RESULT"]))).toEqual(["REQUIRED_INCOMPLETE"]);
+ });
+
+ it("marks a requirement with no required criteria", () => {
+ expect(ids("REQUIREMENT", verdict("REQUIREMENT", "HOLD_NO_CRITERIA", ["NO_REQUIRED_CRITERIA"]))).toEqual(["REQUIRED_INCOMPLETE"]);
+ });
+
+ it("gives nothing to a met or plainly failed requirement", () => {
+ expect(ids("REQUIREMENT", verdict("REQUIREMENT", "PASS", []))).toEqual([]);
+ expect(ids("REQUIREMENT", verdict("REQUIREMENT", "FAIL", ["C1:FAIL_COMPUTED"]))).toEqual([]);
+ });
+
+ it("adds the changed-basis tag to either kind, and only on a stale line", () => {
+ const stale = { currentness: { state: "STALE_BASIS", changed_dependencies: [] } };
+ expect(ids("CRITERION", verdict("CRITERION", "PASS_COMPUTED", [], stale))).toEqual(["BASIS_CHANGED"]);
+ expect(ids("REQUIREMENT", verdict("REQUIREMENT", "FAIL_WITH_INCOMPLETE_COVERAGE", ["C1:HOLD_NO_RESULT"], stale))).toEqual(["REQUIRED_INCOMPLETE", "BASIS_CHANGED"]);
+ expect(ids("CRITERION", verdict("CRITERION", "FAIL_COMPUTED", ["THRESHOLD_NOT_MET:R1:value=0.8 need >= 0.9 ratio"], stale))).toEqual(["VALID_MISS", "BASIS_CHANGED"]);
+ });
+});
+
+describe("tag wording", () => {
+ const BANNED = /재시험 필수|원인은|기준을 낮추|통과 가능|승인됨|종결|해결|면제|AI 추천/;
+ it("is short Korean with a one-line hint, no codes and no forbidden phrases", () => {
+ const entries = Object.values(REASON_TAG_TEXT);
+ expect(entries).toHaveLength(10);
+ for (const { label, hint } of entries) {
+ expect(label.length).toBeLessThanOrEqual(12);
+ expect(hint).not.toContain("\n");
+ for (const text of [label, hint]) {
+ expect(text).not.toMatch(BANNED);
+ expect(text).not.toMatch(/HOLD_|FAIL_|PASS_|[A-Za-z]+_[A-Za-z]+/);
+ expect(text).toMatch(/[가-힣]/);
+ }
+ }
+ });
+});
+
+/** The reason codes the server can give a verdict, read from where it builds them: `f"CODE:{...}"` and `("CODE",)`. A code built another way is not seen. */
+function serverReasonCodes(text: string): string[] {
+ const found = [...text.matchAll(/f"([A-Z][A-Z_]+):\{/g), ...text.matchAll(/(?:computed=|else )\(\s*"([A-Z][A-Z_]+)",\s*\)/g)].map(match => match[1]);
+ return [...new Set(found)].sort();
+}
+const traceSource = () => readFileSync(new URL("../../../../src/thoth/application/services/verification_trace.py", import.meta.url), "utf8");
+
+describe("reason codes against the server", () => {
+ it("reads the ten codes the server gives today", () => {
+ expect(serverReasonCodes(traceSource())).toEqual([
+ "DENOMINATOR_NOT_POSITIVE", "NO_REQUIRED_CRITERIA", "NO_RESULT", "NO_RULE", "THRESHOLD_NOT_MET", "UNIT_MISMATCH",
+ "VALUE_DISAGREES_WITH_COUNTS", "VALUE_MISSING", "VALUE_NOT_A_NUMBER", "VALUE_OUT_OF_RANGE",
+ ]);
+ });
+
+ it("maps every server code to tags, or marks it as left without a tag on purpose", () => {
+ for (const code of serverReasonCodes(traceSource())) {
+ const entry = REASON_CODE_TAGS[code];
+ expect(entry, `${code} is new: map it to a tag in traceReasonTags.ts or mark it "NO_TAG"`).toBeDefined();
+ if (entry !== "NO_TAG") for (const id of entry) expect(REASON_TAG_TEXT[id], code).toBeDefined();
+ }
+ expect(Object.keys(REASON_CODE_TAGS).sort()).toEqual(serverReasonCodes(traceSource())); // no entry for a code the server no longer gives
+ });
+
+ it("fails when the server gives a code the table does not know (checked on the source with one code added)", () => {
+ const changed = traceSource() + '\nreturn None, f"BRAND_NEW_REASON:{name}"\n';
+ const unknown = serverReasonCodes(changed).filter(code => REASON_CODE_TAGS[code] === undefined);
+ expect(unknown).toEqual(["BRAND_NEW_REASON"]);
+ });
+
+ it("makes each mapped code produce a tag the table declares for it", () => {
+ for (const [code, entry] of Object.entries(REASON_CODE_TAGS)) {
+ if (entry === "NO_TAG") continue;
+ const kind = code === "NO_REQUIRED_CRITERIA" ? "REQUIREMENT" : "CRITERION";
+ const state = kind === "REQUIREMENT" ? "HOLD_NO_CRITERIA" : code === "THRESHOLD_NOT_MET" ? "FAIL_COMPUTED" : code === "NO_RESULT" ? "HOLD_NO_RESULT" : code === "NO_RULE" ? "HOLD_NO_RULE" : "HOLD_INVALID_RESULT";
+ const made = ids(kind, verdict(kind, state, [code + ":R1"]));
+ expect(made.length, code).toBeGreaterThan(0);
+ for (const id of made) expect(entry, code).toContain(id);
+ }
+ });
+});
diff --git a/apps/web/src/components/traceReasonTags.ts b/apps/web/src/components/traceReasonTags.ts
new file mode 100644
index 0000000..e5485af
--- /dev/null
+++ b/apps/web/src/components/traceReasonTags.ts
@@ -0,0 +1,75 @@
+import type { TraceView, Verdict } from "../api/trace";
+
+/**
+ * Why a held or failed line is held or failed, in a few short words. The tags come only from the verdict's
+ * own computed reasons, its chosen and set-aside results, the links of the table and its currentness; a
+ * code this file does not know gets no tag. They split the existing states and add none.
+ * Not covered on purpose: access to the test, equipment configuration, and how near a miss is.
+ */
+
+export type ReasonTagId =
+ | "UNTESTED" | "UNLINKED" | "UNIT" | "DENOMINATOR" | "VALUE" | "CONDITION_MISMATCH"
+ | "NO_RULE" | "VALID_MISS" | "REQUIRED_INCOMPLETE" | "BASIS_CHANGED";
+
+export type ReasonTag = { id: ReasonTagId; label: string; hint: string };
+
+export const REASON_TAG_TEXT: Record = {
+ UNTESTED: { label: "미시험", hint: "이 기준에 연결된 시험은 있지만 아직 결과가 없습니다." },
+ UNLINKED: { label: "미연결", hint: "이 기준에 연결된 시험이 없어 결과가 들어올 곳이 없습니다." },
+ UNIT: { label: "단위 오류", hint: "결과의 단위가 기준의 단위와 달라 비교하지 않았습니다." },
+ DENOMINATOR: { label: "분모 오류", hint: "결과의 분모가 0 이하이거나, 값이 분자·분모로 계산한 값과 달라 비교하지 않았습니다." },
+ VALUE: { label: "값 오류", hint: "결과의 값이 비었거나 숫자가 아니거나 너무 커서 비교하지 않았습니다." },
+ CONDITION_MISMATCH: { label: "조건 불일치", hint: "결과는 있지만 기준의 조건과 달라 쓰지 않았습니다." },
+ NO_RULE: { label: "규칙 없음", hint: "이 기준에는 판정 규칙이 없어 비교하지 않았습니다." },
+ VALID_MISS: { label: "유효한 미달", hint: "결과를 비교할 수 있었고 기준에 못 미쳤습니다." },
+ REQUIRED_INCOMPLETE: { label: "필수 기준 미완료", hint: "필수 기준이 없거나, 필수 기준 중 아직 충족이나 미달이 정해지지 않은 것이 있습니다." },
+ BASIS_CHANGED: { label: "근거 변경", hint: "이 판정이 기댄 규칙·결과·연결이 바뀌어 다시 계산해야 합니다." },
+};
+
+const ORDER: ReasonTagId[] = [
+ "UNTESTED", "UNLINKED", "CONDITION_MISMATCH", "NO_RULE", "UNIT", "DENOMINATOR", "VALUE", "VALID_MISS", "REQUIRED_INCOMPLETE", "BASIS_CHANGED",
+];
+const INCOMPLETE_REQUIREMENT = new Set(["HOLD_INCOMPLETE", "FAIL_WITH_INCOMPLETE_COVERAGE", "HOLD_NO_CRITERIA"]);
+/**
+ * Every reason code the server gives a verdict, and the tags it can lead to ("NO_TAG" when it is left untagged on purpose).
+ * A requirement's per-criterion reasons (":") carry no code; its tag comes from its state.
+ * A test reads the server source and fails when a code is missing here.
+ */
+export const REASON_CODE_TAGS: Record = {
+ NO_RULE: ["NO_RULE"],
+ NO_RESULT: ["UNTESTED", "UNLINKED", "CONDITION_MISMATCH"],
+ THRESHOLD_NOT_MET: ["VALID_MISS"],
+ UNIT_MISMATCH: ["UNIT"],
+ DENOMINATOR_NOT_POSITIVE: ["DENOMINATOR"],
+ VALUE_DISAGREES_WITH_COUNTS: ["DENOMINATOR"],
+ VALUE_NOT_A_NUMBER: ["VALUE"],
+ VALUE_MISSING: ["VALUE"],
+ VALUE_OUT_OF_RANGE: ["VALUE"],
+ NO_REQUIRED_CRITERIA: ["REQUIRED_INCOMPLETE"],
+};
+const CODE_TAG: Record = {
+ UNIT_MISMATCH: "UNIT",
+ DENOMINATOR_NOT_POSITIVE: "DENOMINATOR", VALUE_DISAGREES_WITH_COUNTS: "DENOMINATOR",
+ VALUE_NOT_A_NUMBER: "VALUE", VALUE_MISSING: "VALUE", VALUE_OUT_OF_RANGE: "VALUE",
+};
+const codeOf = (reason: string) => reason.split(":")[0];
+
+export function reasonTags(kind: "CRITERION" | "REQUIREMENT", verdict: Verdict, view: Pick): ReasonTag[] {
+ const found = new Set();
+ const codes = verdict.reasons.computed.map(codeOf);
+ if (kind === "CRITERION") {
+ if (codes.includes("NO_RULE")) found.add("NO_RULE");
+ if (codes.includes("NO_RESULT")) {
+ const setAside = (verdict.selection?.excluded ?? []).some(item => item.reason === "CONDITION_MISMATCH");
+ const tested = view.links.some(link => link.relation === "VERIFIED_BY" && link.from_id === verdict.subject_id);
+ found.add(setAside ? "CONDITION_MISMATCH" : tested ? "UNTESTED" : "UNLINKED");
+ }
+ for (const code of codes) if (CODE_TAG[code]) found.add(CODE_TAG[code]);
+ const unusable = codes.some(code => CODE_TAG[code]);
+ if (verdict.state === "FAIL_COMPUTED" && codes.includes("THRESHOLD_NOT_MET") && !unusable) found.add("VALID_MISS");
+ } else if (INCOMPLETE_REQUIREMENT.has(verdict.state) || codes.includes("NO_REQUIRED_CRITERIA")) {
+ found.add("REQUIRED_INCOMPLETE");
+ }
+ if (verdict.currentness.state === "STALE_BASIS") found.add("BASIS_CHANGED");
+ return ORDER.filter(id => found.has(id)).map(id => ({ id, ...REASON_TAG_TEXT[id] }));
+}
diff --git a/apps/web/src/components/traceRows.ts b/apps/web/src/components/traceRows.ts
new file mode 100644
index 0000000..135f036
--- /dev/null
+++ b/apps/web/src/components/traceRows.ts
@@ -0,0 +1,46 @@
+import type { TraceItem, TraceResult, TraceRule, TraceView, Verdict } from "../api/trace";
+
+export type CriterionRow = { item: TraceItem; rule: TraceRule | undefined; verdict: Verdict | undefined; chosen: TraceResult[] };
+export type RequirementGroup = { item: TraceItem | null; verdict: Verdict | undefined; criteria: CriterionRow[] };
+
+const byId = (a: TraceItem, b: TraceItem) => a.item_id.localeCompare(b.item_id);
+
+/** One group per requirement (its criteria beneath it); criteria linked to no requirement come last. */
+export function buildGroups(view: TraceView): RequirementGroup[] {
+ const verdict = (kind: string, id: string) => view.verdicts.find(item => item.subject_kind === kind && item.subject_id === id);
+ const criteria = new Map(view.items.filter(item => item.kind === "CRITERION").map(item => [item.item_id, item]));
+ const row = (item: TraceItem): CriterionRow => {
+ const found = verdict("CRITERION", item.item_id);
+ const chosen = (found?.selection?.chosen ?? []).flatMap(ref => view.results.filter(result => result.result_id === ref.result_id && result.result_revision === ref.result_revision));
+ return { item, rule: view.rules.find(rule => rule.criterion_id === item.item_id), verdict: found, chosen };
+ };
+ const linked = new Set();
+ const groups = view.items.filter(item => item.kind === "REQUIREMENT").sort(byId).map(requirement => {
+ const ids = view.links.filter(link => link.relation === "REFINES" && link.to_id === requirement.item_id).map(link => link.from_id);
+ const members = ids.flatMap(id => (criteria.has(id) ? [criteria.get(id)!] : [])).sort(byId);
+ members.forEach(item => linked.add(item.item_id));
+ return { item: requirement, verdict: verdict("REQUIREMENT", requirement.item_id), criteria: members.map(row) };
+ });
+ const loose = [...criteria.values()].filter(item => !linked.has(item.item_id)).sort(byId);
+ return loose.length > 0 ? [...groups, { item: null, verdict: undefined, criteria: loose.map(row) }] : groups;
+}
+
+export const titleOf = (view: TraceView) => (id: string) => view.items.find(item => item.item_id === id)?.title || id;
+
+export function ruleText(rule: TraceRule | undefined) {
+ if (!rule) return "판정 규칙 없음";
+ const unit = rule.unit ? ` (${rule.unit})` : "";
+ return `${rule.threshold} ${rule.comparator === ">=" ? "이상" : "이하"}${unit}${rule.required ? "" : " · 필수 아님"}`;
+}
+
+export function resultText(result: TraceResult) {
+ const counts = result.numerator !== null && result.denominator !== null ? ` (${result.numerator}/${result.denominator})` : "";
+ const value = result.value ?? (result.raw_value ? `${result.raw_value}(숫자 아님)` : "값 없음");
+ return `${value}${result.unit ? ` ${result.unit}` : ""}${counts}`;
+}
+
+const MARK = "SYNTHETIC DEMO DATA";
+/** Data written for a demo says so on the screen; the table itself cannot tell a demo from real values. */
+export const isSyntheticTrace = (view: TraceView) =>
+ view.items.some(item => item.item_id.startsWith("SYN-") || item.title.includes(MARK)
+ || Object.entries(item.fields).some(([key, value]) => key === "synthetic_notice" || value.includes(MARK)));
diff --git a/apps/web/src/components/traceText.test.ts b/apps/web/src/components/traceText.test.ts
new file mode 100644
index 0000000..27504b7
--- /dev/null
+++ b/apps/web/src/components/traceText.test.ts
@@ -0,0 +1,194 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+import { applyFailureText, changeSentence, describeCondition, describeConditionText, issueText, lossText, NEXT_STEP_DECIDERS, nextStep, reasonSentence, verdictLook } from "./traceText";
+
+const source = (path: string) => readFileSync(new URL("../../../../src/thoth/" + path, import.meta.url), "utf8");
+const RAW_CODE = /[A-Z]{3,}_[A-Z_]{3,}/;
+
+describe("trace wording stays in step with the backend", () => {
+ it("has plain words for every problem code the import can report", () => {
+ const text = source("application/services/trace_csv.py") + source("application/services/verification_trace_import.py");
+ const codes = [...new Set([...text.matchAll(/Issue\(\s*"([A-Z_]+)"/g)].map(match => match[1]))];
+ expect(codes.length).toBeGreaterThan(20);
+ for (const code of codes) {
+ const said = issueText({ code, row: 3, ref: "X-1", detail: "detail" });
+ expect(said.message, code).not.toContain("이 파일을 반영할 수 없는 문제가 있습니다");
+ expect(said.message + said.next, code).not.toMatch(RAW_CODE);
+ expect(said.next.length, code).toBeGreaterThan(5);
+ }
+ });
+
+ it("has a look for every verdict state, and the wording never shows the state constant", () => {
+ const text = source("domain/verification_trace.py");
+ const states = (name: string) => {
+ const body = text.split("class " + name)[1].split("\nclass ")[0];
+ return [...body.matchAll(/^\s+([A-Z_]+) = "([A-Z_]+)"$/gm)].map(match => match[2]);
+ };
+ const criterion = states("CriterionVerdictState(StrEnum)");
+ const requirement = states("RequirementVerdictState(StrEnum)");
+ expect(criterion).toHaveLength(5); expect(requirement).toHaveLength(5);
+ for (const [kind, list] of [["CRITERION", criterion], ["REQUIREMENT", requirement]] as const) {
+ for (const state of list) {
+ const look = verdictLook(kind, state);
+ expect(look.text, state).not.toBe("알 수 없는 판정");
+ expect(look.text, state).not.toMatch(RAW_CODE);
+ expect(look.intent === "none", state).toBe(false);
+ }
+ }
+ });
+
+ it("turns every reason the rule can give into a sentence", () => {
+ const names = (id: string) => (id === "C-1" ? "탐지율(맑음)" : id);
+ const codes = ["NO_RULE", "NO_RESULT:weather=rain", "THRESHOLD_NOT_MET:R-1:value=0.80 need >= 0.90 ratio", "UNIT_MISMATCH:R-1:per_hour!=per_min",
+ "VALUE_MISSING:R-1", "VALUE_NOT_A_NUMBER:R-1", "VALUE_DISAGREES_WITH_COUNTS:R-1", "DENOMINATOR_NOT_POSITIVE:R-1", "VALUE_OUT_OF_RANGE:R-1",
+ "NO_REQUIRED_CRITERIA", "C-1:HOLD_NO_RESULT"];
+ for (const code of codes) {
+ const sentence = reasonSentence(code, names);
+ expect(sentence, code).not.toContain("이유를 문장으로 바꾸지 못했습니다");
+ expect(sentence, code).not.toMatch(RAW_CODE);
+ }
+ expect(reasonSentence("THRESHOLD_NOT_MET:R-1:value=0.80 need >= 0.90 ratio", names)).toBe("측정값 0.80이(가) 기준 0.90 이상(ratio)에 못 미칩니다.");
+ expect(reasonSentence("THRESHOLD_NOT_MET:R-2:value=0.70 need <= 0.50 per_min", names)).toBe("측정값 0.70이(가) 기준 0.50 이하(per_min)를 넘습니다.");
+ expect(reasonSentence("C-1:HOLD_NO_RESULT", names)).toBe("탐지율(맑음): 보류 · 결과 없음");
+ });
+
+ it("says what a refused apply means and what to do, and what the file cannot carry", () => {
+ for (const code of ["TRACE_IMPORT_PREVIEW_STALE", "TRACE_IMPORT_INPUT_CHANGED", "TRACE_IMPORT_NOT_APPLICABLE:STALE_BASE", "TRACE_REVISION_CONFLICT", "closing or archived project"]) {
+ const said = applyFailureText(new Error(code));
+ expect(said.message + said.next).not.toMatch(RAW_CODE);
+ expect(said.message).not.toBe("반영하지 못했습니다. 아무것도 저장되지 않았습니다.");
+ }
+ for (const line of source("application/services/trace_csv.py").match(/"(\w+): [^"]+"/g) ?? []) {
+ if (!/^"(evidence_text|verdict_history|confirmations|selection_policy|pending_changes|closures):/.test(line)) continue;
+ expect(lossText(line.slice(1, -1))).not.toMatch(RAW_CODE);
+ }
+ expect(lossText("closures: closure_status is for reading only; editing it changes nothing. Record closures in THOTH.")).toContain("처분 표시 열");
+ });
+});
+
+describe("conditions in plain words", () => {
+ it("names the known weather conditions and shows anything else as written", () => {
+ expect(describeCondition("weather", "rain")).toBe("날씨: 비");
+ expect(describeCondition("weather", "dry")).toBe("날씨: 맑음(건조)");
+ expect(describeCondition("weather", "fog")).toBe("날씨: 안개");
+ expect(describeCondition("weather", "snow")).toBe("날씨: snow");
+ expect(describeCondition("temperature", "hot")).toBe("temperature = hot");
+ expect(describeConditionText("weather=rain")).toBe("날씨: 비");
+ expect(describeConditionText("no key value")).toBe("no key value");
+ });
+
+ it("builds the no-result sentence from the verdict's condition, not from the code text", () => {
+ const names = (id: string) => id;
+ expect(reasonSentence("NO_RESULT:weather=rain", names, "weather=fog")).toBe("날씨: 안개 조건의 결과가 아직 없습니다.");
+ expect(reasonSentence("NO_RESULT:weather=rain", names)).toBe("날씨: 비 조건의 결과가 아직 없습니다.");
+ });
+
+ it("describes an added result from the stored result, and a changed condition in words", () => {
+ const result = { result_id: "R-1", result_revision: 1, criterion_id: "C", condition: "weather=rain", value: "0.80", raw_value: null, unit: "ratio", numerator: 16, denominator: 20, observed_at: "2026-01-02T00:00:00Z", source_span_refs: [] };
+ const added = { kind: "RESULT", ref_id: "R-1", field: "*", before: null, after: "0.80 ratio @weather=rain", before_revision: null, after_revision: 1, criterion_ids: [], requirement_ids: [] };
+ expect(changeSentence(added, { results: [result], rules: [] })).toBe("결과 추가: R-1 (0.80 ratio (16/20) · 날씨: 비)");
+ expect(changeSentence(added)).toBe("결과 추가: R-1");
+ const changed = { ...added, field: "condition", before: "weather=dry", after: "weather=rain" };
+ expect(changeSentence(changed)).toBe("결과 R-1의 조건: 날씨: 맑음(건조) → 날씨: 비");
+ });
+});
+
+describe("the next check for a held or failed line", () => {
+ const BANNED = /재시험 필수|원인은|기준을 낮추|통과 가능|승인됨|종결|해결|면제|AI 추천/;
+ const LEAK = /HOLD_|FAIL_|PASS_|[a-z]+_[a-z]+/;
+ const cases: [("CRITERION" | "REQUIREMENT"), string, number, string | null][] = [
+ ["CRITERION", "HOLD_NO_RESULT", 1, NEXT_STEP_DECIDERS.test],
+ ["CRITERION", "HOLD_INVALID_RESULT", 1, NEXT_STEP_DECIDERS.validity],
+ ["CRITERION", "HOLD_NO_RULE", 1, NEXT_STEP_DECIDERS.rule],
+ ["CRITERION", "FAIL_COMPUTED", 1, NEXT_STEP_DECIDERS.disposition],
+ ["REQUIREMENT", "FAIL", 1, NEXT_STEP_DECIDERS.disposition],
+ ["REQUIREMENT", "FAIL_WITH_INCOMPLETE_COVERAGE", 2, NEXT_STEP_DECIDERS.disposition],
+ ["REQUIREMENT", "HOLD_INCOMPLETE", 1, null],
+ ["REQUIREMENT", "HOLD_NO_CRITERIA", 1, NEXT_STEP_DECIDERS.rule],
+ ];
+
+ it.each(cases)("gives %s %s its own check lines and decider", (kind, state, lines, decider) => {
+ const step = nextStep(kind, state, false)!;
+ expect(step.lines).toHaveLength(lines);
+ expect(step.lines[0].decider).toBe(decider);
+ });
+
+ it("tells a failed requirement with missing conditions both to investigate the failure and to plan the untested conditions", () => {
+ const step = nextStep("REQUIREMENT", "FAIL_WITH_INCOMPLETE_COVERAGE", false)!;
+ expect(step.lines[0].text).toContain("원인 후보");
+ expect(step.lines[1].text).toContain("시험하지 않은 조건");
+ expect(step.lines[1].text).toContain("시험 계획");
+ // each line names its own decider: the authority for the failure, the test owner for the plan
+ expect(step.lines.map(item => item.decider)).toEqual([NEXT_STEP_DECIDERS.disposition, NEXT_STEP_DECIDERS.test]);
+ });
+
+ it("gives a met line nothing, unless its basis changed", () => {
+ for (const [kind, state] of [["CRITERION", "PASS_COMPUTED"], ["REQUIREMENT", "PASS"]] as const) {
+ expect(nextStep(kind, state, false)).toBeNull();
+ const stale = nextStep(kind, state, true)!;
+ expect(stale.lines).toHaveLength(1);
+ expect(stale.lines[0].text).toContain("이전 안내는 이전 근거 기준입니다");
+ expect(stale.lines[0].decider).toBeNull();
+ }
+ });
+
+ it("puts the changed-basis line first on a held line and keeps the state's own lines", () => {
+ const step = nextStep("CRITERION", "HOLD_NO_RESULT", true)!;
+ expect(step.lines[0].text).toContain("이전 안내는 이전 근거 기준입니다");
+ expect(step.lines).toHaveLength(2);
+ expect(step.lines[1].decider).toBe(NEXT_STEP_DECIDERS.test);
+ });
+
+ it.each([
+ ["HOLD_NO_RESULT", ["UNTESTED"], "연결된 시험의 결과가 아직 들어오지 않았습니다", NEXT_STEP_DECIDERS.test],
+ ["HOLD_NO_RESULT", ["UNLINKED"], "확인할 시험이 연결돼 있지 않습니다", NEXT_STEP_DECIDERS.rule],
+ ["HOLD_NO_RESULT", ["CONDITION_MISMATCH"], "기준의 조건과 달라 쓰지 않았습니다", NEXT_STEP_DECIDERS.test],
+ ["HOLD_INVALID_RESULT", ["UNIT"], "단위가 기준의 단위와 다릅니다", NEXT_STEP_DECIDERS.validity],
+ ["HOLD_INVALID_RESULT", ["DENOMINATOR"], "분모가 0 이하", NEXT_STEP_DECIDERS.validity],
+ ["HOLD_INVALID_RESULT", ["VALUE"], "값이 비었거나", NEXT_STEP_DECIDERS.validity],
+ ] as const)("gives %s with tag %j its own check and decider", (state, tags, phrase, decider) => {
+ const step = nextStep("CRITERION", state, false, tags)!;
+ expect(step.lines).toHaveLength(1);
+ expect(step.lines[0].text).toContain(phrase);
+ expect(step.lines[0].decider).toBe(decider);
+ });
+
+ it("gives one line per tag in a fixed order, and the general check when no tag has its own", () => {
+ const both = nextStep("CRITERION", "HOLD_INVALID_RESULT", false, ["VALUE", "UNIT"])!;
+ expect(both.lines).toHaveLength(2);
+ expect(both.lines[0].text).toContain("단위"); expect(both.lines[1].text).toContain("값이 비었거나");
+ const plain = nextStep("CRITERION", "HOLD_INVALID_RESULT", false, ["BASIS_CHANGED"])!;
+ expect(plain.lines).toEqual(nextStep("CRITERION", "HOLD_INVALID_RESULT", false)!.lines);
+ expect(nextStep("CRITERION", "HOLD_NO_RESULT", false, ["UNIT"])!.lines).toEqual(nextStep("CRITERION", "HOLD_NO_RESULT", false)!.lines); // a tag from the other state is ignored
+ expect(nextStep("REQUIREMENT", "HOLD_INCOMPLETE", false, ["UNTESTED"])!.lines).toHaveLength(1); // a requirement never takes a criterion's tag
+ });
+
+ it("guesses nothing for a state it does not know, or for the other kind's state", () => {
+ expect(nextStep("CRITERION", "SOMETHING_NEW", false)).toBeNull();
+ expect(nextStep("CRITERION", "SOMETHING_NEW", true)).toBeNull();
+ expect(nextStep("CRITERION", "HOLD_INCOMPLETE", false)).toBeNull();
+ expect(nextStep("REQUIREMENT", "FAIL_COMPUTED", false)).toBeNull();
+ });
+
+ it("never uses a forbidden phrase, a state constant or a field name in any line, or in the decider names", () => {
+ const lines: string[] = [];
+ for (const [kind, state] of cases) for (const stale of [false, true]) { const step = nextStep(kind, state, stale)!; lines.push(...step.lines.map(item => item.text)); }
+ for (const [state, tags] of [["HOLD_NO_RESULT", ["UNTESTED", "UNLINKED", "CONDITION_MISMATCH"]], ["HOLD_INVALID_RESULT", ["UNIT", "DENOMINATOR", "VALUE"]]] as const) {
+ for (const tag of tags) lines.push(...nextStep("CRITERION", state, true, [tag])!.lines.map(item => item.text));
+ }
+ lines.push(...nextStep("CRITERION", "PASS_COMPUTED", true)!.lines.map(item => item.text), ...Object.values(NEXT_STEP_DECIDERS));
+ expect(lines.length).toBeGreaterThan(10);
+ for (const line of lines) { expect(line).not.toMatch(BANNED); expect(line).not.toMatch(LEAK); }
+ });
+
+ it("covers every verdict state the backend can give, either with a check or by design with none", () => {
+ const text = source("domain/verification_trace.py");
+ const states = (name: string) => [...text.split("class " + name)[1].split("\nclass ")[0].matchAll(/^\s+([A-Z_]+) = "([A-Z_]+)"$/gm)].map(match => match[2]);
+ for (const [kind, list] of [["CRITERION", states("CriterionVerdictState(StrEnum)")], ["REQUIREMENT", states("RequirementVerdictState(StrEnum)")]] as const) {
+ for (const state of list) {
+ const met = state === "PASS" || state === "PASS_COMPUTED";
+ expect(nextStep(kind, state, false) === null, kind + " " + state).toBe(met);
+ }
+ }
+ });
+});
diff --git a/apps/web/src/components/traceText.ts b/apps/web/src/components/traceText.ts
new file mode 100644
index 0000000..dd8d529
--- /dev/null
+++ b/apps/web/src/components/traceText.ts
@@ -0,0 +1,248 @@
+import type { ChangedDependency, ImportIssue, TraceResult, TraceRule, VerdictChange } from "../api/trace";
+import { resultText, ruleText } from "./traceRows";
+import type { ReasonTagId } from "./traceReasonTags";
+
+/** Plain-language text for the trace table. Raw codes stay in the technical details, never in the main text. */
+
+export type Look = { text: string; intent: "success" | "danger" | "warning" | "none"; icon: "tick-circle" | "cross-circle" | "warning-sign" | "help" };
+
+const criterionLooks: Record = {
+ PASS_COMPUTED: { text: "기준 충족", intent: "success", icon: "tick-circle" },
+ FAIL_COMPUTED: { text: "기준 미달", intent: "danger", icon: "cross-circle" },
+ HOLD_NO_RESULT: { text: "보류 · 결과 없음", intent: "warning", icon: "warning-sign" },
+ HOLD_INVALID_RESULT: { text: "보류 · 결과를 쓸 수 없음", intent: "warning", icon: "warning-sign" },
+ HOLD_NO_RULE: { text: "보류 · 판정 규칙 없음", intent: "warning", icon: "warning-sign" },
+};
+const requirementLooks: Record = {
+ PASS: { text: "모든 필수 기준 충족", intent: "success", icon: "tick-circle" },
+ FAIL: { text: "기준 미달", intent: "danger", icon: "cross-circle" },
+ FAIL_WITH_INCOMPLETE_COVERAGE: { text: "기준 미달 · 결과 없는 조건도 있음", intent: "danger", icon: "cross-circle" },
+ HOLD_INCOMPLETE: { text: "보류 · 결과 없는 조건이 있음", intent: "warning", icon: "warning-sign" },
+ HOLD_NO_CRITERIA: { text: "보류 · 필수 기준이 없음", intent: "warning", icon: "warning-sign" },
+};
+export const verdictLook = (kind: string, state: string): Look =>
+ (kind === "REQUIREMENT" ? requirementLooks : criterionLooks)[state] ?? { text: "알 수 없는 판정", intent: "none", icon: "help" };
+
+/** Who decides after the check. Provisional names, to be confirmed with the people who do this work. */
+export const NEXT_STEP_DECIDERS = { test: "시험 책임자", validity: "지정 검증자", rule: "요구사항 담당자", disposition: "해당 권한자" } as const;
+
+/** One thing to check, and who decides after it. A line can have its own decider; null when no one is named. */
+export type NextLine = { text: string; decider: string | null };
+export type NextStep = { lines: NextLine[] };
+const line = (text: string, decider: string | null): NextLine => ({ text, decider });
+const STALE_CHECK = "바뀐 근거로 판정을 다시 볼지 확인합니다. 이전 안내는 이전 근거 기준입니다.";
+const INVESTIGATE = "시험이 유효했는지, 다른 원인 후보가 있는지 확인합니다.";
+
+const criterionNext: Record = {
+ HOLD_NO_RESULT: [line("이 조건의 시험 결과가 있는지, 있다면 추적표에 연결됐는지 확인합니다. 없으면 추가 시험 계획을 검토합니다.", NEXT_STEP_DECIDERS.test)],
+ HOLD_INVALID_RESULT: [line("결과의 ID·단위·분모·시험 조건을 원자료와 대조하고, 그 결과를 쓸지 정합니다.", NEXT_STEP_DECIDERS.validity)],
+ HOLD_NO_RULE: [line("요구사항 원문에서 승인된 판정 규칙과 그 버전을 확인해 규칙을 확정합니다.", NEXT_STEP_DECIDERS.rule)],
+ FAIL_COMPUTED: [line(INVESTIGATE, NEXT_STEP_DECIDERS.disposition)],
+};
+/** A more specific check for each reason tag of a held line; with none of these tags the state's general check above is used. */
+const tagNext: Record>> = {
+ HOLD_NO_RESULT: {
+ UNTESTED: line("이 기준에 연결된 시험의 결과가 아직 들어오지 않았습니다. 시험이 진행됐는지 확인하고, 결과가 없으면 시험 계획을 검토합니다.", NEXT_STEP_DECIDERS.test),
+ UNLINKED: line("이 기준을 확인할 시험이 연결돼 있지 않습니다. 이 기준을 시험으로 확인해야 하는지 정하고, 필요하면 시험과 연결합니다.", NEXT_STEP_DECIDERS.rule),
+ CONDITION_MISMATCH: line("결과는 있지만 기준의 조건과 달라 쓰지 않았습니다. 기준의 조건이 맞는지, 이 조건으로 시험이 더 필요한지 확인합니다.", NEXT_STEP_DECIDERS.test),
+ },
+ HOLD_INVALID_RESULT: {
+ UNIT: line("결과의 단위가 기준의 단위와 다릅니다. 원자료와 대조해 어느 쪽 단위가 맞는지 확인하고, 그 결과를 쓸지 정합니다.", NEXT_STEP_DECIDERS.validity),
+ DENOMINATOR: line("결과의 분모가 0 이하이거나, 값이 분자·분모로 계산한 값과 다릅니다. 분자·분모와 값을 원자료와 대조해 어느 쪽이 틀렸는지 확인합니다.", NEXT_STEP_DECIDERS.validity),
+ VALUE: line("결과의 값이 비었거나 숫자가 아니거나 너무 큽니다. 원자료에서 값을 다시 확인해 결과를 고칠지 정합니다.", NEXT_STEP_DECIDERS.validity),
+ },
+};
+const requirementNext: Record = {
+ FAIL: [line("미달인 기준 줄에서 " + INVESTIGATE, NEXT_STEP_DECIDERS.disposition)],
+ FAIL_WITH_INCOMPLETE_COVERAGE: [
+ line("미달인 기준 줄에서 " + INVESTIGATE, NEXT_STEP_DECIDERS.disposition),
+ line("아직 시험하지 않은 조건은 시험 계획을 따로 세웁니다. 미달 조사가 끝나도 이 계획은 남습니다.", NEXT_STEP_DECIDERS.test),
+ ],
+ HOLD_INCOMPLETE: [line("결과가 없는 조건은 각 기준 줄의 '다음 확인'을 따릅니다.", null)],
+ HOLD_NO_CRITERIA: [line("이 요구사항에 필수 기준이 정의돼 있는지 확인합니다.", NEXT_STEP_DECIDERS.rule)],
+};
+const settled = new Set(["PASS_COMPUTED", "PASS"]);
+const TAG_ORDER: ReasonTagId[] = ["UNTESTED", "UNLINKED", "CONDITION_MISMATCH", "UNIT", "DENOMINATOR", "VALUE"];
+
+/**
+ * What to check next for a held or failed line, from its state and its reason tags. A held criterion gets one line per tag it
+ * carries (no tag, or none this table knows, gives the state's general check). A met line has none unless its basis changed.
+ * An unknown state gets nothing, never a guess.
+ */
+export function nextStep(kind: "CRITERION" | "REQUIREMENT", state: string, stale: boolean, tags: readonly ReasonTagId[] = []): NextStep | null {
+ const general = (kind === "REQUIREMENT" ? requirementNext : criterionNext)[state];
+ if (!general && !settled.has(state)) return null;
+ const specific = kind === "CRITERION" ? TAG_ORDER.flatMap(id => (tags.includes(id) && tagNext[state]?.[id] ? [tagNext[state]![id]!] : [])) : [];
+ const own = specific.length > 0 ? specific : general ?? [];
+ if (!stale) return own.length > 0 ? { lines: own.map(item => ({ ...item })) } : null;
+ return { lines: [line(STALE_CHECK, null), ...own.map(item => ({ ...item }))] };
+}
+const triggers: Record = { INITIAL_COMPUTE: "처음 계산", CSV_IMPORT: "CSV 들여오기 반영", RECOMPUTE: "다시 계산" };
+export const triggerLabel = (trigger: string) => triggers[trigger] ?? "다른 이유로 다시 계산";
+
+export function actorLabel(actorId: string) {
+ if (actorId === "system:verification-trace") return "THOTH 계산";
+ if (actorId === "human:local-user") return "이 컴퓨터의 사용자";
+ return actorId;
+}
+export const timeText = (iso: string) => {
+ const date = new Date(iso);
+ return Number.isNaN(date.getTime()) ? iso : date.toLocaleString("ko-KR");
+};
+
+/** One reason the rule found, as a sentence. `titleOf` names a criterion for the reasons a requirement carries. */
+export function reasonSentence(code: string, titleOf: (id: string) => string, condition?: string | null): string {
+ const [head, ...rest] = code.split(":");
+ const tail = rest.join(":");
+ const known: Record = {
+ NO_RULE: "이 기준에는 판정 규칙이 없어 비교하지 않았습니다.",
+ NO_REQUIRED_CRITERIA: "필수 기준이 하나도 없어 통과로 보지 않습니다.",
+ };
+ if (known[head]) return known[head];
+ if (head === "NO_RESULT") {
+ // The condition comes from the verdict's own condition field; the text after the colon is only the fallback.
+ const text = condition ?? tail;
+ return text ? `${describeConditionText(text)} 조건의 결과가 아직 없습니다.` : "결과가 아직 없습니다.";
+ }
+ if (head === "THRESHOLD_NOT_MET") {
+ const match = /value=(\S+) need (>=|<=) (\S+) (.*)$/.exec(tail);
+ if (!match) return "측정값이 기준을 만족하지 못합니다.";
+ const unit = match[4] ? "(" + match[4] + ")" : "";
+ return match[2] === ">="
+ ? "측정값 " + match[1] + "이(가) 기준 " + match[3] + " 이상" + unit + "에 못 미칩니다."
+ : "측정값 " + match[1] + "이(가) 기준 " + match[3] + " 이하" + unit + "를 넘습니다.";
+ }
+ if (head === "UNIT_MISMATCH") {
+ const match = /:([^!]*)!=(.*)$/.exec(tail);
+ return match ? `결과의 단위(${match[1]})가 기준의 단위(${match[2]})와 달라 비교하지 않았습니다.` : "결과의 단위가 기준과 달라 비교하지 않았습니다.";
+ }
+ const simple: Record = {
+ VALUE_MISSING: "결과에 값이 비어 있어 비교하지 않았습니다.",
+ VALUE_NOT_A_NUMBER: "결과의 값이 숫자가 아니어서 비교하지 않았습니다.",
+ VALUE_DISAGREES_WITH_COUNTS: "결과의 값이 분자·분모로 계산한 값과 달라 비교하지 않았습니다.",
+ DENOMINATOR_NOT_POSITIVE: "결과의 분모가 0 이하여서 비교하지 않았습니다.",
+ VALUE_OUT_OF_RANGE: "결과의 값이 너무 커서 계산하지 못했습니다.",
+ };
+ if (simple[head]) return simple[head];
+ if (rest.length > 0 && criterionLooks[rest.join(":")]) return `${titleOf(head)}: ${criterionLooks[rest.join(":")].text}`;
+ return "이유를 문장으로 바꾸지 못했습니다. 기술 정보를 확인하세요.";
+}
+
+const fieldNames: Record = {
+ value: "측정값", unit: "단위", condition: "조건", numerator: "분자", denominator: "분모", result_revision: "결과 개정",
+ source_span_refs: "근거 위치", raw_value: "원문 값", observed_at: "관측 시각", criterion_id: "대상 기준", threshold: "기준값",
+ comparator: "비교 방향", required: "필수 여부", rounding: "반올림", measure: "측정 항목", rule_revision: "규칙 개정", policy: "결과 선택 정책",
+};
+const shown = (value: string | null) => (value === null || value === "" ? "비어 있음" : value);
+
+const conditionKeys: Record }> = {
+ weather: { name: "날씨", values: { dry: "맑음(건조)", rain: "비", fog: "안개" } },
+};
+/** A condition the user wrote as a key and a value, in plain words. A key or value this screen does not know is shown as written. */
+export function describeCondition(key: string, value: string): string {
+ const known = conditionKeys[key];
+ if (!known) return `${key} = ${value}`;
+ return `${known.name}: ${known.values[value] ?? value}`;
+}
+export function describeConditionText(text: string): string {
+ const at = text.indexOf("=");
+ return at < 0 ? text : describeCondition(text.slice(0, at).trim(), text.slice(at + 1).trim());
+}
+
+export type ChangeLookup = { results: TraceResult[]; rules: TraceRule[] };
+
+/** One input change as a sentence. What was added is described from the stored result or rule, never from the text the server wrote. */
+export function changeSentence(change: ChangedDependency, lookup?: ChangeLookup): string {
+ const name = change.ref_id;
+ const pair = (): [string | null, string | null] => (change.field === "condition"
+ ? [change.before, change.after].map(item => (item ? describeConditionText(item) : item)) as [string | null, string | null]
+ : [change.before, change.after]);
+ if (change.kind === "RESULT") {
+ if (change.field === "*") {
+ if (change.before !== null) return `결과 삭제: ${name}`;
+ const result = lookup?.results.find(item => item.result_id === name);
+ return result ? `결과 추가: ${name} (${resultText(result)} · ${describeConditionText(result.condition)})` : `결과 추가: ${name}`;
+ }
+ const [before, after] = pair();
+ return `결과 ${name}의 ${fieldNames[change.field] ?? "내용"}: ${shown(before)} → ${shown(after)}`;
+ }
+ if (change.kind === "RULE") {
+ if (change.field === "*") {
+ if (change.before !== null) return "판정 규칙 삭제";
+ const rule = lookup?.rules.find(item => item.rule_id === name);
+ return rule ? `판정 규칙 추가: ${ruleText(rule)} · ${describeConditionText(rule.condition)}` : "판정 규칙 추가";
+ }
+ const [before, after] = pair();
+ return `판정 규칙의 ${fieldNames[change.field] ?? "내용"}: ${shown(before)} → ${shown(after)}`;
+ }
+ if (change.kind === "LINK") return change.before === null ? `요구사항 연결 추가: ${name}` : `요구사항 연결 삭제: ${name}`;
+ return "결과 선택 정책이 바뀌었습니다.";
+}
+
+export const verdictChangeSentence = (change: VerdictChange, titleOf: (id: string) => string) =>
+ `${titleOf(change.subject_id)}: ${change.before === null ? "새로 계산됨" : verdictLook(change.subject_kind, change.before).text} → ${verdictLook(change.subject_kind, change.after).text}`;
+
+export const resultReasonText = (reason: string) =>
+ reason === "SUPERSEDED_BY_LATER_RESULT" ? "더 최근 결과가 있어 제외" : reason === "CONDITION_MISMATCH" ? "조건이 달라 제외" : "제외";
+
+const lossTexts: Record = {
+ evidence_text: "근거 문장 자체(위치만 담깁니다)",
+ verdict_history: "판정 이력(들여온 뒤 다시 계산됩니다)",
+ confirmations: "누가 어떤 판정을 확인했는지",
+ selection_policy: "결과를 고르는 정책",
+ pending_changes: "다시 계산이 필요한 항목 표시",
+ closures: "처분 표시 열(읽기 전용이라 고쳐도 반영되지 않습니다. 처분은 THOTH에서 기록하세요)",
+};
+export function lossText(line: string) {
+ const key = line.split(":")[0];
+ if (lossTexts[key]) return lossTexts[key];
+ return line.startsWith("column ignored:") ? `무시한 열: ${line.slice(line.indexOf(":") + 1).trim()}` : line;
+}
+
+export type IssueText = { message: string; next: string };
+const where = (issue: ImportIssue) => (issue.row ? ` (${issue.row}번째 줄)` : "");
+const ref = (issue: ImportIssue) => (issue.ref ? ` ${issue.ref}` : "");
+
+export function issueText(issue: ImportIssue): IssueText {
+ const table: Record IssueText> = {
+ STALE_BASE: () => ({ message: "이 파일은 지금의 추적표와 다른 상태에서 내보낸 것입니다. 그 뒤에 추적표가 바뀌었습니다.", next: "지금 상태에서 'CSV로 내보내기'를 다시 한 뒤, 그 파일을 고쳐서 들여오세요." }),
+ BASE_DIGEST_MISSING: () => ({ message: "파일에 어느 상태에서 내보낸 것인지 표시가 없습니다.", next: "'CSV로 내보내기'로 받은 파일을 그대로 고쳐 쓰세요. 머리 줄과 기준 상태 열은 지우지 마세요." }),
+ BASE_DIGEST_INCONSISTENT: () => ({ message: "한 파일 안에 서로 다른 상태에서 내보낸 줄이 섞여 있습니다.", next: "한 번에 내보낸 파일 하나만 고치세요." }),
+ MODE_CREATE_NEEDS_EMPTY_PROJECT: () => ({ message: "이 프로젝트에는 이미 추적표가 있어 '새로 만들기'를 할 수 없습니다.", next: "'기존 표 고치기'를 선택하세요." }),
+ UPDATE_NEEDS_EXISTING_TRACE: () => ({ message: "고칠 추적표가 아직 없습니다.", next: "'새로 만들기'를 선택하세요." }),
+ DUPLICATE_ID_IN_FILE: () => ({ message: `같은 ID${ref(issue)}가 파일에 두 번 이상 있습니다${where(issue)}.`, next: "중복된 줄을 하나로 정리하세요." }),
+ ROW_INVALID: () => ({ message: `${issue.row ?? "?"}번째 줄${ref(issue)}의 값을 읽지 못했습니다. 칸의 형식이 맞는지 확인하세요.`, next: "숫자 칸에는 숫자만, 종류 칸에는 정해진 값만 쓰세요." }),
+ ITEM_KEY_CHANGED: () => ({ message: `항목${ref(issue)}의 내부 키는 바꿀 수 없습니다${where(issue)}.`, next: "내부 키 칸은 내보낸 값 그대로 두세요." }),
+ DELETE_TARGET_NOT_FOUND: () => ({ message: `지우려는 것${ref(issue)}이(가) 추적표에 없습니다${where(issue)}.`, next: "삭제 줄의 종류와 ID를 확인하세요." }),
+ DELETE_BLOCKED_BY_LINK: () => ({ message: `항목${ref(issue)}에는 아직 연결이 남아 있어 지울 수 없습니다.`, next: "연결 줄의 삭제 줄도 함께 넣거나, 항목 삭제를 빼세요." }),
+ DELETE_BLOCKED_BY_RULE: () => ({ message: `기준${ref(issue)}에는 판정 규칙이 남아 있어 지울 수 없습니다.`, next: "규칙의 삭제 줄도 함께 넣거나, 기준 삭제를 빼세요." }),
+ DELETE_BLOCKED_BY_RESULT: () => ({ message: `항목${ref(issue)}에는 결과가 남아 있어 지울 수 없습니다.`, next: "결과의 삭제 줄도 함께 넣거나, 삭제를 빼세요." }),
+ DELETE_AND_UPSERT_SAME_TARGET: () => ({ message: `${ref(issue)}을(를) 같은 파일에서 고치면서 지우려 합니다${where(issue)}.`, next: "둘 중 하나만 남기세요." }),
+ DELETE_NOT_ALLOWED_IN_CREATE: () => ({ message: "'새로 만들기'에는 삭제 줄을 쓸 수 없습니다.", next: "삭제 줄을 빼거나 '기존 표 고치기'를 선택하세요." }),
+ DELETE_ROW_INCOMPLETE: () => ({ message: `삭제 줄에 종류나 ID가 비어 있습니다${where(issue)}.`, next: "삭제 줄에는 종류와 ID를 모두 쓰세요." }),
+ EXCEL_ID_SUSPECTED: () => ({ message: `ID${ref(issue)}는 스프레드시트가 바꾼 것처럼 보입니다${where(issue)}. ${issue.detail}`, next: "원래 ID로 되돌리고, ID 칸을 '텍스트' 서식으로 바꾼 뒤 다시 저장하세요." }),
+ GRAPH_INVALID: () => ({ message: "반영하면 연결이 맞지 않는 곳이 생깁니다.", next: "연결 줄의 양쪽 ID와 종류가 추적표에 있는지 확인하세요." }),
+ HEADER_MISSING_COLUMNS: () => ({ message: "파일의 머리 줄에 꼭 필요한 열이 빠져 있습니다.", next: "'CSV로 내보내기'로 받은 파일의 머리 줄을 지우지 말고 쓰세요." }),
+ HEADER_DUPLICATE_COLUMN: () => ({ message: "머리 줄에 같은 열 이름이 두 번 있습니다.", next: "중복된 열을 지우세요." }),
+ FORMAT_UNSUPPORTED: () => ({ message: `이 화면이 읽는 형식의 파일이 아닙니다${where(issue)}.`, next: "'CSV로 내보내기'로 받은 파일을 쓰세요." }),
+ ROW_TYPE_UNKNOWN: () => ({ message: `줄의 종류를 알 수 없습니다${where(issue)}.`, next: "줄의 종류는 항목·연결·규칙·결과·삭제 중 하나여야 합니다." }),
+ ID_MISSING: () => ({ message: `ID가 비어 있는 줄이 있습니다${where(issue)}.`, next: "ID 칸을 채우거나 그 줄을 지우세요." }),
+ CSV_EMPTY: () => ({ message: "파일이 비어 있습니다.", next: "내용이 있는 파일을 고르세요." }),
+ CSV_HAS_NO_ROWS: () => ({ message: "파일에 들여올 줄이 없습니다.", next: "내용이 있는 파일을 고르세요." }),
+ FILE_TOO_LARGE: () => ({ message: "파일이 너무 큽니다.", next: "파일을 나누어 들여오세요." }),
+ TOO_MANY_ROWS: () => ({ message: "줄이 너무 많습니다.", next: "파일을 나누어 들여오세요." }),
+ CSV_UNREADABLE: () => ({ message: "CSV로 읽을 수 없는 파일입니다.", next: "UTF-8 CSV 파일인지 확인하세요." }),
+ };
+ return (table[issue.code] ?? (() => ({ message: "이 파일을 반영할 수 없는 문제가 있습니다.", next: "기술 정보를 확인한 뒤 파일을 고쳐 다시 시도하세요." })))();
+}
+
+/** An apply that the server refused, in words and with what to do next. */
+export function applyFailureText(error: unknown): IssueText {
+ const message = error instanceof Error ? error.message : "";
+ if (message.includes("TRACE_IMPORT_PREVIEW_STALE")) return { message: "미리보기를 본 뒤에 추적표가 바뀌어 반영하지 않았습니다.", next: "파일을 다시 선택해 미리보기부터 다시 하세요." };
+ if (message.includes("TRACE_IMPORT_INPUT_CHANGED")) return { message: "미리보기에서 본 파일과 반영하려는 파일이 달라 반영하지 않았습니다.", next: "파일을 다시 선택해 미리보기부터 다시 하세요." };
+ if (message.includes("TRACE_IMPORT_NOT_APPLICABLE")) return { message: "파일에 해결되지 않은 문제가 있어 반영하지 않았습니다.", next: "미리보기의 문제를 고친 파일로 다시 시도하세요." };
+ if (message.includes("TRACE_REVISION_CONFLICT")) return { message: "다른 곳에서 먼저 추적표가 바뀌어 반영하지 않았습니다.", next: "화면을 새로 읽은 뒤 다시 시도하세요." };
+ if (message.includes("closing or archived")) return { message: "닫는 중이거나 보관된 프로젝트에서는 추적표를 바꿀 수 없습니다.", next: "프로젝트 상태를 확인하세요." };
+ return { message: "반영하지 못했습니다. 아무것도 저장되지 않았습니다.", next: "잠시 뒤 다시 시도하세요." };
+}
diff --git a/apps/web/src/components/useHypothesisLinks.ts b/apps/web/src/components/useHypothesisLinks.ts
new file mode 100644
index 0000000..1a1894f
--- /dev/null
+++ b/apps/web/src/components/useHypothesisLinks.ts
@@ -0,0 +1,9 @@
+import { useQuery } from "@tanstack/react-query";
+import { hypothesisLinksKey, listHypothesisLinks } from "../api/hypothesisLink";
+
+/** The hypotheses of a project that came from a trace row, read against today's verdicts. A failed read shows no marks. */
+export function useHypothesisLinks(projectId: string, enabled = true) {
+ const query = useQuery({ queryKey: hypothesisLinksKey(projectId), enabled: enabled && Boolean(projectId), retry: false,
+ queryFn: ({ signal }) => listHypothesisLinks(projectId, signal) });
+ return { links: query.data?.links, distribution: query.data?.reason_distribution };
+}
diff --git a/apps/web/src/components/useJudgmentRecords.ts b/apps/web/src/components/useJudgmentRecords.ts
new file mode 100644
index 0000000..44c451d
--- /dev/null
+++ b/apps/web/src/components/useJudgmentRecords.ts
@@ -0,0 +1,28 @@
+import { useQuery } from "@tanstack/react-query";
+import { closuresKey, discriminationKey, listClosures, listDiscrimination } from "../api/judgmentRecords";
+import { lessonsKey, listLessons } from "../api/lessons";
+import { listSame, sameKey } from "../api/hypothesisSame";
+
+/** What people recorded about the tests of a project's hypotheses. A failed read shows nothing recorded; it never blocks the screen. */
+export function useDiscrimination(projectId: string, enabled = true) {
+ const query = useQuery({ queryKey: discriminationKey(projectId), enabled: enabled && Boolean(projectId), retry: false, queryFn: ({ signal }) => listDiscrimination(projectId, signal) });
+ return { items: query.data?.items };
+}
+
+/** Earlier results recalled for the rows of a project, by exact condition. A failed read shows none. */
+export function useSameHypotheses(projectId: string, enabled = true) {
+ const query = useQuery({ queryKey: sameKey(projectId), enabled: enabled && Boolean(projectId), retry: false, queryFn: ({ signal }) => listSame(projectId, signal) });
+ return { same: query.data };
+}
+
+/** Earlier results recalled for the rows of a project, by exact condition. A failed read shows none. */
+export function useLessons(projectId: string, enabled = true) {
+ const query = useQuery({ queryKey: lessonsKey(projectId), enabled: enabled && Boolean(projectId), retry: false, queryFn: ({ signal }) => listLessons(projectId, signal) });
+ return { lessons: query.data?.lessons };
+}
+
+/** The closures people recorded on trace rows, with the effect the rules confirmed. A failed read shows none. */
+export function useTraceClosures(projectId: string, enabled = true) {
+ const query = useQuery({ queryKey: closuresKey(projectId), enabled: enabled && Boolean(projectId), retry: false, queryFn: ({ signal }) => listClosures(projectId, signal) });
+ return { closures: query.data?.closures };
+}
diff --git a/apps/web/src/components/useResearchSession.ts b/apps/web/src/components/useResearchSession.ts
index dfb66b6..1bebc07 100644
--- a/apps/web/src/components/useResearchSession.ts
+++ b/apps/web/src/components/useResearchSession.ts
@@ -6,11 +6,13 @@ import type { ResearchAdmission } from "../api/research";
import type { ModelSelection } from "./ModelSettings";
import { readDraft, writeDraft } from "../api/conversation";
import { clearLocalPending, inspectLocalDraft, readLocalPending, submissionSignature, validWorkspaceId, writeLocalPending,
- type BrowserScope, type DraftWriteResult, type PendingRead, type PendingSubmission } from "../api/localWorkspacePersistence";
+ type BrowserScope, type DraftWriteResult, type PendingOrigin, type PendingRead, type PendingSubmission } from "../api/localWorkspacePersistence";
/** Draft ownership only. All execution/current-result states are server projections. */
export function useResearchSession(projectId: string, threadId: string, epoch: RefObject, onAdmitted: (id: string) => void,
- storageScope: BrowserScope, canExecute: boolean) {
+ storageScope: BrowserScope, canExecute: boolean,
+ /** The trace row this question is about (set by "원인 조사"); it goes with the next request only, and is dropped when the question is emptied. */
+ origin: PendingOrigin | null = null, onOriginDone?: () => void) {
const client = useQueryClient();
const [problem, updateProblem] = useState(() => readDraft(projectId, threadId, storageScope));
const problemRef = useRef(problem);
@@ -26,6 +28,7 @@ export function useResearchSession(projectId: string, threadId: string, epoch: R
const [pendingStorageError, setPendingStorageError] = useState(null);
const [queuedNotice, setQueuedNotice] = useState(false);
const setProblem = (text: string) => {
+ if (origin && !text.trim()) onOriginDone?.();
const saved = writeDraft(projectId, threadId, text, storageScope);
if (storageScope.mode === "LOCAL") {
setDraftSaveState(saved);
@@ -51,7 +54,7 @@ export function useResearchSession(projectId: string, threadId: string, epoch: R
const [modelSelection, setModelSelection] = useState(null);
const draftRevision = useRef(0);
const pendingSubmission = useRef<{ signature: string; key: string; inFlight: boolean } | null>(null);
- const currentSignature = submissionSignature(projectId, threadId, problem, modelSelection);
+ const currentSignature = submissionSignature(projectId, threadId, problem, modelSelection, origin);
const pendingBlocksNewInput = pendingRead.kind === "INVALID" || pendingRead.kind === "PENDING" && pendingRead.value.signature !== currentSignature;
const chooseModel = (selection: ModelSelection) => { draftRevision.current += 1; setModelSelection(selection); };
const clearModel = (revision: number, capturedEpoch = epoch.current) => {
@@ -79,9 +82,9 @@ export function useResearchSession(projectId: string, threadId: string, epoch: R
else if (sawRunning.current) setQueuedNotice(false);
}, [queuedNotice, operationState]);
const submit = useMutation({
- mutationFn: async (captured: { projectId: string; threadId: string; problem: string; selection: ModelSelection | null; revision: number; epoch: number; key: string; scope: BrowserScope }) => {
+ mutationFn: async (captured: { projectId: string; threadId: string; problem: string; selection: ModelSelection | null; origin: PendingOrigin | null; revision: number; epoch: number; key: string; scope: BrowserScope }) => {
const admission = await rpc(captured.threadId ? "thread/input" : "thread/start", {
- project_id: captured.projectId, contract_version: 2, ...captured.selection,
+ project_id: captured.projectId, contract_version: 2, ...captured.selection, ...(captured.origin ? { origin: captured.origin } : {}),
...(captured.threadId ? { thread_id: captured.threadId, instruction: captured.problem } : { problem: captured.problem }),
}, captured.key);
const value = admission.value;
@@ -112,6 +115,7 @@ export function useResearchSession(projectId: string, threadId: string, epoch: R
else setPendingStorageError("접수는 확인됐지만 브라우저의 요청 복구 표시를 지우지 못했습니다. 원 요청을 다시 확인하세요.");
}
clearModel(captured.revision, captured.epoch);
+ if (captured.origin) onOriginDone?.();
setQueuedNotice(admission.status === "QUEUED_AFTER_CURRENT");
const next = problemRef.current === captured.problem ? "" : problemRef.current;
if (!next) {
@@ -156,7 +160,7 @@ export function useResearchSession(projectId: string, threadId: string, epoch: R
const { value } = stored;
pendingSubmission.current = { signature: value.signature, key: value.key, inFlight: true };
setPendingStorageError(null);
- submit.mutate({ projectId, threadId, problem: value.problem, selection: value.selection,
+ submit.mutate({ projectId, threadId, problem: value.problem, selection: value.selection, origin: value.origin ?? null,
revision: -1, epoch: epoch.current, key: value.key, scope: storageScope });
};
return { research, queuedNotice, problem, setProblem, draftSaveState, draftRestoreIssue, pendingRead, pendingBlocksNewInput, pendingStorageError,
@@ -170,7 +174,7 @@ export function useResearchSession(projectId: string, threadId: string, epoch: R
if (stored.kind === "INVALID" || stored.kind === "PENDING" && stored.value.signature !== currentSignature) return;
const record: PendingSubmission = stored.kind === "PENDING" ? stored.value : {
version: 1, workspaceId: storageScope.workspaceId ?? "", projectId, threadId,
- problem, selection: modelSelection, signature: currentSignature, key: crypto.randomUUID(), createdAt: Date.now(),
+ problem, selection: modelSelection, ...(origin ? { origin } : {}), signature: currentSignature, key: crypto.randomUUID(), createdAt: Date.now(),
};
if (stored.kind === "NONE") {
const saved = writeLocalPending(record);
@@ -183,15 +187,15 @@ export function useResearchSession(projectId: string, threadId: string, epoch: R
}
pendingSubmission.current = { signature: record.signature, key: record.key, inFlight: true };
setPendingStorageError(null);
- submit.mutate({ projectId, threadId, problem: record.problem, selection: record.selection,
+ submit.mutate({ projectId, threadId, problem: record.problem, selection: record.selection, origin: record.origin ?? null,
revision: draftRevision.current, epoch: epoch.current, key: record.key, scope: storageScope });
return;
}
- const signature = JSON.stringify([projectId,threadId,problem,modelSelection,epoch.current]);
+ const signature = JSON.stringify([projectId,threadId,problem,modelSelection,epoch.current,...(origin ? [origin] : [])]);
if (pendingSubmission.current?.signature !== signature) pendingSubmission.current = {signature,key:crypto.randomUUID(),inFlight:false};
const request = pendingSubmission.current;
request.inFlight = true;
- submit.mutate({ projectId, threadId, problem, selection: modelSelection, revision: draftRevision.current, epoch: epoch.current, key:request.key, scope: storageScope });
+ submit.mutate({ projectId, threadId, problem, selection: modelSelection, origin, revision: draftRevision.current, epoch: epoch.current, key:request.key, scope: storageScope });
},
};
}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 19810ac..fb945b6 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { App } from "./App";
+import { ThothBlueprint } from "./components/ThothBlueprint";
import "@blueprintjs/core/lib/css/blueprint.css";
import "@blueprintjs/icons/lib/css/blueprint-icons.css";
import "./styles.css";
@@ -13,7 +14,9 @@ const queryClient = new QueryClient();
createRoot(document.getElementById("root")!).render(
-
+
+
+
,
);
diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts
index 4033efd..bf9e457 100644
--- a/apps/web/src/types.ts
+++ b/apps/web/src/types.ts
@@ -18,6 +18,8 @@ export type WorkThread = {
current_object_ids: string[];
working_head_digest: string;
updated_at?: string | null;
+ /** The trace row this thread was started from; null or missing for an ordinary or older thread. */
+ origin?: { subject_kind: string; subject_id: string; verdict_revision: string } | null;
};
export type ConnectedArtifact = {
diff --git a/apps/web/src/workstation.css b/apps/web/src/workstation.css
index 7455130..94e8c0d 100644
--- a/apps/web/src/workstation.css
+++ b/apps/web/src/workstation.css
@@ -310,3 +310,34 @@ button:focus-visible, summary:focus-visible, a:focus-visible { outline: 2px soli
.review-line {margin:6px 0}.review-line p {margin:2px 0}.cell-review {min-height:16px;padding:0 2px}.review-reasons {border:0;padding:0;margin:8px 0}.review-reasons legend {font-size:11px;color:var(--muted)}.review-note {display:block;font-size:11px;margin-top:8px}
.approval-card ins {background:rgba(45,164,78,.25);text-decoration:none;padding:0 2px}.approval-card del {background:rgba(219,55,55,.25);padding:0 2px}.approval-change {margin:6px 0}.approval-actions {display:flex;gap:8px;margin-top:8px}.approval-full {display:grid;gap:4px;margin:8px 0}.approval-full dt {font-size:11px;color:var(--muted)}.approval-full dd {margin:0}
.project-memory .memory-card.older {opacity:.7}.project-memory .memory-reason {color:var(--warning, #d9822b);margin:2px 0}.history-change-brief {display:flex;gap:6px;flex-wrap:wrap;align-items:center;font-size:11px;color:var(--muted)}
+
+/* Trace table: one line per requirement and criterion, verdict in words and colour, history under the line. */
+.trace-page{gap:12px}
+.trace-synthetic-banner{background:#3a2a10 !important}
+.workspace-content:has(.trace-page){display:flex;flex-direction:column;overflow:hidden}.trace-page{flex:1;min-height:0;display:flex;flex-direction:column}.trace-synthetic-banner{flex:none}.trace-body{flex:1;min-height:0;overflow:auto;display:grid;gap:12px;align-content:start;padding-right:4px}.trace-table .bp6-button{padding-left:6px;padding-right:6px}.trace-page{min-width:0}.trace-table td,.trace-table th{min-width:0}
+.trace-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
+.trace-table{width:100%;table-layout:fixed;font-size:12px}
+.trace-table .bp6-button{white-space:nowrap}
+.trace-table .bp6-tag{white-space:normal;max-width:100%;height:auto;min-height:20px}.trace-table .bp6-tag .bp6-text-overflow-ellipsis{white-space:normal;overflow:visible;text-overflow:clip}
+.trace-sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
+.trace-table th,.trace-table td{vertical-align:top;text-align:left;padding:8px 10px;line-height:1.55;overflow-wrap:anywhere}
+.trace-table thead th{font-size:11px;color:var(--muted);font-weight:600}
+.trace-table .trace-requirement>*{background:var(--surface-1);border-top:2px solid var(--line)}
+.trace-table .trace-requirement th{font-size:13px}
+.trace-kind{display:inline-block;font-size:10px;font-weight:600;padding:1px 6px;border:1px solid var(--line);border-radius:8px;margin-right:4px;color:var(--muted)}
+.trace-indent{display:inline-block;padding-left:14px}
+.trace-reasons{margin:4px 0 0;padding-left:16px;font-size:11px;line-height:1.55}
+.trace-next{margin-top:4px}.trace-tags{display:flex;flex-wrap:wrap;gap:4px;margin-top:4px}.trace-next-label{font-size:11px;font-weight:600}
+.trace-history{list-style:none;margin:0;padding:6px 0 10px;display:grid;gap:10px}
+.trace-revision{border:1px solid var(--line-soft);border-radius:8px;padding:10px 12px;background:var(--surface-0)}
+.trace-revision[data-current="true"]{border-color:var(--line)}
+.trace-revision h4{margin:0 0 6px;font-size:12px}
+.trace-revision p{margin:4px 0}
+.trace-change{font-size:11px;color:var(--muted)}
+.trace-preview h3{font-size:13px;margin:12px 0 6px}
+.trace-file-field{display:grid;gap:4px;margin:10px 0}
+.trace-notice p{margin:2px 0}
+.trace-evidence-dialog{width:min(780px,94vw)}
+.trace-evidence-dialog .bp6-dialog-body{max-height:calc(100vh - 230px);overflow-y:auto}
+.trace-evidence-dialog .evidence-item.focused{border-color:#8abbff;background:rgba(76,144,240,.16);box-shadow:0 0 0 2px rgba(76,144,240,.45)}
+.trace-import-dialog{width:min(720px,94vw)}
diff --git a/config/architecture-conformance.json b/config/architecture-conformance.json
index 8452490..a1a6a33 100644
--- a/config/architecture-conformance.json
+++ b/config/architecture-conformance.json
@@ -170,7 +170,7 @@
],
"known_exceptions": [],
"watch_limits": {
- "python_module_lines": 900,
+ "python_module_lines": 600,
"warning_only": false,
"enforced_by": "scripts/check_module_budget.py"
}
diff --git a/config/atomicity-paths.json b/config/atomicity-paths.json
index 5ad417c..4a65918 100644
--- a/config/atomicity-paths.json
+++ b/config/atomicity-paths.json
@@ -2,7 +2,7 @@
"schema_version": "1.0.0",
"purpose": "DEVELOPMENT_VERIFICATION_LEDGER_NOT_RUNTIME_POLICY",
"baseline_head": "f0262732a0e79235f3839c3393f459b90bd53783",
- "catalog_sha256": "e9c33b11d61e95b8946953dbc1fc7e792e4df4e6e99645971a9124b50222a7c5",
+ "catalog_sha256": "9c8e14688ccd2bc92bc2487213c7dd4fbe2cd625572a0673f1f2282a7b29e95a",
"public_entries": [
{
"name": "action/audit/read",
@@ -166,6 +166,33 @@
],
"mutation_observed_in_code": true
},
+ {
+ "name": "action/draft/fromTest",
+ "namespace": "action",
+ "surface": "COMMAND",
+ "canonical_owner": "ACTION_PLAN_AUTHORIZATION",
+ "policy": "action:versioned-policy",
+ "normal_entrypoint": "thread/input",
+ "behavioral_evidence": [
+ "A04",
+ "A05"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED",
+ "method": "action/draft/fromTest",
+ "actual_callable": "thoth.application.commands.action_from_test.ActionFromTestHandlers.draft",
+ "source": "src/thoth/application/commands/action_from_test.py",
+ "line": 38,
+ "callable_sha256": "6a4834d2b555c8f5d64715f34ee68ce0686519b3d94fe9a924e087a67e6639ef",
+ "entry_kind": "public",
+ "path_id": "rpc:action/draft/fromTest",
+ "owner_scope": "ACTION_PLAN_AUTHORIZATION",
+ "phase_ids": [
+ "rpc:action/draft/fromTest"
+ ],
+ "mutation_observed_in_code": true
+ },
{
"name": "action/generate",
"namespace": "action",
@@ -3419,6 +3446,141 @@
],
"mutation_observed_in_code": true
},
+ {
+ "name": "hypothesis/link/list",
+ "namespace": "hypothesis",
+ "surface": "QUERY",
+ "canonical_owner": "HYPOTHESIS",
+ "policy": "hypothesis:versioned-policy",
+ "normal_entrypoint": "thread/input",
+ "behavioral_evidence": [
+ "A01",
+ "A03"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED",
+ "method": "hypothesis/link/list",
+ "actual_callable": "thoth.application.commands.hypothesis_link.HypothesisLinkHandlers.list",
+ "source": "src/thoth/application/commands/hypothesis_link.py",
+ "line": 87,
+ "callable_sha256": "8ff5bfa13aa41db1de62a7c69f40e2e7c97b47b441c7f701296fe9d47758a8dd",
+ "entry_kind": "public",
+ "path_id": "rpc:hypothesis/link/list",
+ "owner_scope": "HYPOTHESIS",
+ "phase_ids": [
+ "rpc:hypothesis/link/list"
+ ],
+ "mutation_observed_in_code": false
+ },
+ {
+ "name": "hypothesis/test/result/list",
+ "namespace": "hypothesis",
+ "surface": "QUERY",
+ "canonical_owner": "HYPOTHESIS",
+ "policy": "hypothesis:versioned-policy",
+ "normal_entrypoint": "thread/input",
+ "behavioral_evidence": [
+ "A01",
+ "A03"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED",
+ "method": "hypothesis/test/result/list",
+ "actual_callable": "thoth.application.commands.discrimination.DiscriminationHandlers.list",
+ "source": "src/thoth/application/commands/discrimination.py",
+ "line": 68,
+ "callable_sha256": "efddb148aa648ed435c004848421038b3710fcc5ea7d3b468633875d97e93966",
+ "entry_kind": "public",
+ "path_id": "rpc:hypothesis/test/result/list",
+ "owner_scope": "HYPOTHESIS",
+ "phase_ids": [
+ "rpc:hypothesis/test/result/list"
+ ],
+ "mutation_observed_in_code": false
+ },
+ {
+ "name": "hypothesis/link/recheck",
+ "namespace": "hypothesis",
+ "surface": "COMMAND",
+ "canonical_owner": "HYPOTHESIS",
+ "policy": "hypothesis:versioned-policy",
+ "normal_entrypoint": "thread/input",
+ "behavioral_evidence": [
+ "A01",
+ "A03"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED",
+ "method": "hypothesis/link/recheck",
+ "actual_callable": "thoth.application.commands.hypothesis_link.HypothesisLinkHandlers.recheck",
+ "source": "src/thoth/application/commands/hypothesis_link.py",
+ "line": 97,
+ "callable_sha256": "f7bebb16c954a88318ab95570d1ea8f12f885d21fcd2ffec616ed2c6d7b77d62",
+ "entry_kind": "public",
+ "path_id": "rpc:hypothesis/link/recheck",
+ "owner_scope": "HYPOTHESIS",
+ "phase_ids": [
+ "rpc:hypothesis/link/recheck"
+ ],
+ "mutation_observed_in_code": true
+ },
+ {
+ "name": "hypothesis/refutation/record",
+ "namespace": "hypothesis",
+ "surface": "COMMAND",
+ "canonical_owner": "HYPOTHESIS",
+ "policy": "hypothesis:versioned-policy",
+ "normal_entrypoint": "thread/input",
+ "behavioral_evidence": [
+ "A01",
+ "A03"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED",
+ "method": "hypothesis/refutation/record",
+ "actual_callable": "thoth.application.commands.discrimination.DiscriminationHandlers.record_conditions",
+ "source": "src/thoth/application/commands/discrimination.py",
+ "line": 93,
+ "callable_sha256": "745640d42e12d7a117237633ccd7b69c5572fbdb909777fc9d2de39406407fa3",
+ "entry_kind": "public",
+ "path_id": "rpc:hypothesis/refutation/record",
+ "owner_scope": "HYPOTHESIS",
+ "phase_ids": [
+ "rpc:hypothesis/refutation/record"
+ ],
+ "mutation_observed_in_code": true
+ },
+ {
+ "name": "hypothesis/test/result/record",
+ "namespace": "hypothesis",
+ "surface": "COMMAND",
+ "canonical_owner": "HYPOTHESIS",
+ "policy": "hypothesis:versioned-policy",
+ "normal_entrypoint": "thread/input",
+ "behavioral_evidence": [
+ "A01",
+ "A03"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED",
+ "method": "hypothesis/test/result/record",
+ "actual_callable": "thoth.application.commands.discrimination.DiscriminationHandlers.record_result",
+ "source": "src/thoth/application/commands/discrimination.py",
+ "line": 73,
+ "callable_sha256": "b03a13d3ed0feb95c5730fd1860c6f92a9f8316ceb1f7298e9a1145a1241a57a",
+ "entry_kind": "public",
+ "path_id": "rpc:hypothesis/test/result/record",
+ "owner_scope": "HYPOTHESIS",
+ "phase_ids": [
+ "rpc:hypothesis/test/result/record"
+ ],
+ "mutation_observed_in_code": true
+ },
{
"name": "hypothesis/list",
"namespace": "hypothesis",
@@ -3824,6 +3986,33 @@
],
"mutation_observed_in_code": false
},
+ {
+ "name": "hypothesis/same/list",
+ "namespace": "hypothesis",
+ "surface": "QUERY",
+ "canonical_owner": "HYPOTHESIS",
+ "policy": "hypothesis:versioned-policy",
+ "normal_entrypoint": "thread/input",
+ "behavioral_evidence": [
+ "A01",
+ "A03"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED",
+ "method": "hypothesis/same/list",
+ "actual_callable": "thoth.application.commands.hypothesis_same.SameHandlers.list",
+ "source": "src/thoth/application/commands/hypothesis_same.py",
+ "line": 45,
+ "callable_sha256": "8625ce7443ef24f1e649c50f8db067751de41f7534d6edbeed31f0679b0ca53a",
+ "entry_kind": "public",
+ "path_id": "rpc:hypothesis/same/list",
+ "owner_scope": "HYPOTHESIS",
+ "phase_ids": [
+ "rpc:hypothesis/same/list"
+ ],
+ "mutation_observed_in_code": false
+ },
{
"name": "hypothesis/review/request",
"namespace": "hypothesis",
@@ -3878,6 +4067,33 @@
],
"mutation_observed_in_code": true
},
+ {
+ "name": "hypothesis/same/record",
+ "namespace": "hypothesis",
+ "surface": "COMMAND",
+ "canonical_owner": "HYPOTHESIS",
+ "policy": "hypothesis:versioned-policy",
+ "normal_entrypoint": "thread/input",
+ "behavioral_evidence": [
+ "A01",
+ "A03"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED",
+ "method": "hypothesis/same/record",
+ "actual_callable": "thoth.application.commands.hypothesis_same.SameHandlers.record",
+ "source": "src/thoth/application/commands/hypothesis_same.py",
+ "line": 50,
+ "callable_sha256": "8e563f96a18bd74e0948896f61e71dc56c92c733a2b0462ce92086a319a6699f",
+ "entry_kind": "public",
+ "path_id": "rpc:hypothesis/same/record",
+ "owner_scope": "HYPOTHESIS",
+ "phase_ids": [
+ "rpc:hypothesis/same/record"
+ ],
+ "mutation_observed_in_code": true
+ },
{
"name": "hypothesis/split/propose",
"namespace": "hypothesis",
@@ -9194,6 +9410,240 @@
],
"mutation_observed_in_code": true
},
+ {
+ "name": "trace/confirm",
+ "namespace": "trace",
+ "surface": "COMMAND",
+ "canonical_owner": "VERIFICATION_TRACE",
+ "policy": "trace:versioned-policy",
+ "normal_entrypoint": "trace/read|trace/importApply",
+ "behavioral_evidence": [
+ "B3"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED",
+ "method": "trace/confirm",
+ "actual_callable": "thoth.application.commands.verification_trace.TraceHandlers.confirm",
+ "source": "src/thoth/application/commands/verification_trace.py",
+ "line": 172,
+ "callable_sha256": "7f9c1c45756db4e4ec3ac97e60ff5ff0500fba5cd0a8c543160e4d9bccc31287",
+ "entry_kind": "public",
+ "path_id": "rpc:trace/confirm",
+ "owner_scope": "SHARED_OR_OTHER_OWNER",
+ "phase_ids": [
+ "rpc:trace/confirm"
+ ],
+ "mutation_observed_in_code": true
+ },
+ {
+ "name": "trace/closure/record",
+ "namespace": "trace",
+ "surface": "COMMAND",
+ "canonical_owner": "VERIFICATION_TRACE",
+ "policy": "trace:versioned-policy",
+ "normal_entrypoint": "trace/read|trace/importApply",
+ "behavioral_evidence": [
+ "B3"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED",
+ "method": "trace/closure/record",
+ "actual_callable": "thoth.application.commands.trace_closure.ClosureHandlers.record",
+ "source": "src/thoth/application/commands/trace_closure.py",
+ "line": 58,
+ "callable_sha256": "163c951da492107a86e51b02abde70c120f1add2af00b194c6bca58e233e1007",
+ "entry_kind": "public",
+ "path_id": "rpc:trace/closure/record",
+ "owner_scope": "SHARED_OR_OTHER_OWNER",
+ "phase_ids": [
+ "rpc:trace/closure/record"
+ ],
+ "mutation_observed_in_code": true
+ },
+ {
+ "name": "trace/export",
+ "namespace": "trace",
+ "surface": "QUERY",
+ "canonical_owner": "VERIFICATION_TRACE",
+ "policy": "trace:versioned-policy",
+ "normal_entrypoint": "trace/read|trace/importApply",
+ "behavioral_evidence": [
+ "B3"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED",
+ "method": "trace/export",
+ "actual_callable": "thoth.application.commands.verification_trace.TraceHandlers.export",
+ "source": "src/thoth/application/commands/verification_trace.py",
+ "line": 135,
+ "callable_sha256": "550494de063eaf5a0808eca4331cac1f11c3e67ac57e1fb38842b12952aa619a",
+ "entry_kind": "public",
+ "path_id": "rpc:trace/export",
+ "owner_scope": "SHARED_OR_OTHER_OWNER",
+ "phase_ids": [
+ "rpc:trace/export"
+ ],
+ "mutation_observed_in_code": false
+ },
+ {
+ "name": "trace/history",
+ "namespace": "trace",
+ "surface": "QUERY",
+ "canonical_owner": "VERIFICATION_TRACE",
+ "policy": "trace:versioned-policy",
+ "normal_entrypoint": "trace/read|trace/importApply",
+ "behavioral_evidence": [
+ "B3"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED",
+ "method": "trace/history",
+ "actual_callable": "thoth.application.commands.verification_trace.TraceHandlers.history",
+ "source": "src/thoth/application/commands/verification_trace.py",
+ "line": 109,
+ "callable_sha256": "fd843fb6d68eccc661c8af5a988672c84e320f04fd5420ebb760401b4123b823",
+ "entry_kind": "public",
+ "path_id": "rpc:trace/history",
+ "owner_scope": "SHARED_OR_OTHER_OWNER",
+ "phase_ids": [
+ "rpc:trace/history"
+ ],
+ "mutation_observed_in_code": false
+ },
+ {
+ "name": "trace/importApply",
+ "namespace": "trace",
+ "surface": "COMMAND",
+ "canonical_owner": "VERIFICATION_TRACE",
+ "policy": "trace:versioned-policy",
+ "normal_entrypoint": "trace/read|trace/importApply",
+ "behavioral_evidence": [
+ "B3"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED",
+ "method": "trace/importApply",
+ "actual_callable": "thoth.application.commands.verification_trace.TraceHandlers.import_apply",
+ "source": "src/thoth/application/commands/verification_trace.py",
+ "line": 156,
+ "callable_sha256": "0ff4ada9361e293d146f69eacd12d96a77dfba357eeca44f00f59671c03eb384",
+ "entry_kind": "public",
+ "path_id": "rpc:trace/importApply",
+ "owner_scope": "SHARED_OR_OTHER_OWNER",
+ "phase_ids": [
+ "rpc:trace/importApply"
+ ],
+ "mutation_observed_in_code": true
+ },
+ {
+ "name": "trace/importPreview",
+ "namespace": "trace",
+ "surface": "QUERY",
+ "canonical_owner": "VERIFICATION_TRACE",
+ "policy": "trace:versioned-policy",
+ "normal_entrypoint": "trace/read|trace/importApply",
+ "behavioral_evidence": [
+ "B3"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED",
+ "method": "trace/importPreview",
+ "actual_callable": "thoth.application.commands.verification_trace.TraceHandlers.import_preview",
+ "source": "src/thoth/application/commands/verification_trace.py",
+ "line": 150,
+ "callable_sha256": "d43512dc1699c40604c287764e974c519801e8930352ef0d1158ab64523e2dab",
+ "entry_kind": "public",
+ "path_id": "rpc:trace/importPreview",
+ "owner_scope": "SHARED_OR_OTHER_OWNER",
+ "phase_ids": [
+ "rpc:trace/importPreview"
+ ],
+ "mutation_observed_in_code": false
+ },
+ {
+ "name": "trace/read",
+ "namespace": "trace",
+ "surface": "QUERY",
+ "canonical_owner": "VERIFICATION_TRACE",
+ "policy": "trace:versioned-policy",
+ "normal_entrypoint": "trace/read|trace/importApply",
+ "behavioral_evidence": [
+ "B3"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED",
+ "method": "trace/read",
+ "actual_callable": "thoth.application.commands.verification_trace.TraceHandlers.read",
+ "source": "src/thoth/application/commands/verification_trace.py",
+ "line": 104,
+ "callable_sha256": "741009fdcf47c3a86e2184651159ddfa70d753ba822700d0531174d414ec2d1e",
+ "entry_kind": "public",
+ "path_id": "rpc:trace/read",
+ "owner_scope": "SHARED_OR_OTHER_OWNER",
+ "phase_ids": [
+ "rpc:trace/read"
+ ],
+ "mutation_observed_in_code": false
+ },
+ {
+ "name": "trace/closure/list",
+ "namespace": "trace",
+ "surface": "QUERY",
+ "canonical_owner": "VERIFICATION_TRACE",
+ "policy": "trace:versioned-policy",
+ "normal_entrypoint": "trace/read|trace/importApply",
+ "behavioral_evidence": [
+ "B3"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED",
+ "method": "trace/closure/list",
+ "actual_callable": "thoth.application.commands.trace_closure.ClosureHandlers.list",
+ "source": "src/thoth/application/commands/trace_closure.py",
+ "line": 53,
+ "callable_sha256": "5319e411d67ef2719042ace425cd9ba9156832e57be9f23d8234f8c5c51c7702",
+ "entry_kind": "public",
+ "path_id": "rpc:trace/closure/list",
+ "owner_scope": "SHARED_OR_OTHER_OWNER",
+ "phase_ids": [
+ "rpc:trace/closure/list"
+ ],
+ "mutation_observed_in_code": false
+ },
+ {
+ "name": "trace/lesson/list",
+ "namespace": "trace",
+ "surface": "QUERY",
+ "canonical_owner": "VERIFICATION_TRACE",
+ "policy": "trace:versioned-policy",
+ "normal_entrypoint": "trace/read|trace/importApply",
+ "behavioral_evidence": [
+ "B3"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED",
+ "method": "trace/lesson/list",
+ "actual_callable": "thoth.application.commands.lesson.LessonHandlers.list",
+ "source": "src/thoth/application/commands/lesson.py",
+ "line": 32,
+ "callable_sha256": "8c800d28257a1e4470d3a0e0a69fde36b99a59f8e909a09a3ec2842baa07ae76",
+ "entry_kind": "public",
+ "path_id": "rpc:trace/lesson/list",
+ "owner_scope": "SHARED_OR_OTHER_OWNER",
+ "phase_ids": [
+ "rpc:trace/lesson/list"
+ ],
+ "mutation_observed_in_code": false
+ },
{
"name": "workspace/ready",
"namespace": "workspace",
@@ -10214,6 +10664,159 @@
]
}
},
+ {
+ "path_id": "rpc:action/draft/fromTest",
+ "owner": "ACTION_PLAN_AUTHORIZATION",
+ "catalog_owner": "ACTION_PLAN_AUTHORIZATION",
+ "entry_kind": "public",
+ "method_or_trigger": "action/draft/fromTest",
+ "actual_callable": "thoth.application.commands.action_from_test.ActionFromTestHandlers.draft",
+ "caller_refs": [
+ {
+ "source": "src/thoth/apps/action_draft_composition.py",
+ "line": 16,
+ "binding": "handlers.draft"
+ }
+ ],
+ "producer_refs": [
+ "thoth.adapters.storage.action.SqliteActionStore._add",
+ "thoth.adapters.storage.action.SqliteActionStore.append_audit",
+ "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.apply_impact_plan",
+ "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.insert_receipt",
+ "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.insert_revision",
+ "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.insert_snapshot",
+ "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.set_head"
+ ],
+ "phase_id": [
+ "PUBLICATION"
+ ],
+ "composite_not_one_transaction": false,
+ "canonical_records_and_projections": [
+ "action_audit",
+ "action_records",
+ "dependency_states",
+ "entity_snapshots",
+ "receipts",
+ "revision_parents",
+ "semantic_revisions",
+ "working_heads"
+ ],
+ "head_selector": "Existing typed owner/current revision selector; see owner contract and concrete producer",
+ "audit_receipt_refs": [
+ "action_audit",
+ "receipts"
+ ],
+ "expected_basis": "A draft action request composed from one stored discriminating test is created by calling action/create, so the write is the same single ledger commit; the risk comes from the declared effects and the rules, no model is called, and a stale hypothesis is refused before anything is written.",
+ "uow_participants": [
+ "thoth.adapters.storage.action.SqliteActionStore._add",
+ "thoth.adapters.storage.action.SqliteActionStore.append_audit",
+ "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.apply_impact_plan",
+ "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.insert_receipt",
+ "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.insert_revision",
+ "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.insert_snapshot",
+ "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.set_head"
+ ],
+ "irreversible_effect_boundary": "Explicit IO phases where listed; code traversal does not treat a whole research cycle as one transaction",
+ "allowed_phase_deltas": [
+ "Only exact request/operation/attempt identities and stated transitions; tests/atomicity/harness.py validates error bindings and declared row witnesses. No whole table exclusions."
+ ],
+ "downstream_reader": [
+ "action/audit/read",
+ "action/authorization/read",
+ "action/impact/read",
+ "action/list",
+ "action/plan/graph",
+ "action/plan/read",
+ "action/policy/read",
+ "action/portfolio/list",
+ "action/portfolio/read",
+ "action/read",
+ "action/step/read"
+ ],
+ "existing_tests": [
+ "tests/integration/test_hypothesis_action_guard.py"
+ ],
+ "fault_points": "The write is the one action/create commit; a failure writes nothing.",
+ "disposition": "OWNER_EVIDENCE_PENDING",
+ "evidence_refs": [],
+ "source_refs": [
+ "src/thoth/adapters/storage/action.py",
+ "src/thoth/adapters/storage/artifacts.py",
+ "src/thoth/adapters/storage/auth.py",
+ "src/thoth/adapters/storage/control_record.py",
+ "src/thoth/adapters/storage/criterion_contract.py",
+ "src/thoth/adapters/storage/decision_object.py",
+ "src/thoth/adapters/storage/events.py",
+ "src/thoth/adapters/storage/evidence_graph.py",
+ "src/thoth/adapters/storage/execution.py",
+ "src/thoth/adapters/storage/governance.py",
+ "src/thoth/adapters/storage/governance_history.py",
+ "src/thoth/adapters/storage/hypothesis.py",
+ "src/thoth/adapters/storage/investigation.py",
+ "src/thoth/adapters/storage/operations.py",
+ "src/thoth/adapters/storage/outcome.py",
+ "src/thoth/adapters/storage/research_identity.py",
+ "src/thoth/adapters/storage/resource_scope.py",
+ "src/thoth/adapters/storage/sqlite.py",
+ "src/thoth/adapters/storage/thread_runtime.py",
+ "src/thoth/adapters/storage/transaction.py",
+ "src/thoth/application/commands/action_from_test.py",
+ "src/thoth/application/commands/actions_full.py",
+ "src/thoth/application/services/action_from_test.py",
+ "src/thoth/application/services/action_service.py",
+ "src/thoth/application/services/resource_scope_service.py",
+ "src/thoth/application/services/revision_service.py",
+ "src/thoth/application/services/scoped_artifacts.py",
+ "src/thoth/apps/projectpack_execution.py"
+ ],
+ "atomic_regions": [
+ {
+ "callable": "thoth.adapters.storage.sqlite.SqliteLedger.transaction",
+ "source": "src/thoth/adapters/storage/sqlite.py",
+ "line": 242,
+ "contexts": [
+ "SqliteAtomicUnitOfWork(self._engine, immediate=True).transaction()"
+ ]
+ },
+ {
+ "callable": "thoth.adapters.storage.transaction.SqliteAtomicUnitOfWork.transaction",
+ "source": "src/thoth/adapters/storage/transaction.py",
+ "line": 77,
+ "contexts": [
+ "resource_use_transaction()"
+ ]
+ },
+ {
+ "callable": "thoth.application.services.action_service.ActionService._persist_action",
+ "source": "src/thoth/application/services/action_service.py",
+ "line": 657,
+ "contexts": [
+ "self._semantic_uow.transaction()"
+ ]
+ },
+ {
+ "callable": "thoth.application.services.resource_scope_service.ResourceScopeService.require_reads",
+ "source": "src/thoth/application/services/resource_scope_service.py",
+ "line": 488,
+ "contexts": [
+ "self._ledger.transaction()"
+ ]
+ },
+ {
+ "callable": "thoth.application.services.revision_service.RevisionCommitService.commit",
+ "source": "src/thoth/application/services/revision_service.py",
+ "line": 53,
+ "contexts": [
+ "self._ledger.transaction()"
+ ]
+ }
+ ],
+ "entry_is_not_a_transaction": true,
+ "region_semantics": "Reachable delegated transaction regions; this entry-level inventory is not a claim that the whole command/IO cycle is one atomic transaction.",
+ "blockers": [
+ "New in the test-to-action stage; no owner closure receipt."
+ ]
+ },
{
"path_id": "rpc:action/generate",
"owner": "ACTION_PLAN_AUTHORIZATION",
@@ -28914,204 +29517,254 @@
]
},
{
- "path_id": "rpc:hypothesis/merge/propose",
+ "path_id": "rpc:hypothesis/same/list",
"owner": "HYPOTHESIS",
"catalog_owner": "HYPOTHESIS",
"entry_kind": "public",
- "method_or_trigger": "hypothesis/merge/propose",
- "actual_callable": "thoth.application.commands.hypotheses_full.HypothesisHandlers.merge_propose",
+ "method_or_trigger": "hypothesis/same/list",
+ "actual_callable": "thoth.application.commands.hypothesis_same.SameHandlers.list",
"caller_refs": [
{
- "source": "src/thoth/apps/decision_chain_routes.py",
- "line": 51,
- "binding": "hypothesis_handlers.merge_propose"
+ "source": "src/thoth/apps/judgment_records_composition.py",
+ "line": 35,
+ "binding": "same.list"
}
],
- "producer_refs": [
- "thoth.adapters.storage.hypothesis.SqliteHypothesisStore.append_audit"
- ],
+ "producer_refs": [],
"phase_id": [
- "PUBLICATION"
+ "READ_ONLY"
],
"composite_not_one_transaction": false,
- "canonical_records_and_projections": [
- "hypothesis_audit"
- ],
+ "canonical_records_and_projections": [],
"head_selector": "Existing typed owner/current revision selector; see owner contract and concrete producer",
- "audit_receipt_refs": [
- "hypothesis_audit"
- ],
- "expected_basis": "Generation batches, portfolio/auxiliary/prediction/appraisal/validity and normal-cycle identities retain exact canonical revisions; denied replay preserves its original operation.",
- "uow_participants": [
- "thoth.adapters.storage.hypothesis.SqliteHypothesisStore.append_audit"
- ],
+ "audit_receipt_refs": [],
+ "expected_basis": "The current groups of hypotheses a person marked as the same, with what a screen shows about each member, read from committed records. A read never writes.",
+ "uow_participants": [],
"irreversible_effect_boundary": "Explicit IO phases where listed; code traversal does not treat a whole research cycle as one transaction",
"allowed_phase_deltas": [
"Only exact request/operation/attempt identities and stated transitions; tests/atomicity/harness.py validates error bindings and declared row witnesses. No whole table exclusions."
],
"downstream_reader": [
- "hypothesis/appraisal/read",
- "hypothesis/assumption/list",
- "hypothesis/audit/read",
- "hypothesis/graph/read",
- "hypothesis/list",
- "hypothesis/portfolio/list",
- "hypothesis/portfolio/read",
- "hypothesis/prediction/list",
- "hypothesis/prediction/read",
- "hypothesis/quality/read",
- "hypothesis/read",
- "hypothesis/relation/list"
+ "apps/web/src/components/SameHypothesis.tsx"
],
"existing_tests": [
- "tests/integration/test_a03_storage_atomicity.py",
- "tests/integration/test_a03_portfolio_atomicity.py",
- "tests/integration/test_hypothesis_prediction_outcome_cycle.py",
- "tests/integration/test_research_replay_ownership.py",
- "tests/integration/test_research_boundaries_v2.py"
+ "tests/unit/domain/test_hypothesis_same_group.py",
+ "tests/integration/test_hypothesis_same.py"
],
- "fault_points": "See exact owner tests; stage failures, CAS, reopen and consumer assertions are distinct",
- "disposition": "OWNER_EVIDENCE_PENDING",
+ "fault_points": "See exact owner tests.",
+ "disposition": "READ_ONLY_REVIEWED",
"evidence_refs": [],
"source_refs": [
- "src/thoth/adapters/storage/action.py",
- "src/thoth/adapters/storage/artifacts.py",
- "src/thoth/adapters/storage/auth.py",
- "src/thoth/adapters/storage/control_record.py",
- "src/thoth/adapters/storage/criterion_contract.py",
- "src/thoth/adapters/storage/decision_object.py",
- "src/thoth/adapters/storage/evidence_graph.py",
- "src/thoth/adapters/storage/execution.py",
- "src/thoth/adapters/storage/governance.py",
- "src/thoth/adapters/storage/governance_history.py",
- "src/thoth/adapters/storage/hypothesis.py",
- "src/thoth/adapters/storage/operations.py",
- "src/thoth/adapters/storage/outcome.py",
- "src/thoth/adapters/storage/research_identity.py",
- "src/thoth/adapters/storage/resource_scope.py",
- "src/thoth/adapters/storage/sqlite.py",
- "src/thoth/adapters/storage/transaction.py",
- "src/thoth/application/commands/hypotheses_full.py",
- "src/thoth/application/services/hypothesis_service.py",
- "src/thoth/application/services/resource_scope_service.py"
- ],
- "atomic_regions": [
- {
- "callable": "thoth.adapters.storage.sqlite.SqliteLedger.transaction",
- "source": "src/thoth/adapters/storage/sqlite.py",
- "line": 242,
- "contexts": [
- "SqliteAtomicUnitOfWork(self._engine, immediate=True).transaction()"
- ]
- },
- {
- "callable": "thoth.adapters.storage.transaction.SqliteAtomicUnitOfWork.transaction",
- "source": "src/thoth/adapters/storage/transaction.py",
- "line": 77,
- "contexts": [
- "resource_use_transaction()"
- ]
- },
- {
- "callable": "thoth.application.services.resource_scope_service.ResourceScopeService.require_reads",
- "source": "src/thoth/application/services/resource_scope_service.py",
- "line": 488,
- "contexts": [
- "self._ledger.transaction()"
- ]
- }
+ "src/thoth/application/commands/hypothesis_same.py",
+ "src/thoth/application/services/hypothesis_same.py",
+ "src/thoth/domain/hypothesis_same.py"
],
+ "atomic_regions": [],
"entry_is_not_a_transaction": true,
"region_semantics": "Reachable delegated transaction regions; this entry-level inventory is not a claim that the whole command/IO cycle is one atomic transaction.",
- "verification": {
- "classification": "MUTATION",
- "required_callables": [
- "thoth.adapters.storage.hypothesis.SqliteHypothesisStore.append_audit",
- "thoth.application.commands.hypotheses_full.HypothesisHandlers.merge_propose"
- ],
- "cases": [
- {
- "nodeid": "tests/integration/test_hypothesis_full_rpc.py::test_hypothesis_prediction_validity_appraisal_and_portfolio",
- "scenario": "test_hypothesis_prediction_validity_appraisal_and_portfolio",
- "state_assertion_excerpt": [
- "assert len(generated) == 3",
- "assert unknown[\"primary_intent\"] == \"EXPLORATORY\"",
- "assert explicit[\"development_stage\"] == \"GROUNDED_CANDIDATE\"",
- "assert causal_not_applicable[\"applicability\"] == \"NOT_APPLICABLE\"",
- "assert first[\"development_stage\"] == \"COUNTEREVIDENCE_CHECKED\"",
- "assert first[\"development_stage\"] == \"PREDICTION_BOUND\"",
- "assert response.error is not None",
- "assert \"TEST_VALIDITY_PRODUCER_NOT_FOUND\" in response.error.message",
- "assert (",
- "assert first[\"empirical_appraisal\"] == \"UNASSESSED\""
- ],
- "test_source_sha256": "ae4dd44ee9cc3ac98085594db06683bbc93dffe0deeeeba75c2442161c52edb9",
- "observed_callables": [
- "thoth.application.commands.hypotheses_full.HypothesisHandlers.merge_propose"
- ],
- "execution_records": [
- {
- "file": "outputs/owner-atomicity-20260914/owner-final-02/junit.xml",
- "sha256": "387eec1d7c5901a1e8c2d758ac8c2bd4c70c2a09d7f72d8b4d102f33da374733",
- "scope": "HISTORICAL_PRE_FIX_FAILED_RUN",
- "result": "PASS"
- }
- ]
- }
- ]
- }
+ "blockers": [
+ "New in the follow-up stage; no owner closure receipt."
+ ]
},
{
- "path_id": "rpc:hypothesis/portfolio/compose",
+ "path_id": "rpc:hypothesis/merge/propose",
"owner": "HYPOTHESIS",
"catalog_owner": "HYPOTHESIS",
"entry_kind": "public",
- "method_or_trigger": "hypothesis/portfolio/compose",
- "actual_callable": "thoth.application.commands.hypotheses_full.HypothesisHandlers.portfolio_compose",
+ "method_or_trigger": "hypothesis/merge/propose",
+ "actual_callable": "thoth.application.commands.hypotheses_full.HypothesisHandlers.merge_propose",
"caller_refs": [
{
"source": "src/thoth/apps/decision_chain_routes.py",
- "line": 44,
- "binding": "hypothesis_handlers.portfolio_compose"
+ "line": 51,
+ "binding": "hypothesis_handlers.merge_propose"
}
],
"producer_refs": [
- "thoth.adapters.storage.hypothesis.SqliteHypothesisStore._add",
- "thoth.adapters.storage.hypothesis.SqliteHypothesisStore.append_audit",
- "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.apply_impact_plan",
- "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.insert_receipt",
- "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.insert_revision",
- "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.insert_snapshot",
- "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.set_head"
+ "thoth.adapters.storage.hypothesis.SqliteHypothesisStore.append_audit"
],
"phase_id": [
"PUBLICATION"
],
"composite_not_one_transaction": false,
"canonical_records_and_projections": [
- "dependency_states",
- "entity_snapshots",
- "hypothesis_audit",
- "hypothesis_portfolios",
- "receipts",
- "revision_parents",
- "semantic_revisions",
- "working_heads"
+ "hypothesis_audit"
],
"head_selector": "Existing typed owner/current revision selector; see owner contract and concrete producer",
"audit_receipt_refs": [
- "hypothesis_audit",
- "receipts"
+ "hypothesis_audit"
],
"expected_basis": "Generation batches, portfolio/auxiliary/prediction/appraisal/validity and normal-cycle identities retain exact canonical revisions; denied replay preserves its original operation.",
"uow_participants": [
- "thoth.adapters.storage.hypothesis.SqliteHypothesisStore._add",
- "thoth.adapters.storage.hypothesis.SqliteHypothesisStore.append_audit",
- "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.apply_impact_plan",
- "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.insert_receipt",
- "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.insert_revision",
- "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.insert_snapshot",
- "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.set_head"
+ "thoth.adapters.storage.hypothesis.SqliteHypothesisStore.append_audit"
+ ],
+ "irreversible_effect_boundary": "Explicit IO phases where listed; code traversal does not treat a whole research cycle as one transaction",
+ "allowed_phase_deltas": [
+ "Only exact request/operation/attempt identities and stated transitions; tests/atomicity/harness.py validates error bindings and declared row witnesses. No whole table exclusions."
+ ],
+ "downstream_reader": [
+ "hypothesis/appraisal/read",
+ "hypothesis/assumption/list",
+ "hypothesis/audit/read",
+ "hypothesis/graph/read",
+ "hypothesis/list",
+ "hypothesis/portfolio/list",
+ "hypothesis/portfolio/read",
+ "hypothesis/prediction/list",
+ "hypothesis/prediction/read",
+ "hypothesis/quality/read",
+ "hypothesis/read",
+ "hypothesis/relation/list"
+ ],
+ "existing_tests": [
+ "tests/integration/test_a03_storage_atomicity.py",
+ "tests/integration/test_a03_portfolio_atomicity.py",
+ "tests/integration/test_hypothesis_prediction_outcome_cycle.py",
+ "tests/integration/test_research_replay_ownership.py",
+ "tests/integration/test_research_boundaries_v2.py"
+ ],
+ "fault_points": "See exact owner tests; stage failures, CAS, reopen and consumer assertions are distinct",
+ "disposition": "OWNER_EVIDENCE_PENDING",
+ "evidence_refs": [],
+ "source_refs": [
+ "src/thoth/adapters/storage/action.py",
+ "src/thoth/adapters/storage/artifacts.py",
+ "src/thoth/adapters/storage/auth.py",
+ "src/thoth/adapters/storage/control_record.py",
+ "src/thoth/adapters/storage/criterion_contract.py",
+ "src/thoth/adapters/storage/decision_object.py",
+ "src/thoth/adapters/storage/evidence_graph.py",
+ "src/thoth/adapters/storage/execution.py",
+ "src/thoth/adapters/storage/governance.py",
+ "src/thoth/adapters/storage/governance_history.py",
+ "src/thoth/adapters/storage/hypothesis.py",
+ "src/thoth/adapters/storage/operations.py",
+ "src/thoth/adapters/storage/outcome.py",
+ "src/thoth/adapters/storage/research_identity.py",
+ "src/thoth/adapters/storage/resource_scope.py",
+ "src/thoth/adapters/storage/sqlite.py",
+ "src/thoth/adapters/storage/transaction.py",
+ "src/thoth/application/commands/hypotheses_full.py",
+ "src/thoth/application/services/hypothesis_service.py",
+ "src/thoth/application/services/resource_scope_service.py"
+ ],
+ "atomic_regions": [
+ {
+ "callable": "thoth.adapters.storage.sqlite.SqliteLedger.transaction",
+ "source": "src/thoth/adapters/storage/sqlite.py",
+ "line": 242,
+ "contexts": [
+ "SqliteAtomicUnitOfWork(self._engine, immediate=True).transaction()"
+ ]
+ },
+ {
+ "callable": "thoth.adapters.storage.transaction.SqliteAtomicUnitOfWork.transaction",
+ "source": "src/thoth/adapters/storage/transaction.py",
+ "line": 77,
+ "contexts": [
+ "resource_use_transaction()"
+ ]
+ },
+ {
+ "callable": "thoth.application.services.resource_scope_service.ResourceScopeService.require_reads",
+ "source": "src/thoth/application/services/resource_scope_service.py",
+ "line": 488,
+ "contexts": [
+ "self._ledger.transaction()"
+ ]
+ }
+ ],
+ "entry_is_not_a_transaction": true,
+ "region_semantics": "Reachable delegated transaction regions; this entry-level inventory is not a claim that the whole command/IO cycle is one atomic transaction.",
+ "verification": {
+ "classification": "MUTATION",
+ "required_callables": [
+ "thoth.adapters.storage.hypothesis.SqliteHypothesisStore.append_audit",
+ "thoth.application.commands.hypotheses_full.HypothesisHandlers.merge_propose"
+ ],
+ "cases": [
+ {
+ "nodeid": "tests/integration/test_hypothesis_full_rpc.py::test_hypothesis_prediction_validity_appraisal_and_portfolio",
+ "scenario": "test_hypothesis_prediction_validity_appraisal_and_portfolio",
+ "state_assertion_excerpt": [
+ "assert len(generated) == 3",
+ "assert unknown[\"primary_intent\"] == \"EXPLORATORY\"",
+ "assert explicit[\"development_stage\"] == \"GROUNDED_CANDIDATE\"",
+ "assert causal_not_applicable[\"applicability\"] == \"NOT_APPLICABLE\"",
+ "assert first[\"development_stage\"] == \"COUNTEREVIDENCE_CHECKED\"",
+ "assert first[\"development_stage\"] == \"PREDICTION_BOUND\"",
+ "assert response.error is not None",
+ "assert \"TEST_VALIDITY_PRODUCER_NOT_FOUND\" in response.error.message",
+ "assert (",
+ "assert first[\"empirical_appraisal\"] == \"UNASSESSED\""
+ ],
+ "test_source_sha256": "ae4dd44ee9cc3ac98085594db06683bbc93dffe0deeeeba75c2442161c52edb9",
+ "observed_callables": [
+ "thoth.application.commands.hypotheses_full.HypothesisHandlers.merge_propose"
+ ],
+ "execution_records": [
+ {
+ "file": "outputs/owner-atomicity-20260914/owner-final-02/junit.xml",
+ "sha256": "387eec1d7c5901a1e8c2d758ac8c2bd4c70c2a09d7f72d8b4d102f33da374733",
+ "scope": "HISTORICAL_PRE_FIX_FAILED_RUN",
+ "result": "PASS"
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "path_id": "rpc:hypothesis/portfolio/compose",
+ "owner": "HYPOTHESIS",
+ "catalog_owner": "HYPOTHESIS",
+ "entry_kind": "public",
+ "method_or_trigger": "hypothesis/portfolio/compose",
+ "actual_callable": "thoth.application.commands.hypotheses_full.HypothesisHandlers.portfolio_compose",
+ "caller_refs": [
+ {
+ "source": "src/thoth/apps/decision_chain_routes.py",
+ "line": 44,
+ "binding": "hypothesis_handlers.portfolio_compose"
+ }
+ ],
+ "producer_refs": [
+ "thoth.adapters.storage.hypothesis.SqliteHypothesisStore._add",
+ "thoth.adapters.storage.hypothesis.SqliteHypothesisStore.append_audit",
+ "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.apply_impact_plan",
+ "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.insert_receipt",
+ "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.insert_revision",
+ "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.insert_snapshot",
+ "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.set_head"
+ ],
+ "phase_id": [
+ "PUBLICATION"
+ ],
+ "composite_not_one_transaction": false,
+ "canonical_records_and_projections": [
+ "dependency_states",
+ "entity_snapshots",
+ "hypothesis_audit",
+ "hypothesis_portfolios",
+ "receipts",
+ "revision_parents",
+ "semantic_revisions",
+ "working_heads"
+ ],
+ "head_selector": "Existing typed owner/current revision selector; see owner contract and concrete producer",
+ "audit_receipt_refs": [
+ "hypothesis_audit",
+ "receipts"
+ ],
+ "expected_basis": "Generation batches, portfolio/auxiliary/prediction/appraisal/validity and normal-cycle identities retain exact canonical revisions; denied replay preserves its original operation.",
+ "uow_participants": [
+ "thoth.adapters.storage.hypothesis.SqliteHypothesisStore._add",
+ "thoth.adapters.storage.hypothesis.SqliteHypothesisStore.append_audit",
+ "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.apply_impact_plan",
+ "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.insert_receipt",
+ "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.insert_revision",
+ "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.insert_snapshot",
+ "thoth.adapters.storage.sqlite.SqliteLedgerTransaction.set_head"
],
"irreversible_effect_boundary": "Explicit IO phases where listed; code traversal does not treat a whole research cycle as one transaction",
"allowed_phase_deltas": [
@@ -31096,182 +31749,239 @@
}
},
{
- "path_id": "rpc:hypothesis/split/propose",
+ "path_id": "rpc:hypothesis/same/record",
"owner": "HYPOTHESIS",
"catalog_owner": "HYPOTHESIS",
"entry_kind": "public",
- "method_or_trigger": "hypothesis/split/propose",
- "actual_callable": "thoth.application.commands.hypotheses_full.HypothesisHandlers.split_propose",
+ "method_or_trigger": "hypothesis/same/record",
+ "actual_callable": "thoth.application.commands.hypothesis_same.SameHandlers.record",
"caller_refs": [
{
- "source": "src/thoth/apps/decision_chain_routes.py",
- "line": 50,
- "binding": "hypothesis_handlers.split_propose"
+ "source": "src/thoth/apps/judgment_records_composition.py",
+ "line": 36,
+ "binding": "same.record"
}
],
"producer_refs": [
- "thoth.adapters.storage.hypothesis.SqliteHypothesisStore.append_audit"
+ "thoth.application.services.hypothesis_same.HypothesisSame.record"
],
"phase_id": [
"PUBLICATION"
],
"composite_not_one_transaction": false,
"canonical_records_and_projections": [
- "hypothesis_audit"
+ "semantic_revisions (THREAD:hypothesis-discrimination)",
+ "entity_snapshots"
],
"head_selector": "Existing typed owner/current revision selector; see owner contract and concrete producer",
- "audit_receipt_refs": [
- "hypothesis_audit"
- ],
- "expected_basis": "Generation batches, portfolio/auxiliary/prediction/appraisal/validity and normal-cycle identities retain exact canonical revisions; denied replay preserves its original operation.",
+ "audit_receipt_refs": [],
+ "expected_basis": "A person's mark that two hypotheses of different investigations are the same (or the taking back of it) is appended to the project's same-hypothesis record in one ledger commit; earlier marks are kept, neither hypothesis is changed and no model is called.",
"uow_participants": [
- "thoth.adapters.storage.hypothesis.SqliteHypothesisStore.append_audit"
+ "thoth.application.services.request_records.RequestRecords.save"
],
- "irreversible_effect_boundary": "Explicit IO phases where listed; code traversal does not treat a whole research cycle as one transaction",
+ "irreversible_effect_boundary": "No external effect and no model call.",
"allowed_phase_deltas": [
"Only exact request/operation/attempt identities and stated transitions; tests/atomicity/harness.py validates error bindings and declared row witnesses. No whole table exclusions."
],
"downstream_reader": [
- "hypothesis/appraisal/read",
- "hypothesis/assumption/list",
- "hypothesis/audit/read",
- "hypothesis/graph/read",
- "hypothesis/list",
- "hypothesis/portfolio/list",
- "hypothesis/portfolio/read",
- "hypothesis/prediction/list",
- "hypothesis/prediction/read",
- "hypothesis/quality/read",
- "hypothesis/read",
- "hypothesis/relation/list"
+ "apps/web/src/components/SameHypothesis.tsx"
],
"existing_tests": [
- "tests/integration/test_a03_storage_atomicity.py",
- "tests/integration/test_a03_portfolio_atomicity.py",
- "tests/integration/test_hypothesis_prediction_outcome_cycle.py",
- "tests/integration/test_research_replay_ownership.py",
- "tests/integration/test_research_boundaries_v2.py"
+ "tests/unit/domain/test_hypothesis_same_group.py",
+ "tests/integration/test_hypothesis_same.py"
],
- "fault_points": "See exact owner tests; stage failures, CAS, reopen and consumer assertions are distinct",
+ "fault_points": "The write is one ledger transaction; a failure writes nothing.",
"disposition": "OWNER_EVIDENCE_PENDING",
"evidence_refs": [],
"source_refs": [
- "src/thoth/adapters/storage/action.py",
- "src/thoth/adapters/storage/artifacts.py",
- "src/thoth/adapters/storage/auth.py",
- "src/thoth/adapters/storage/control_record.py",
- "src/thoth/adapters/storage/criterion_contract.py",
- "src/thoth/adapters/storage/decision_object.py",
- "src/thoth/adapters/storage/evidence_graph.py",
- "src/thoth/adapters/storage/execution.py",
- "src/thoth/adapters/storage/governance.py",
- "src/thoth/adapters/storage/governance_history.py",
- "src/thoth/adapters/storage/hypothesis.py",
- "src/thoth/adapters/storage/operations.py",
- "src/thoth/adapters/storage/outcome.py",
- "src/thoth/adapters/storage/research_identity.py",
- "src/thoth/adapters/storage/resource_scope.py",
- "src/thoth/adapters/storage/sqlite.py",
- "src/thoth/adapters/storage/transaction.py",
- "src/thoth/application/commands/hypotheses_full.py",
- "src/thoth/application/services/hypothesis_service.py",
- "src/thoth/application/services/resource_scope_service.py"
- ],
- "atomic_regions": [
- {
- "callable": "thoth.adapters.storage.sqlite.SqliteLedger.transaction",
- "source": "src/thoth/adapters/storage/sqlite.py",
- "line": 242,
- "contexts": [
- "SqliteAtomicUnitOfWork(self._engine, immediate=True).transaction()"
- ]
- },
- {
- "callable": "thoth.adapters.storage.transaction.SqliteAtomicUnitOfWork.transaction",
- "source": "src/thoth/adapters/storage/transaction.py",
- "line": 77,
- "contexts": [
- "resource_use_transaction()"
- ]
- },
- {
- "callable": "thoth.application.services.resource_scope_service.ResourceScopeService.require_reads",
- "source": "src/thoth/application/services/resource_scope_service.py",
- "line": 488,
- "contexts": [
- "self._ledger.transaction()"
- ]
- }
+ "src/thoth/application/commands/hypothesis_same.py",
+ "src/thoth/application/services/hypothesis_same.py",
+ "src/thoth/domain/hypothesis_same.py"
],
+ "atomic_regions": [],
"entry_is_not_a_transaction": true,
"region_semantics": "Reachable delegated transaction regions; this entry-level inventory is not a claim that the whole command/IO cycle is one atomic transaction.",
- "verification": {
- "classification": "MUTATION",
- "required_callables": [
- "thoth.adapters.storage.hypothesis.SqliteHypothesisStore.append_audit",
- "thoth.application.commands.hypotheses_full.HypothesisHandlers.split_propose"
- ],
- "cases": [
- {
- "nodeid": "tests/integration/test_hypothesis_full_rpc.py::test_hypothesis_prediction_validity_appraisal_and_portfolio",
- "scenario": "test_hypothesis_prediction_validity_appraisal_and_portfolio",
- "state_assertion_excerpt": [
- "assert len(generated) == 3",
- "assert unknown[\"primary_intent\"] == \"EXPLORATORY\"",
- "assert explicit[\"development_stage\"] == \"GROUNDED_CANDIDATE\"",
- "assert causal_not_applicable[\"applicability\"] == \"NOT_APPLICABLE\"",
- "assert first[\"development_stage\"] == \"COUNTEREVIDENCE_CHECKED\"",
- "assert first[\"development_stage\"] == \"PREDICTION_BOUND\"",
- "assert response.error is not None",
- "assert \"TEST_VALIDITY_PRODUCER_NOT_FOUND\" in response.error.message",
- "assert (",
- "assert first[\"empirical_appraisal\"] == \"UNASSESSED\""
- ],
- "test_source_sha256": "ae4dd44ee9cc3ac98085594db06683bbc93dffe0deeeeba75c2442161c52edb9",
- "observed_callables": [
- "thoth.application.commands.hypotheses_full.HypothesisHandlers.split_propose"
- ],
- "execution_records": [
- {
- "file": "outputs/owner-atomicity-20260914/owner-final-02/junit.xml",
- "sha256": "387eec1d7c5901a1e8c2d758ac8c2bd4c70c2a09d7f72d8b4d102f33da374733",
- "scope": "HISTORICAL_PRE_FIX_FAILED_RUN",
- "result": "PASS"
- }
- ]
- }
- ]
- }
+ "blockers": [
+ "New in the follow-up stage; no owner closure receipt."
+ ]
},
{
- "path_id": "rpc:hypothesis/test/bind",
+ "path_id": "rpc:hypothesis/split/propose",
"owner": "HYPOTHESIS",
"catalog_owner": "HYPOTHESIS",
"entry_kind": "public",
- "method_or_trigger": "hypothesis/test/bind",
- "actual_callable": "thoth.application.commands.hypotheses_full.HypothesisHandlers.test_bind",
+ "method_or_trigger": "hypothesis/split/propose",
+ "actual_callable": "thoth.application.commands.hypotheses_full.HypothesisHandlers.split_propose",
"caller_refs": [
{
"source": "src/thoth/apps/decision_chain_routes.py",
- "line": 48,
- "binding": "hypothesis_handlers.test_bind"
+ "line": 50,
+ "binding": "hypothesis_handlers.split_propose"
}
],
"producer_refs": [
- "thoth.adapters.storage.hypothesis.SqliteHypothesisStore._add"
+ "thoth.adapters.storage.hypothesis.SqliteHypothesisStore.append_audit"
],
"phase_id": [
"PUBLICATION"
],
"composite_not_one_transaction": false,
"canonical_records_and_projections": [
- "hypothesis_test_bindings"
+ "hypothesis_audit"
],
"head_selector": "Existing typed owner/current revision selector; see owner contract and concrete producer",
- "audit_receipt_refs": [],
+ "audit_receipt_refs": [
+ "hypothesis_audit"
+ ],
"expected_basis": "Generation batches, portfolio/auxiliary/prediction/appraisal/validity and normal-cycle identities retain exact canonical revisions; denied replay preserves its original operation.",
"uow_participants": [
- "thoth.adapters.storage.hypothesis.SqliteHypothesisStore._add"
+ "thoth.adapters.storage.hypothesis.SqliteHypothesisStore.append_audit"
+ ],
+ "irreversible_effect_boundary": "Explicit IO phases where listed; code traversal does not treat a whole research cycle as one transaction",
+ "allowed_phase_deltas": [
+ "Only exact request/operation/attempt identities and stated transitions; tests/atomicity/harness.py validates error bindings and declared row witnesses. No whole table exclusions."
+ ],
+ "downstream_reader": [
+ "hypothesis/appraisal/read",
+ "hypothesis/assumption/list",
+ "hypothesis/audit/read",
+ "hypothesis/graph/read",
+ "hypothesis/list",
+ "hypothesis/portfolio/list",
+ "hypothesis/portfolio/read",
+ "hypothesis/prediction/list",
+ "hypothesis/prediction/read",
+ "hypothesis/quality/read",
+ "hypothesis/read",
+ "hypothesis/relation/list"
+ ],
+ "existing_tests": [
+ "tests/integration/test_a03_storage_atomicity.py",
+ "tests/integration/test_a03_portfolio_atomicity.py",
+ "tests/integration/test_hypothesis_prediction_outcome_cycle.py",
+ "tests/integration/test_research_replay_ownership.py",
+ "tests/integration/test_research_boundaries_v2.py"
+ ],
+ "fault_points": "See exact owner tests; stage failures, CAS, reopen and consumer assertions are distinct",
+ "disposition": "OWNER_EVIDENCE_PENDING",
+ "evidence_refs": [],
+ "source_refs": [
+ "src/thoth/adapters/storage/action.py",
+ "src/thoth/adapters/storage/artifacts.py",
+ "src/thoth/adapters/storage/auth.py",
+ "src/thoth/adapters/storage/control_record.py",
+ "src/thoth/adapters/storage/criterion_contract.py",
+ "src/thoth/adapters/storage/decision_object.py",
+ "src/thoth/adapters/storage/evidence_graph.py",
+ "src/thoth/adapters/storage/execution.py",
+ "src/thoth/adapters/storage/governance.py",
+ "src/thoth/adapters/storage/governance_history.py",
+ "src/thoth/adapters/storage/hypothesis.py",
+ "src/thoth/adapters/storage/operations.py",
+ "src/thoth/adapters/storage/outcome.py",
+ "src/thoth/adapters/storage/research_identity.py",
+ "src/thoth/adapters/storage/resource_scope.py",
+ "src/thoth/adapters/storage/sqlite.py",
+ "src/thoth/adapters/storage/transaction.py",
+ "src/thoth/application/commands/hypotheses_full.py",
+ "src/thoth/application/services/hypothesis_service.py",
+ "src/thoth/application/services/resource_scope_service.py"
+ ],
+ "atomic_regions": [
+ {
+ "callable": "thoth.adapters.storage.sqlite.SqliteLedger.transaction",
+ "source": "src/thoth/adapters/storage/sqlite.py",
+ "line": 242,
+ "contexts": [
+ "SqliteAtomicUnitOfWork(self._engine, immediate=True).transaction()"
+ ]
+ },
+ {
+ "callable": "thoth.adapters.storage.transaction.SqliteAtomicUnitOfWork.transaction",
+ "source": "src/thoth/adapters/storage/transaction.py",
+ "line": 77,
+ "contexts": [
+ "resource_use_transaction()"
+ ]
+ },
+ {
+ "callable": "thoth.application.services.resource_scope_service.ResourceScopeService.require_reads",
+ "source": "src/thoth/application/services/resource_scope_service.py",
+ "line": 488,
+ "contexts": [
+ "self._ledger.transaction()"
+ ]
+ }
+ ],
+ "entry_is_not_a_transaction": true,
+ "region_semantics": "Reachable delegated transaction regions; this entry-level inventory is not a claim that the whole command/IO cycle is one atomic transaction.",
+ "verification": {
+ "classification": "MUTATION",
+ "required_callables": [
+ "thoth.adapters.storage.hypothesis.SqliteHypothesisStore.append_audit",
+ "thoth.application.commands.hypotheses_full.HypothesisHandlers.split_propose"
+ ],
+ "cases": [
+ {
+ "nodeid": "tests/integration/test_hypothesis_full_rpc.py::test_hypothesis_prediction_validity_appraisal_and_portfolio",
+ "scenario": "test_hypothesis_prediction_validity_appraisal_and_portfolio",
+ "state_assertion_excerpt": [
+ "assert len(generated) == 3",
+ "assert unknown[\"primary_intent\"] == \"EXPLORATORY\"",
+ "assert explicit[\"development_stage\"] == \"GROUNDED_CANDIDATE\"",
+ "assert causal_not_applicable[\"applicability\"] == \"NOT_APPLICABLE\"",
+ "assert first[\"development_stage\"] == \"COUNTEREVIDENCE_CHECKED\"",
+ "assert first[\"development_stage\"] == \"PREDICTION_BOUND\"",
+ "assert response.error is not None",
+ "assert \"TEST_VALIDITY_PRODUCER_NOT_FOUND\" in response.error.message",
+ "assert (",
+ "assert first[\"empirical_appraisal\"] == \"UNASSESSED\""
+ ],
+ "test_source_sha256": "ae4dd44ee9cc3ac98085594db06683bbc93dffe0deeeeba75c2442161c52edb9",
+ "observed_callables": [
+ "thoth.application.commands.hypotheses_full.HypothesisHandlers.split_propose"
+ ],
+ "execution_records": [
+ {
+ "file": "outputs/owner-atomicity-20260914/owner-final-02/junit.xml",
+ "sha256": "387eec1d7c5901a1e8c2d758ac8c2bd4c70c2a09d7f72d8b4d102f33da374733",
+ "scope": "HISTORICAL_PRE_FIX_FAILED_RUN",
+ "result": "PASS"
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "path_id": "rpc:hypothesis/test/bind",
+ "owner": "HYPOTHESIS",
+ "catalog_owner": "HYPOTHESIS",
+ "entry_kind": "public",
+ "method_or_trigger": "hypothesis/test/bind",
+ "actual_callable": "thoth.application.commands.hypotheses_full.HypothesisHandlers.test_bind",
+ "caller_refs": [
+ {
+ "source": "src/thoth/apps/decision_chain_routes.py",
+ "line": 48,
+ "binding": "hypothesis_handlers.test_bind"
+ }
+ ],
+ "producer_refs": [
+ "thoth.adapters.storage.hypothesis.SqliteHypothesisStore._add"
+ ],
+ "phase_id": [
+ "PUBLICATION"
+ ],
+ "composite_not_one_transaction": false,
+ "canonical_records_and_projections": [
+ "hypothesis_test_bindings"
+ ],
+ "head_selector": "Existing typed owner/current revision selector; see owner contract and concrete producer",
+ "audit_receipt_refs": [],
+ "expected_basis": "Generation batches, portfolio/auxiliary/prediction/appraisal/validity and normal-cycle identities retain exact canonical revisions; denied replay preserves its original operation.",
+ "uow_participants": [
+ "thoth.adapters.storage.hypothesis.SqliteHypothesisStore._add"
],
"irreversible_effect_boundary": "Explicit IO phases where listed; code traversal does not treat a whole research cycle as one transaction",
"allowed_phase_deltas": [
@@ -66109,6 +66819,478 @@
]
}
},
+ {
+ "path_id": "rpc:trace/confirm",
+ "owner": "SHARED_OR_OTHER_OWNER",
+ "catalog_owner": "VERIFICATION_TRACE",
+ "entry_kind": "public",
+ "method_or_trigger": "trace/confirm",
+ "actual_callable": "thoth.application.commands.verification_trace.TraceHandlers.confirm",
+ "caller_refs": [
+ {
+ "source": "src/thoth/apps/trace_composition.py",
+ "line": 25,
+ "binding": "handlers.confirm"
+ }
+ ],
+ "producer_refs": [
+ "thoth.application.services.verification_trace.VerificationTraceService.confirm"
+ ],
+ "phase_id": [
+ "PUBLICATION"
+ ],
+ "composite_not_one_transaction": false,
+ "canonical_records_and_projections": [
+ "semantic_revisions (THREAD:verification-trace:project, THREAD:trace-verdict:)",
+ "entity_snapshots"
+ ],
+ "head_selector": "Existing typed owner/current revision selector; see owner contract and concrete producer",
+ "audit_receipt_refs": [],
+ "expected_basis": "A confirmation is added to the stored trace record from the digest the caller read. It names one current verdict revision and changes no verdict; a revision that is no longer current is refused.",
+ "uow_participants": [
+ "thoth.application.services.request_records.RequestRecords.stage",
+ "thoth.application.services.request_records.RequestRecords.commit_staged"
+ ],
+ "irreversible_effect_boundary": "No external effect and no model call.",
+ "allowed_phase_deltas": [
+ "Only exact request/operation/attempt identities and stated transitions; tests/atomicity/harness.py validates error bindings and declared row witnesses. No whole table exclusions."
+ ],
+ "downstream_reader": [
+ "trace/read"
+ ],
+ "existing_tests": [
+ "tests/integration/test_trace_csv.py",
+ "tests/unit/services/test_verification_trace.py"
+ ],
+ "fault_points": "The write is one ledger transaction; a failure writes nothing.",
+ "disposition": "OWNER_EVIDENCE_PENDING",
+ "evidence_refs": [],
+ "source_refs": [
+ "src/thoth/application/commands/verification_trace.py",
+ "src/thoth/apps/trace_composition.py"
+ ],
+ "atomic_regions": [],
+ "entry_is_not_a_transaction": true,
+ "region_semantics": "Reachable delegated transaction regions; this entry-level inventory is not a claim that the whole command/IO cycle is one atomic transaction.",
+ "blockers": [
+ "New in the trace stage; no owner closure receipt."
+ ]
+ },
+ {
+ "path_id": "rpc:trace/closure/record",
+ "owner": "SHARED_OR_OTHER_OWNER",
+ "catalog_owner": "VERIFICATION_TRACE",
+ "entry_kind": "public",
+ "method_or_trigger": "trace/closure/record",
+ "actual_callable": "thoth.application.commands.trace_closure.ClosureHandlers.record",
+ "caller_refs": [
+ {
+ "source": "src/thoth/apps/judgment_records_composition.py",
+ "line": 32,
+ "binding": "closures.record"
+ }
+ ],
+ "producer_refs": [
+ "thoth.application.services.trace_closure.TraceClosures.record"
+ ],
+ "phase_id": [
+ "PUBLICATION"
+ ],
+ "composite_not_one_transaction": false,
+ "canonical_records_and_projections": [
+ "semantic_revisions (THREAD:trace-closures)",
+ "entity_snapshots"
+ ],
+ "head_selector": "Existing typed owner/current revision selector; see owner contract and concrete producer",
+ "audit_receipt_refs": [],
+ "expected_basis": "A person's closure of a trace row that was not met is appended to the project's closure record in one ledger commit with the document it rests on; the verdict is never changed and earlier events are kept.",
+ "uow_participants": [
+ "thoth.application.services.request_records.RequestRecords.save"
+ ],
+ "irreversible_effect_boundary": "No external effect and no model call.",
+ "allowed_phase_deltas": [
+ "Only exact request/operation/attempt identities and stated transitions; tests/atomicity/harness.py validates error bindings and declared row witnesses. No whole table exclusions."
+ ],
+ "downstream_reader": [
+ "apps/web/src/components/ClosureMark.tsx",
+ "apps/web/src/components/TracePage.tsx"
+ ],
+ "existing_tests": [
+ "tests/unit/domain/test_trace_closure.py",
+ "tests/integration/test_judgment_records.py"
+ ],
+ "fault_points": "The write is one ledger transaction; a failure writes nothing.",
+ "disposition": "OWNER_EVIDENCE_PENDING",
+ "evidence_refs": [],
+ "source_refs": [
+ "src/thoth/application/commands/trace_closure.py",
+ "src/thoth/application/services/trace_closure.py",
+ "src/thoth/domain/trace_closure.py"
+ ],
+ "atomic_regions": [],
+ "entry_is_not_a_transaction": true,
+ "region_semantics": "Reachable delegated transaction regions; this entry-level inventory is not a claim that the whole command/IO cycle is one atomic transaction.",
+ "blockers": [
+ "New in the judgment records stage; no owner closure receipt."
+ ]
+ },
+ {
+ "path_id": "rpc:trace/export",
+ "owner": "SHARED_OR_OTHER_OWNER",
+ "catalog_owner": "VERIFICATION_TRACE",
+ "entry_kind": "public",
+ "method_or_trigger": "trace/export",
+ "actual_callable": "thoth.application.commands.verification_trace.TraceHandlers.export",
+ "caller_refs": [
+ {
+ "source": "src/thoth/apps/trace_composition.py",
+ "line": 22,
+ "binding": "handlers.export"
+ }
+ ],
+ "producer_refs": [],
+ "phase_id": [
+ "READ_ONLY"
+ ],
+ "composite_not_one_transaction": false,
+ "canonical_records_and_projections": [],
+ "head_selector": "Existing typed owner/current revision selector; see owner contract and concrete producer",
+ "audit_receipt_refs": [],
+ "expected_basis": "The trace set as one thoth-trace-csv/1 file with a loss manifest that lists what the file cannot carry. An export never writes.",
+ "uow_participants": [],
+ "irreversible_effect_boundary": "Explicit IO phases where listed; code traversal does not treat a whole research cycle as one transaction",
+ "allowed_phase_deltas": [
+ "Only exact request/operation/attempt identities and stated transitions; tests/atomicity/harness.py validates error bindings and declared row witnesses. No whole table exclusions."
+ ],
+ "downstream_reader": [
+ "trace/importPreview",
+ "trace/importApply"
+ ],
+ "existing_tests": [
+ "tests/integration/test_trace_csv.py",
+ "tests/unit/services/test_verification_trace.py"
+ ],
+ "fault_points": "See exact owner tests.",
+ "disposition": "READ_ONLY_REVIEWED",
+ "evidence_refs": [],
+ "source_refs": [
+ "src/thoth/application/commands/verification_trace.py",
+ "src/thoth/apps/trace_composition.py"
+ ],
+ "atomic_regions": [],
+ "entry_is_not_a_transaction": true,
+ "region_semantics": "Reachable delegated transaction regions; this entry-level inventory is not a claim that the whole command/IO cycle is one atomic transaction.",
+ "blockers": [
+ "New in the trace stage; no owner closure receipt."
+ ]
+ },
+ {
+ "path_id": "rpc:trace/history",
+ "owner": "SHARED_OR_OTHER_OWNER",
+ "catalog_owner": "VERIFICATION_TRACE",
+ "entry_kind": "public",
+ "method_or_trigger": "trace/history",
+ "actual_callable": "thoth.application.commands.verification_trace.TraceHandlers.history",
+ "caller_refs": [
+ {
+ "source": "src/thoth/apps/trace_composition.py",
+ "line": 21,
+ "binding": "handlers.history"
+ }
+ ],
+ "producer_refs": [],
+ "phase_id": [
+ "READ_ONLY"
+ ],
+ "composite_not_one_transaction": false,
+ "canonical_records_and_projections": [],
+ "head_selector": "Existing typed owner/current revision selector; see owner contract and concrete producer",
+ "audit_receipt_refs": [],
+ "expected_basis": "Older revisions of one verdict, newest first, in pages of at most 50, from the stored per-revision records. A read never writes.",
+ "uow_participants": [],
+ "irreversible_effect_boundary": "Explicit IO phases where listed; code traversal does not treat a whole research cycle as one transaction",
+ "allowed_phase_deltas": [
+ "Only exact request/operation/attempt identities and stated transitions; tests/atomicity/harness.py validates error bindings and declared row witnesses. No whole table exclusions."
+ ],
+ "downstream_reader": [
+ "trace/read"
+ ],
+ "existing_tests": [
+ "tests/integration/test_trace_csv.py",
+ "tests/unit/services/test_verification_trace.py"
+ ],
+ "fault_points": "See exact owner tests.",
+ "disposition": "READ_ONLY_REVIEWED",
+ "evidence_refs": [],
+ "source_refs": [
+ "src/thoth/application/commands/verification_trace.py",
+ "src/thoth/apps/trace_composition.py"
+ ],
+ "atomic_regions": [],
+ "entry_is_not_a_transaction": true,
+ "region_semantics": "Reachable delegated transaction regions; this entry-level inventory is not a claim that the whole command/IO cycle is one atomic transaction.",
+ "blockers": [
+ "New in the trace stage; no owner closure receipt."
+ ]
+ },
+ {
+ "path_id": "rpc:trace/importApply",
+ "owner": "SHARED_OR_OTHER_OWNER",
+ "catalog_owner": "VERIFICATION_TRACE",
+ "entry_kind": "public",
+ "method_or_trigger": "trace/importApply",
+ "actual_callable": "thoth.application.commands.verification_trace.TraceHandlers.import_apply",
+ "caller_refs": [
+ {
+ "source": "src/thoth/apps/trace_composition.py",
+ "line": 24,
+ "binding": "handlers.import_apply"
+ }
+ ],
+ "producer_refs": [
+ "thoth.application.services.verification_trace.VerificationTraceService.apply_import"
+ ],
+ "phase_id": [
+ "PUBLICATION"
+ ],
+ "composite_not_one_transaction": false,
+ "canonical_records_and_projections": [
+ "semantic_revisions (THREAD:verification-trace:project, THREAD:trace-verdict:)",
+ "entity_snapshots"
+ ],
+ "head_selector": "Existing typed owner/current revision selector; see owner contract and concrete producer",
+ "audit_receipt_refs": [],
+ "expected_basis": "One apply is one ledger commit: the trace record THREAD:verification-trace:project (the new set and the digests of each subject's revisions) and one record THREAD:trace-verdict: per new verdict revision, written from the digest the caller read. A preview id or input hash that no longer matches, a file with a conflict, or a failure while computing verdicts writes nothing. No model is called.",
+ "uow_participants": [
+ "thoth.application.services.request_records.RequestRecords.stage",
+ "thoth.application.services.request_records.RequestRecords.commit_staged"
+ ],
+ "irreversible_effect_boundary": "No external effect and no model call.",
+ "allowed_phase_deltas": [
+ "Only exact request/operation/attempt identities and stated transitions; tests/atomicity/harness.py validates error bindings and declared row witnesses. No whole table exclusions."
+ ],
+ "downstream_reader": [
+ "trace/read",
+ "trace/export"
+ ],
+ "existing_tests": [
+ "tests/integration/test_trace_csv.py",
+ "tests/unit/services/test_verification_trace.py"
+ ],
+ "fault_points": "The commit is one ledger transaction; a conflict, a stale preview or a failed verdict step writes nothing.",
+ "disposition": "OWNER_EVIDENCE_PENDING",
+ "evidence_refs": [],
+ "source_refs": [
+ "src/thoth/application/commands/verification_trace.py",
+ "src/thoth/apps/trace_composition.py"
+ ],
+ "atomic_regions": [],
+ "entry_is_not_a_transaction": true,
+ "region_semantics": "Reachable delegated transaction regions; this entry-level inventory is not a claim that the whole command/IO cycle is one atomic transaction.",
+ "blockers": [
+ "New in the trace stage; no owner closure receipt."
+ ]
+ },
+ {
+ "path_id": "rpc:trace/importPreview",
+ "owner": "SHARED_OR_OTHER_OWNER",
+ "catalog_owner": "VERIFICATION_TRACE",
+ "entry_kind": "public",
+ "method_or_trigger": "trace/importPreview",
+ "actual_callable": "thoth.application.commands.verification_trace.TraceHandlers.import_preview",
+ "caller_refs": [
+ {
+ "source": "src/thoth/apps/trace_composition.py",
+ "line": 23,
+ "binding": "handlers.import_preview"
+ }
+ ],
+ "producer_refs": [],
+ "phase_id": [
+ "READ_ONLY"
+ ],
+ "composite_not_one_transaction": false,
+ "canonical_records_and_projections": [],
+ "head_selector": "Existing typed owner/current revision selector; see owner contract and concrete producer",
+ "audit_receipt_refs": [],
+ "expected_basis": "What an import would add, change and delete, worked out from the exact text sent and the set the trace has right now; the preview id is bound to both. Nothing is stored.",
+ "uow_participants": [],
+ "irreversible_effect_boundary": "Explicit IO phases where listed; code traversal does not treat a whole research cycle as one transaction",
+ "allowed_phase_deltas": [
+ "Only exact request/operation/attempt identities and stated transitions; tests/atomicity/harness.py validates error bindings and declared row witnesses. No whole table exclusions."
+ ],
+ "downstream_reader": [
+ "trace/importApply"
+ ],
+ "existing_tests": [
+ "tests/integration/test_trace_csv.py",
+ "tests/unit/services/test_verification_trace.py"
+ ],
+ "fault_points": "See exact owner tests.",
+ "disposition": "READ_ONLY_REVIEWED",
+ "evidence_refs": [],
+ "source_refs": [
+ "src/thoth/application/commands/verification_trace.py",
+ "src/thoth/apps/trace_composition.py"
+ ],
+ "atomic_regions": [],
+ "entry_is_not_a_transaction": true,
+ "region_semantics": "Reachable delegated transaction regions; this entry-level inventory is not a claim that the whole command/IO cycle is one atomic transaction.",
+ "blockers": [
+ "New in the trace stage; no owner closure receipt."
+ ]
+ },
+ {
+ "path_id": "rpc:trace/read",
+ "owner": "SHARED_OR_OTHER_OWNER",
+ "catalog_owner": "VERIFICATION_TRACE",
+ "entry_kind": "public",
+ "method_or_trigger": "trace/read",
+ "actual_callable": "thoth.application.commands.verification_trace.TraceHandlers.read",
+ "caller_refs": [
+ {
+ "source": "src/thoth/apps/trace_composition.py",
+ "line": 20,
+ "binding": "handlers.read"
+ }
+ ],
+ "producer_refs": [],
+ "phase_id": [
+ "READ_ONLY"
+ ],
+ "composite_not_one_transaction": false,
+ "canonical_records_and_projections": [],
+ "head_selector": "Existing typed owner/current revision selector; see owner contract and concrete producer",
+ "audit_receipt_refs": [],
+ "expected_basis": "The project's trace set, each current verdict with its currentness, its confirmations and its three most recent revisions with the total count, with the digest of the record version read. A read never writes.",
+ "uow_participants": [],
+ "irreversible_effect_boundary": "Explicit IO phases where listed; code traversal does not treat a whole research cycle as one transaction",
+ "allowed_phase_deltas": [
+ "Only exact request/operation/attempt identities and stated transitions; tests/atomicity/harness.py validates error bindings and declared row witnesses. No whole table exclusions."
+ ],
+ "downstream_reader": [
+ "trace/history",
+ "trace/export"
+ ],
+ "existing_tests": [
+ "tests/integration/test_trace_csv.py",
+ "tests/unit/services/test_verification_trace.py"
+ ],
+ "fault_points": "See exact owner tests.",
+ "disposition": "READ_ONLY_REVIEWED",
+ "evidence_refs": [],
+ "source_refs": [
+ "src/thoth/application/commands/verification_trace.py",
+ "src/thoth/apps/trace_composition.py"
+ ],
+ "atomic_regions": [],
+ "entry_is_not_a_transaction": true,
+ "region_semantics": "Reachable delegated transaction regions; this entry-level inventory is not a claim that the whole command/IO cycle is one atomic transaction.",
+ "blockers": [
+ "New in the trace stage; no owner closure receipt."
+ ]
+ },
+ {
+ "path_id": "rpc:trace/closure/list",
+ "owner": "SHARED_OR_OTHER_OWNER",
+ "catalog_owner": "VERIFICATION_TRACE",
+ "entry_kind": "public",
+ "method_or_trigger": "trace/closure/list",
+ "actual_callable": "thoth.application.commands.trace_closure.ClosureHandlers.list",
+ "caller_refs": [
+ {
+ "source": "src/thoth/apps/judgment_records_composition.py",
+ "line": 31,
+ "binding": "closures.list"
+ }
+ ],
+ "producer_refs": [],
+ "phase_id": [
+ "READ_ONLY"
+ ],
+ "composite_not_one_transaction": false,
+ "canonical_records_and_projections": [],
+ "head_selector": "Existing typed owner/current revision selector; see owner contract and concrete producer",
+ "audit_receipt_refs": [],
+ "expected_basis": "The closures people recorded on trace rows, and the effect the rules confirm from the row's own verdict history, read from committed records. A read never writes.",
+ "uow_participants": [],
+ "irreversible_effect_boundary": "Explicit IO phases where listed; code traversal does not treat a whole research cycle as one transaction",
+ "allowed_phase_deltas": [
+ "Only exact request/operation/attempt identities and stated transitions; tests/atomicity/harness.py validates error bindings and declared row witnesses. No whole table exclusions."
+ ],
+ "downstream_reader": [
+ "apps/web/src/components/ClosureMark.tsx",
+ "apps/web/src/components/TracePage.tsx"
+ ],
+ "existing_tests": [
+ "tests/unit/domain/test_trace_closure.py",
+ "tests/integration/test_judgment_records.py"
+ ],
+ "fault_points": "See exact owner tests.",
+ "disposition": "READ_ONLY_REVIEWED",
+ "evidence_refs": [],
+ "source_refs": [
+ "src/thoth/application/commands/trace_closure.py",
+ "src/thoth/application/services/trace_closure.py",
+ "src/thoth/domain/trace_closure.py"
+ ],
+ "atomic_regions": [],
+ "entry_is_not_a_transaction": true,
+ "region_semantics": "Reachable delegated transaction regions; this entry-level inventory is not a claim that the whole command/IO cycle is one atomic transaction.",
+ "blockers": [
+ "New in the judgment records stage; no owner closure receipt."
+ ]
+ },
+ {
+ "path_id": "rpc:trace/lesson/list",
+ "owner": "SHARED_OR_OTHER_OWNER",
+ "catalog_owner": "VERIFICATION_TRACE",
+ "entry_kind": "public",
+ "method_or_trigger": "trace/lesson/list",
+ "actual_callable": "thoth.application.commands.lesson.LessonHandlers.list",
+ "caller_refs": [
+ {
+ "source": "src/thoth/apps/judgment_records_composition.py",
+ "line": 37,
+ "binding": "LessonHandlers(lessons=lessons, closures=trace_closures, projects=projects).list"
+ }
+ ],
+ "producer_refs": [],
+ "phase_id": [
+ "READ_ONLY"
+ ],
+ "composite_not_one_transaction": false,
+ "canonical_records_and_projections": [],
+ "head_selector": "Existing typed owner/current revision selector; see owner contract and concrete producer",
+ "audit_receipt_refs": [],
+ "expected_basis": "The lessons recalled for each trace row from its exact context, read from committed records: each lesson points at a record a rule computed or a person made, and is marked stale or refuted when it is. A read never writes.",
+ "uow_participants": [],
+ "irreversible_effect_boundary": "Explicit IO phases where listed; code traversal does not treat a whole research cycle as one transaction",
+ "allowed_phase_deltas": [
+ "Only exact request/operation/attempt identities and stated transitions; tests/atomicity/harness.py validates error bindings and declared row witnesses. No whole table exclusions."
+ ],
+ "downstream_reader": [
+ "apps/web/src/components/LessonRecall.tsx"
+ ],
+ "existing_tests": [
+ "tests/unit/test_lesson_recall_cases.py",
+ "tests/integration/test_lesson_recall.py"
+ ],
+ "fault_points": "See exact owner tests.",
+ "disposition": "READ_ONLY_REVIEWED",
+ "evidence_refs": [],
+ "source_refs": [
+ "src/thoth/application/commands/lesson.py",
+ "src/thoth/application/services/lesson_ledger.py",
+ "src/thoth/domain/lesson_ref.py"
+ ],
+ "atomic_regions": [],
+ "entry_is_not_a_transaction": true,
+ "region_semantics": "Reachable delegated transaction regions; this entry-level inventory is not a claim that the whole command/IO cycle is one atomic transaction.",
+ "blockers": [
+ "New in the learning loop stage; no owner closure receipt."
+ ]
+ },
{
"path_id": "rpc:workspace/ready",
"owner": "SHARED_OR_OTHER_OWNER",
@@ -67894,6 +69076,282 @@
]
}
},
+ {
+ "path_id": "rpc:hypothesis/link/list",
+ "owner": "HYPOTHESIS",
+ "catalog_owner": "HYPOTHESIS",
+ "entry_kind": "public",
+ "method_or_trigger": "hypothesis/link/list",
+ "actual_callable": "thoth.application.commands.hypothesis_link.HypothesisLinkHandlers.list",
+ "caller_refs": [
+ {
+ "source": "src/thoth/apps/hypothesis_link_composition.py",
+ "line": 28,
+ "binding": "handlers.list"
+ }
+ ],
+ "producer_refs": [],
+ "phase_id": [
+ "READ_ONLY"
+ ],
+ "composite_not_one_transaction": false,
+ "canonical_records_and_projections": [],
+ "head_selector": "Existing typed owner/current revision selector; see owner contract and concrete producer",
+ "audit_receipt_refs": [],
+ "expected_basis": "Each hypothesis that came from a trace row, read against the trace's current verdicts from committed records, and the project's re-check reasons. A read never writes.",
+ "uow_participants": [],
+ "irreversible_effect_boundary": "Explicit IO phases where listed; code traversal does not treat a whole research cycle as one transaction",
+ "allowed_phase_deltas": [
+ "Only exact request/operation/attempt identities and stated transitions; tests/atomicity/harness.py validates error bindings and declared row witnesses. No whole table exclusions."
+ ],
+ "downstream_reader": [
+ "apps/web/src/components/HypothesisLinkNotice.tsx",
+ "apps/web/src/components/TracePage.tsx"
+ ],
+ "existing_tests": [
+ "tests/unit/domain/test_verdict_link.py",
+ "tests/integration/test_hypothesis_link.py"
+ ],
+ "fault_points": "See exact owner tests.",
+ "disposition": "READ_ONLY_REVIEWED",
+ "evidence_refs": [],
+ "source_refs": [
+ "src/thoth/application/commands/hypothesis_link.py",
+ "src/thoth/application/services/hypothesis_link_view.py",
+ "src/thoth/domain/verdict_link.py"
+ ],
+ "atomic_regions": [],
+ "entry_is_not_a_transaction": true,
+ "region_semantics": "Reachable delegated transaction regions; this entry-level inventory is not a claim that the whole command/IO cycle is one atomic transaction.",
+ "blockers": [
+ "New in the hypothesis link stage; no owner closure receipt."
+ ]
+ },
+ {
+ "path_id": "rpc:hypothesis/test/result/list",
+ "owner": "HYPOTHESIS",
+ "catalog_owner": "HYPOTHESIS",
+ "entry_kind": "public",
+ "method_or_trigger": "hypothesis/test/result/list",
+ "actual_callable": "thoth.application.commands.discrimination.DiscriminationHandlers.list",
+ "caller_refs": [
+ {
+ "source": "src/thoth/apps/judgment_records_composition.py",
+ "line": 26,
+ "binding": "discrimination.list"
+ }
+ ],
+ "producer_refs": [],
+ "phase_id": [
+ "READ_ONLY"
+ ],
+ "composite_not_one_transaction": false,
+ "canonical_records_and_projections": [],
+ "head_selector": "Existing typed owner/current revision selector; see owner contract and concrete producer",
+ "audit_receipt_refs": [],
+ "expected_basis": "What people recorded for the tests of hypotheses and the elimination those results amount to, read from committed records. A read never writes.",
+ "uow_participants": [],
+ "irreversible_effect_boundary": "Explicit IO phases where listed; code traversal does not treat a whole research cycle as one transaction",
+ "allowed_phase_deltas": [
+ "Only exact request/operation/attempt identities and stated transitions; tests/atomicity/harness.py validates error bindings and declared row witnesses. No whole table exclusions."
+ ],
+ "downstream_reader": [
+ "apps/web/src/components/TestResultRecorder.tsx",
+ "apps/web/src/components/RefutationConditionsPanel.tsx"
+ ],
+ "existing_tests": [
+ "tests/unit/domain/test_discrimination.py",
+ "tests/integration/test_judgment_records.py"
+ ],
+ "fault_points": "See exact owner tests.",
+ "disposition": "READ_ONLY_REVIEWED",
+ "evidence_refs": [],
+ "source_refs": [
+ "src/thoth/application/commands/discrimination.py",
+ "src/thoth/application/services/discrimination_ledger.py",
+ "src/thoth/domain/discrimination.py"
+ ],
+ "atomic_regions": [],
+ "entry_is_not_a_transaction": true,
+ "region_semantics": "Reachable delegated transaction regions; this entry-level inventory is not a claim that the whole command/IO cycle is one atomic transaction.",
+ "blockers": [
+ "New in the judgment records stage; no owner closure receipt."
+ ]
+ },
+ {
+ "path_id": "rpc:hypothesis/link/recheck",
+ "owner": "HYPOTHESIS",
+ "catalog_owner": "HYPOTHESIS",
+ "entry_kind": "public",
+ "method_or_trigger": "hypothesis/link/recheck",
+ "actual_callable": "thoth.application.commands.hypothesis_link.HypothesisLinkHandlers.recheck",
+ "caller_refs": [
+ {
+ "source": "src/thoth/apps/hypothesis_link_composition.py",
+ "line": 29,
+ "binding": "handlers.recheck"
+ }
+ ],
+ "producer_refs": [
+ "thoth.application.services.hypothesis_link_recheck.HypothesisLinkRechecks.recheck"
+ ],
+ "phase_id": [
+ "PUBLICATION"
+ ],
+ "composite_not_one_transaction": false,
+ "canonical_records_and_projections": [
+ "semantic_revisions (THREAD:hypothesis-link-rechecks)",
+ "entity_snapshots"
+ ],
+ "head_selector": "Existing typed owner/current revision selector; see owner contract and concrete producer",
+ "audit_receipt_refs": [],
+ "expected_basis": "A person's re-check of hypotheses on one changed verdict is appended to the project's re-check record in one ledger commit; earlier events are kept, no hypothesis or verdict is changed, and a verdict that moved on since the person looked is refused.",
+ "uow_participants": [
+ "thoth.application.services.request_records.RequestRecords.save"
+ ],
+ "irreversible_effect_boundary": "No external effect and no model call.",
+ "allowed_phase_deltas": [
+ "Only exact request/operation/attempt identities and stated transitions; tests/atomicity/harness.py validates error bindings and declared row witnesses. No whole table exclusions."
+ ],
+ "downstream_reader": [
+ "hypothesis/link/list",
+ "apps/web/src/components/HypothesisLinkNotice.tsx"
+ ],
+ "existing_tests": [
+ "tests/unit/domain/test_verdict_link.py",
+ "tests/integration/test_hypothesis_link.py"
+ ],
+ "fault_points": "The write is one ledger transaction; a failure writes nothing.",
+ "disposition": "OWNER_EVIDENCE_PENDING",
+ "evidence_refs": [],
+ "source_refs": [
+ "src/thoth/application/commands/hypothesis_link.py",
+ "src/thoth/application/services/hypothesis_link_recheck.py",
+ "src/thoth/domain/verdict_link.py"
+ ],
+ "atomic_regions": [],
+ "entry_is_not_a_transaction": true,
+ "region_semantics": "Reachable delegated transaction regions; this entry-level inventory is not a claim that the whole command/IO cycle is one atomic transaction.",
+ "blockers": [
+ "New in the hypothesis link stage; no owner closure receipt."
+ ]
+ },
+ {
+ "path_id": "rpc:hypothesis/refutation/record",
+ "owner": "HYPOTHESIS",
+ "catalog_owner": "HYPOTHESIS",
+ "entry_kind": "public",
+ "method_or_trigger": "hypothesis/refutation/record",
+ "actual_callable": "thoth.application.commands.discrimination.DiscriminationHandlers.record_conditions",
+ "caller_refs": [
+ {
+ "source": "src/thoth/apps/judgment_records_composition.py",
+ "line": 28,
+ "binding": "discrimination.record_conditions"
+ }
+ ],
+ "producer_refs": [
+ "thoth.application.services.discrimination_ledger.DiscriminationLedger.record_conditions"
+ ],
+ "phase_id": [
+ "PUBLICATION"
+ ],
+ "composite_not_one_transaction": false,
+ "canonical_records_and_projections": [
+ "semantic_revisions (THREAD:hypothesis-discrimination)",
+ "entity_snapshots"
+ ],
+ "head_selector": "Existing typed owner/current revision selector; see owner contract and concrete producer",
+ "audit_receipt_refs": [],
+ "expected_basis": "A person's list of refutation conditions for a hypothesis is appended to the project's discrimination record in one ledger commit; earlier lists are kept, the hypothesis is not changed and no model is called.",
+ "uow_participants": [
+ "thoth.application.services.request_records.RequestRecords.save"
+ ],
+ "irreversible_effect_boundary": "No external effect and no model call.",
+ "allowed_phase_deltas": [
+ "Only exact request/operation/attempt identities and stated transitions; tests/atomicity/harness.py validates error bindings and declared row witnesses. No whole table exclusions."
+ ],
+ "downstream_reader": [
+ "apps/web/src/components/TestResultRecorder.tsx",
+ "apps/web/src/components/RefutationConditionsPanel.tsx"
+ ],
+ "existing_tests": [
+ "tests/unit/domain/test_discrimination.py",
+ "tests/integration/test_judgment_records.py"
+ ],
+ "fault_points": "The write is one ledger transaction; a failure writes nothing.",
+ "disposition": "OWNER_EVIDENCE_PENDING",
+ "evidence_refs": [],
+ "source_refs": [
+ "src/thoth/application/commands/discrimination.py",
+ "src/thoth/application/services/discrimination_ledger.py",
+ "src/thoth/domain/discrimination.py"
+ ],
+ "atomic_regions": [],
+ "entry_is_not_a_transaction": true,
+ "region_semantics": "Reachable delegated transaction regions; this entry-level inventory is not a claim that the whole command/IO cycle is one atomic transaction.",
+ "blockers": [
+ "New in the judgment records stage; no owner closure receipt."
+ ]
+ },
+ {
+ "path_id": "rpc:hypothesis/test/result/record",
+ "owner": "HYPOTHESIS",
+ "catalog_owner": "HYPOTHESIS",
+ "entry_kind": "public",
+ "method_or_trigger": "hypothesis/test/result/record",
+ "actual_callable": "thoth.application.commands.discrimination.DiscriminationHandlers.record_result",
+ "caller_refs": [
+ {
+ "source": "src/thoth/apps/judgment_records_composition.py",
+ "line": 27,
+ "binding": "discrimination.record_result"
+ }
+ ],
+ "producer_refs": [
+ "thoth.application.services.discrimination_ledger.DiscriminationLedger.record_result"
+ ],
+ "phase_id": [
+ "PUBLICATION"
+ ],
+ "composite_not_one_transaction": false,
+ "canonical_records_and_projections": [
+ "semantic_revisions (THREAD:hypothesis-discrimination)",
+ "entity_snapshots"
+ ],
+ "head_selector": "Existing typed owner/current revision selector; see owner contract and concrete producer",
+ "audit_receipt_refs": [],
+ "expected_basis": "A person's result of one discriminating test is appended to the project's discrimination record in one ledger commit; earlier events are kept, the hypothesis is not changed and no model is called.",
+ "uow_participants": [
+ "thoth.application.services.request_records.RequestRecords.save"
+ ],
+ "irreversible_effect_boundary": "No external effect and no model call.",
+ "allowed_phase_deltas": [
+ "Only exact request/operation/attempt identities and stated transitions; tests/atomicity/harness.py validates error bindings and declared row witnesses. No whole table exclusions."
+ ],
+ "downstream_reader": [
+ "apps/web/src/components/TestResultRecorder.tsx",
+ "apps/web/src/components/RefutationConditionsPanel.tsx"
+ ],
+ "existing_tests": [
+ "tests/unit/domain/test_discrimination.py",
+ "tests/integration/test_judgment_records.py"
+ ],
+ "fault_points": "The write is one ledger transaction; a failure writes nothing.",
+ "disposition": "OWNER_EVIDENCE_PENDING",
+ "evidence_refs": [],
+ "source_refs": [
+ "src/thoth/application/commands/discrimination.py",
+ "src/thoth/application/services/discrimination_ledger.py",
+ "src/thoth/domain/discrimination.py"
+ ],
+ "atomic_regions": [],
+ "entry_is_not_a_transaction": true,
+ "region_semantics": "Reachable delegated transaction regions; this entry-level inventory is not a claim that the whole command/IO cycle is one atomic transaction.",
+ "blockers": [
+ "New in the judgment records stage; no owner closure receipt."
+ ]
+ },
{
"path_id": "rpc:memory/settings/read",
"owner": "SHARED_OR_OTHER_OWNER",
diff --git a/config/module-responsibility-budget.json b/config/module-responsibility-budget.json
index 107dc1f..ca64f40 100644
--- a/config/module-responsibility-budget.json
+++ b/config/module-responsibility-budget.json
@@ -1,18 +1,146 @@
{
"schema_version": "1.0.0",
- "module_line_limit": 900,
+ "module_line_limit": 600,
"function_line_limit": 200,
"scan_roots": [
"src/thoth"
],
+ "web_scan_roots": [
+ "apps/web/src"
+ ],
+ "web_excluded_suffixes": [
+ ".test.ts",
+ ".test.tsx"
+ ],
"watched_modules": {
+ "src/thoth/adapters/http/app.py": {
+ "baseline_lines": 833,
+ "top_level_symbols": [
+ "_hosted_transport_denial",
+ "_hosted_transport_block",
+ "_local_identity_denial",
+ "_dispatch_local_rpc",
+ "_claim_local_rpc",
+ "_dispatch_authenticated_rpc",
+ "healthz",
+ "create_app",
+ "_mount_async_rpc_route",
+ "_mount_internal_routes",
+ "web_ui_dist",
+ "_mount_web_ui",
+ "_cancel_async_operation",
+ "_stage_file",
+ "_resolve_bus_for_request",
+ "_resolve_bus",
+ "_bind_operation_task_canceller",
+ "_workspace_path",
+ "_workspace_path_for_request",
+ "_authenticate_rpc",
+ "_hosted_credential_rpc_block",
+ "_hosted_credential_rpc_block_request",
+ "_auth_denied"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
+ "src/thoth/adapters/models/claude_code.py": {
+ "baseline_lines": 777,
+ "top_level_symbols": [
+ "ClaudeCodeUnavailable",
+ "ClaudeProcess",
+ "claude_profile_dir",
+ "_child_environment",
+ "_native_target_of_shim",
+ "_find_on_path",
+ "thoth_claude_code_executable",
+ "_fallback_locations",
+ "resolve_claude_executable",
+ "_status_run",
+ "_version",
+ "_detect_auth_mode",
+ "claude_code_status",
+ "ClaudeCodeStatusCache",
+ "cached_claude_code_status",
+ "invalidate_claude_code_status",
+ "claude_code_login_guidance",
+ "run_claude_code_login",
+ "ClaudeCodeCatalog",
+ "_finish_shielded",
+ "spawn_claude_process",
+ "_stop_process_tree",
+ "claude_print_argv",
+ "_token_count",
+ "_reported_usage",
+ "ClaudeCodeModel",
+ "_login_required",
+ "claude_code_model_factory"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
+ "src/thoth/adapters/models/claude_oauth.py": {
+ "baseline_lines": 672,
+ "top_level_symbols": [
+ "ClaudeOAuthHold",
+ "ClaudeSession",
+ "_Attempt",
+ "ClaudeOAuthBroker",
+ "broker_for_workspace",
+ "close_workspace_broker",
+ "retain_workspace_broker",
+ "release_workspace_broker"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
+ "src/thoth/adapters/models/codex_broker.py": {
+ "baseline_lines": 831,
+ "top_level_symbols": [
+ "AppServerPort",
+ "CodexBrokerState",
+ "CodexAuthBroker",
+ "broker_for_workspace",
+ "close_workspace_broker",
+ "retain_workspace_broker",
+ "release_workspace_broker",
+ "close_all_brokers"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
+ "src/thoth/adapters/models/local_credentials.py": {
+ "baseline_lines": 638,
+ "top_level_symbols": [
+ "LocalCredentialHold",
+ "registry_dir",
+ "_read_json",
+ "_write_json",
+ "list_credentials",
+ "register_api_key",
+ "local_secret",
+ "available_credentials",
+ "ThothLocalCatalog",
+ "thoth_local_model",
+ "_account_auth_methods",
+ "account_connections",
+ "start_codex_login",
+ "start_console_login",
+ "register_thoth_local_providers",
+ "LocalModelCredentials"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
+ "src/thoth/adapters/storage/artifacts.py": {
+ "baseline_lines": 604,
+ "top_level_symbols": [
+ "_json_dump",
+ "SqliteArtifactLedger"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
"src/thoth/adapters/storage/schema.py": {
"baseline_lines": 1182,
"top_level_symbols": [],
"policy": "schema registry only; new table families prefer owned schema modules"
},
"src/thoth/application/commands/actions_full.py": {
- "baseline_lines": 987,
+ "baseline_lines": 978,
"top_level_symbols": [
"ProjectInput",
"ActionListInput",
@@ -24,7 +152,6 @@
"ImpactReadInput",
"AuthorizationReadInput",
"AuditReadInput",
- "CreateInput",
"ActionRevisionBound",
"ReviseInput",
"PortfolioComposeInput",
@@ -47,6 +174,30 @@
],
"policy": "existing Action RPC surface only; new responsibility goes to a new command/service module"
},
+ "src/thoth/application/commands/executions_full.py": {
+ "baseline_lines": 617,
+ "top_level_symbols": [
+ "ProjectInput",
+ "ExecutionListInput",
+ "ExecutionReadInput",
+ "StepInput",
+ "AttemptListInput",
+ "AttemptReadInput",
+ "ReconciliationReadInput",
+ "StartInput",
+ "RevisionBoundInput",
+ "PauseInput",
+ "ResumeInput",
+ "CancelInput",
+ "RetryInput",
+ "ReconcileInput",
+ "ObservationLinkInput",
+ "CompensationInput",
+ "InvalidateInput",
+ "ExecutionHandlers"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
"src/thoth/application/commands/hypotheses_full.py": {
"baseline_lines": 981,
"top_level_symbols": [
@@ -80,6 +231,112 @@
],
"policy": "existing Hypothesis RPC surface only; new responsibility goes to a new command/service module"
},
+ "src/thoth/application/commands/improvement_full.py": {
+ "baseline_lines": 827,
+ "top_level_symbols": [
+ "ProjectInput",
+ "ListInput",
+ "ImprovementReadInput",
+ "RecordReadInput",
+ "ProposeInput",
+ "ReviseInput",
+ "EvaluationPlanInput",
+ "EvaluationAssessInput",
+ "ExposurePrepareInput",
+ "PromotionPrepareInput",
+ "PromotionDecideInput",
+ "RollbackPrepareInput",
+ "RetireInput",
+ "ImprovementHandlers"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
+ "src/thoth/application/commands/memory_full.py": {
+ "baseline_lines": 840,
+ "top_level_symbols": [
+ "ProjectInput",
+ "MemoryListInput",
+ "MemoryReadInput",
+ "CandidateListInput",
+ "CandidateReadInput",
+ "ContextReadInput",
+ "PolicyListInput",
+ "PolicyReadInput",
+ "GateReadInput",
+ "ConflictListInput",
+ "RecallAuditInput",
+ "CandidateCreateInput",
+ "CandidateClassifyInput",
+ "ValidateInput",
+ "ChangeSetProposeInput",
+ "RevalidateInput",
+ "RetireProposeInput",
+ "ContextBuildInput",
+ "ProjectionRebuildInput",
+ "RetentionEvaluateInput",
+ "MemoryFullHandlers"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
+ "src/thoth/application/commands/objects_full.py": {
+ "baseline_lines": 809,
+ "top_level_symbols": [
+ "ProjectInput",
+ "ObjectListInput",
+ "ObjectReadInput",
+ "CandidateListInput",
+ "CandidateReadInput",
+ "ProfileListInput",
+ "ProfileReadInput",
+ "RelationListInput",
+ "AttentionListInput",
+ "AuditReadInput",
+ "MaterializeInput",
+ "RevisionBoundInput",
+ "FrameReviseInput",
+ "ProfileApplyInput",
+ "FacetUpdateInput",
+ "RelationAddInput",
+ "RelationRemoveInput",
+ "RevalidateInput",
+ "WorkReplanInput",
+ "SplitProposeInput",
+ "MergeProposeInput",
+ "AttentionAcknowledgeInput",
+ "FollowupCreateInput",
+ "DecisionObjectHandlers"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
+ "src/thoth/application/commands/projects.py": {
+ "baseline_lines": 603,
+ "top_level_symbols": [
+ "ProjectCreateInput",
+ "ProjectReadInput",
+ "ProjectListInput",
+ "ProjectExpectedRevisionInput",
+ "ProjectMetadataUpdateInput",
+ "ProjectOverlayUpdateInput",
+ "ProjectPolicyUpdateInput",
+ "ProjectRoleAssignInput",
+ "ProjectRoleRevokeInput",
+ "ProjectReferenceImportInput",
+ "ProjectCutoffImpactInput",
+ "ProjectCutoffUpdateInput",
+ "ProjectCommandHandlers",
+ "_merge_policy_payload",
+ "_public_web_execution",
+ "_string_tuple"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
+ "src/thoth/application/commands/research_threads.py": {
+ "baseline_lines": 856,
+ "top_level_symbols": [
+ "ResearchThreadHandlers"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
"src/thoth/application/commands/revisions_full.py": {
"baseline_lines": 1323,
"top_level_symbols": [
@@ -112,14 +369,49 @@
],
"policy": "existing Revision RPC surface only; new responsibility goes to a new command/service module"
},
+ "src/thoth/application/commands/thread_analysis.py": {
+ "baseline_lines": 709,
+ "top_level_symbols": [
+ "ThreadInputRequest",
+ "_CycleExecution",
+ "ThreadAnalysisCommandHandlers",
+ "_default_action_policy"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
"src/thoth/application/services/action_service.py": {
- "baseline_lines": 973,
+ "baseline_lines": 957,
"top_level_symbols": [
"classify_effect_vector",
"ActionService"
],
"policy": "Action domain orchestration only; extract new policy/evaluator/compiler responsibility"
},
+ "src/thoth/application/services/connector_service.py": {
+ "baseline_lines": 668,
+ "top_level_symbols": [
+ "public_web_search_attempts",
+ "ConnectorAcquisition",
+ "ConnectorService",
+ "_catalog_selectors"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
+ "src/thoth/application/services/critical_counter_search_coordinator.py": {
+ "baseline_lines": 868,
+ "top_level_symbols": [
+ "CriticalCounterSearchExecution",
+ "CriticalCounterSearchCoordinator"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
+ "src/thoth/application/services/decision_object_service.py": {
+ "baseline_lines": 650,
+ "top_level_symbols": [
+ "DecisionObjectService"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
"src/thoth/application/services/execution_service.py": {
"baseline_lines": 828,
"top_level_symbols": [
@@ -127,18 +419,242 @@
],
"policy": "Execution state orchestration only; extract new adapter/recovery responsibility"
},
+ "src/thoth/application/services/full_project_memory.py": {
+ "baseline_lines": 846,
+ "top_level_symbols": [
+ "_hold_for_missing_evidence",
+ "FullProjectMemoryService"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
+ "src/thoth/application/services/hypothesis_service.py": {
+ "baseline_lines": 853,
+ "top_level_symbols": [
+ "HypothesisService"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
+ "src/thoth/application/services/improvement_exposure_service.py": {
+ "baseline_lines": 781,
+ "top_level_symbols": [
+ "ImprovementExposureService"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
+ "src/thoth/application/services/outcome_service.py": {
+ "baseline_lines": 683,
+ "top_level_symbols": [
+ "OutcomeService"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
+ "src/thoth/application/services/r2_closed_loop.py": {
+ "baseline_lines": 881,
+ "top_level_symbols": [
+ "R2ClosedLoopExecution",
+ "R2ClosedLoopCoordinator"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
+ "src/thoth/application/services/recursive_improvement.py": {
+ "baseline_lines": 827,
+ "top_level_symbols": [
+ "PreparedImprovement",
+ "PreparedPair",
+ "RecursiveImprovementCoordinator"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
+ "src/thoth/application/services/reference_service.py": {
+ "baseline_lines": 628,
+ "top_level_symbols": [
+ "ReferenceTransition",
+ "ReferenceService"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
+ "src/thoth/application/services/research_analysis.py": {
+ "baseline_lines": 831,
+ "top_level_symbols": [
+ "ResearchAnalysis"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
+ "src/thoth/application/services/research_followup_projection.py": {
+ "baseline_lines": 757,
+ "top_level_symbols": [
+ "project_thread_followup",
+ "decision_delta",
+ "CriterionViewReader",
+ "ProjectReviewReader",
+ "_related_records",
+ "_coverage_matrix",
+ "_matrix_row",
+ "_semantic_reviews",
+ "_matrix_summary",
+ "_next_action",
+ "_progress_state",
+ "_progress_items",
+ "_recorded_checks",
+ "_remaining_gaps",
+ "_unknowns",
+ "_delta_payload",
+ "_delta_groups",
+ "_change_kind",
+ "_reason_codes",
+ "_reason_refs",
+ "_basis_currentness",
+ "_object_tuple",
+ "_decode_cursor",
+ "_read_head",
+ "_operation_state"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
+ "src/thoth/application/services/research_identity_service.py": {
+ "baseline_lines": 766,
+ "top_level_symbols": [
+ "ResearchContext",
+ "HypothesisOwnershipBatch",
+ "ActionOwnershipBatch",
+ "ResearchOwnershipBatch",
+ "ResearchIdentityService"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
+ "src/thoth/application/services/resource_scope_service.py": {
+ "baseline_lines": 873,
+ "top_level_symbols": [
+ "ResourceScopeService"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
+ "src/thoth/application/services/user_activity_projection.py": {
+ "baseline_lines": 674,
+ "top_level_symbols": [
+ "source_activity_contexts",
+ "_record",
+ "_items",
+ "_alias",
+ "_safe_code",
+ "_redaction_classes",
+ "_redaction",
+ "_public_origin",
+ "_safe_section",
+ "_source_target",
+ "_relation",
+ "_append_stage_events",
+ "_append_source_events",
+ "_append_judgement_events",
+ "_append_model_events",
+ "_append_terminal_event",
+ "project_user_activity_events"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
+ "src/thoth/application/services/verification_trace.py": {
+ "baseline_lines": 815,
+ "top_level_symbols": [
+ "TraceError",
+ "TraceConflict",
+ "_Context",
+ "_ids_of",
+ "_children",
+ "_requirements_of",
+ "_show",
+ "_digest",
+ "_select",
+ "_value_to_compare",
+ "_meets",
+ "_judge",
+ "_chain",
+ "_criterion_revision",
+ "_requirement_state",
+ "_requirement_revision",
+ "compute_verdicts",
+ "_rule_summary",
+ "_result_summary",
+ "_field_changes",
+ "dependency_changes",
+ "mark_stale",
+ "_replaced",
+ "HistoryPage",
+ "VerificationTraceService"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
+ "src/thoth/application/workflows/thread_cycle.py": {
+ "baseline_lines": 889,
+ "top_level_symbols": [
+ "ThreadCycleCommand",
+ "ThreadCycleResult",
+ "ThreadCycleBranchResult",
+ "ThreadCycleService"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
"src/thoth/apps/runtime.py": {
"baseline_lines": 746,
"top_level_symbols": [
"create_runtime"
],
"policy": "composition only; new feature composition belongs in an owned module factory"
+ },
+ "src/thoth/cli.py": {
+ "baseline_lines": 827,
+ "top_level_symbols": [
+ "ModelProbeOutput",
+ "workspace_session_id",
+ "main",
+ "_interactive_shell",
+ "announce_workspace",
+ "_interactive_session",
+ "_module_available",
+ "_sandbox_adapter",
+ "version",
+ "workspace_info",
+ "claude_code_login",
+ "doctor",
+ "connector_sandbox_doctor",
+ "profile_check",
+ "auth_status",
+ "auth_connect",
+ "model_probe",
+ "source_stage",
+ "git_snapshot",
+ "run_request",
+ "rpc_stdio",
+ "events",
+ "run_pack",
+ "serve",
+ "hosted_review_serve",
+ "desktop_window_command",
+ "desktop"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
+ "src/thoth/protocol/bus.py": {
+ "baseline_lines": 741,
+ "top_level_symbols": [
+ "DispatchTicket",
+ "PreClaimAuthorization",
+ "CommandBus"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
+ },
+ "src/thoth/protocol/notifications.py": {
+ "baseline_lines": 717,
+ "top_level_symbols": [
+ "notifications_for",
+ "notifications_for_internal_event"
+ ],
+ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split"
}
},
"watched_long_functions": {
"src/thoth/application/services/acquisition_coordinator.py::execute_if_required": 209,
"src/thoth/application/services/critical_counter_search_coordinator.py::_execute_multi_wave": 380,
- "src/thoth/application/workflows/thread_cycle.py::execute": 208,
+ "src/thoth/application/workflows/thread_cycle.py::execute": 207,
"src/thoth/apps/runtime.py::create_runtime": 627,
"src/thoth/protocol/notifications.py::notifications_for": 255
}
diff --git a/config/read-only-command-helpers.json b/config/read-only-command-helpers.json
index 6275dbe..7febad9 100644
--- a/config/read-only-command-helpers.json
+++ b/config/read-only-command-helpers.json
@@ -3,7 +3,7 @@
"scripts/check_module_budget.py": {
"allowed_argv": [[]],
"files": {
- "scripts/check_module_budget.py": "7a90010afcaaad5cff0adabe8941fa06f5075badb01b0e374fe5d8d163355aac"
+ "scripts/check_module_budget.py": "b8bacdf729f183be2a633cad55d00ceb9520e4a6acef4830065953ad8d85bb54"
}
}
},
diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md
index 05d5763..fbf9fdc 100644
--- a/docs/PACKAGING.md
+++ b/docs/PACKAGING.md
@@ -43,3 +43,10 @@ Git history. The private development repository and hosted service are not part
`SOURCE_MANIFEST.json` tracks hashes of the current reviewed public files. Its
`source_archive_sha256` is the digest of the original assembled 2026-09-25 archive, retained as
historical publication evidence; it is not a digest of later GitHub source ZIPs.
+
+Before opening a public pull request, run `python scripts/update_source_manifest.py` and commit the
+rebuilt `SOURCE_MANIFEST.json` with the change. The script lists every tracked file (plus untracked
+files that are not ignored) except the list itself, keeps the header fields, and sets `created_at` to
+the time of the rebuild. `python scripts/update_source_manifest.py --check` writes nothing and shows
+whether the list is stale. The package checks compare every listed file byte for byte, so a file
+edited after the rebuild fails them until the list is rebuilt again.
diff --git a/docs/architecture/memory-terms.md b/docs/architecture/memory-terms.md
new file mode 100644
index 0000000..6d2f813
--- /dev/null
+++ b/docs/architecture/memory-terms.md
@@ -0,0 +1,36 @@
+# 기억 검색 낱말 설계 메모
+
+범위: 조사가 붙은 한국어 질문이 기억과 같은 낱말을 말하는지 알아보는 규칙(K1), 그리고 질문 낱말을 모델로 넓히는 선택 기능(K2). 코드는 `domain/memory_terms.py`, `application/services/memory_recall.py`입니다.
+
+## 아이디어 출처
+
+- oh-my-openagent PR #9342, memory-core의 `bm25.ts`: 한글·한자·가나 글자 묶음을 두 글자씩 겹쳐 잘라 키워드 검색의 낱말로 쓰는 생각.
+- 두 저장소의 라이선스는 비상업 제한이라 코드를 가져오지 않았습니다. 생각만 참고해 이 저장소에서 새로 썼습니다.
+
+## K1: 낱말을 두 글자씩 겹쳐 자른다
+
+- 한글·한자·가나 글자 묶음이 3글자 이상이면 글자 묶음 자체와 겹치는 2글자 조각(자료를 → 자료, 료를)을 낱말의 모양으로 가집니다. 2글자 묶음은 그대로 하나입니다. 다른 문자와 숫자는 2자부터 낱말입니다("AI", "5G").
+- 질문 낱말은 그 묶음이나 조각 중 하나가 기억의 낱말에 있으면 맞은 것으로 칩니다. **맞은 수는 질문 낱말 하나에 한 번입니다.** 조각이 여러 개 맞아도 한 낱말입니다. 점수나 가중치는 만들지 않습니다.
+- 기억의 낱말은 저장해 둔 `query_terms`가 아니라 회상할 때 기억의 본문에서 다시 나눕니다. 옛 규칙으로 저장된 기억도 새 규칙으로 찾아집니다. `query_terms` 필드는 호환을 위해 그대로 저장합니다.
+- 적용하는 곳: `narrow_recall`(자동 기억은 질문 낱말 2개 이상, 사용자 정정은 1개), `order_recall`(맞은 낱말 수), `common_terms`(프로젝트 기억의 절반 이상에 있는 낱말), 저장할 때의 `content_reusable`(낱말 2개 이상).
+- 이번에 더한 안전장치(요청 사항 밖): 어미 자체인 조각("하다", "에서" 등)과 영어 두 글자 조사·관사("of", "to" 등)는 낱말로 세지 않습니다. 그대로 두면 서로 상관없는 문장이 어미만으로 맞습니다. 아주 긴 글자 묶음은 앞 2,000자까지만 조각을 만듭니다.
+
+## 알려진 한계
+
+- 두 글자 조각은 흔한 어간("결과", "공개", "확인")도 맞게 합니다. 서로 다른 주제의 기억이 낱말 둘로 우연히 통과할 수 있습니다. 측정은 `docs/verification/memory-terms-20261003.md`에 있습니다.
+
+## K2: 질문 넓히기 (조사당 모델 호출 한 번)
+
+질문의 낱말과 기억의 낱말이 달라서(예: 질문은 "분석", 기억은 "대조") 기억을 못 찾는 경우를 위해, 조사마다 모델에게 검색어를 한 번 덧붙이게 합니다. 모델 답은 검색어일 뿐이며 어떤 기억을 부를지는 결정하지 않습니다. 점수나 가중치도 없습니다.
+
+- 포트: `ports/memory.py`의 `MemoryQueryExpanderPort.expand(query) -> QueryExpansion`. `QueryExpansion`(`domain/memory_expansion.py`)은 동의어(`synonyms`), 주제를 다른 언어로 쓴 말(`keywords`), 관련어(`related`), 찾는 기억처럼 쓴 한 문장(`note_line`)이며 목록은 8개, 항목은 40자, 한 문장은 240자까지만 남깁니다. 모델 출력 상한은 300토큰입니다.
+- 구현: `application/services/memory_query_expansion.py`의 `ModelMemoryQueryExpander`. 기억 관계 판정기와 같이 조사가 쓰는 `ModelPort` 하나만 부르고(새 모델 경로 없음), 역할은 `MEMORY_QUERY_EXPANDER`입니다. 호출에는 `purpose=MEMORY_QUERY_EXPANSION`이 붙어, 조사의 결과별 사용량(`result_usage`)에 합계의 일부로 "기억 검색어 넓히기"가 따로 보입니다.
+- 호출 위치: `build_context`는 동기 함수라서 호출하는 쪽(`research_analysis.run`, `thread_analysis.analyze`)이 먼저 `widen_query`를 기다린 뒤 결과를 `expansion=`으로 넘깁니다. 한 조사 안에서 두 곳이 모두 부르므로 답은 `work.preprocessing_cache`에 두고 두 번째 호출자는 같은 답을 받습니다.
+- 부르지 않는 경우: 기억 스위치가 꺼짐, 넓히기 스위치가 꺼짐, 후보 기억이 없음(저장된 기억 중 COMMIT이고 불러올 수 있는 것이 하나도 없음), 앞 조사를 잇는 질문(낱말 조건을 건너뛰므로 넓힐 이유가 없음). 이유는 선정 기록에 `NOT_ASKED`와 함께 남습니다.
+- 실패: 호출 실패·20초 초과는 `EXPANSION_FAILED`, 형식이 틀린 답은 `EXPANSION_INVALID`로 기록하고 덧붙인 말 없이 평소대로 불러옵니다. 조사를 멈추는 신호(`ResearchFence`, 일시정지, 임대 상실)는 삼키지 않습니다.
+- 선정 규칙: 질문 낱말만으로 들어온 기억이 먼저, 덧붙인 말이 있어야 들어온 기억은 그 뒤에 따로 묶입니다. 자동 기억의 "맞은 낱말 2개" 조건은 둘을 합쳐 세며, 덧붙인 말 중 질문에 이미 있는 낱말(같은 낱말이거나 그 낱말의 두 글자 조각)은 세지 않습니다. 선정 기록의 `matches`에 기억마다 `matched_by`(QUERY | EXPANSION), 맞은 낱말, 덧붙인 말이 남고 `expansion`에 호출 결과(상태, 사용한 덧붙인 말, 호출 번호)가 남습니다.
+- 설정: 프로젝트별 "검색어 넓히기"(`ProjectMemorySettings.query_expansion`). 기본값은 `QUERY_EXPANSION_DEFAULT_ON` 상수 하나입니다. 실제 모델로 측정해 넓히지 않는 쪽이 낫다고 나오면 이 상수만 바꿉니다. `memory/settings/update`는 `query_expansion`을 생략하면 지금 값을 유지합니다.
+
+## K3: 통째로 맞은 낱말 하나는 필요하다
+
+두 글자 조각은 흔한 어간(결과, 공개, 확인)도 맞게 해서, 서로 다른 주제의 기억이 조각만으로 낱말 둘을 채워 들어오는 문제가 재현에서 확인되었습니다(docs/verification/memory-terms-20261003.md). 그래서 자동 기억은 질문 낱말 중 글자 묶음 그대로 맞은 것이 1개 이상 있어야 하고, 조각으로만 맞은 낱말은 약한 일치(match_strength=PARTIAL)로 선정 기록에 남깁니다. 덧붙인 말도 같은 원칙이며 사용자 정정의 기준(낱말 1개)은 그대로입니다.
diff --git a/docs/architecture/rpc-method-catalog.md b/docs/architecture/rpc-method-catalog.md
index 1daa6f8..7af57ba 100644
--- a/docs/architecture/rpc-method-catalog.md
+++ b/docs/architecture/rpc-method-catalog.md
@@ -2,7 +2,7 @@
This source-preview index is derived from the public method catalog, registered runtime method names, and public source schemas. It lists available RPC names; it is not a live-service, account-authorization, model-execution, or full-suite verification receipt. The machine-readable metadata and notification names are in [`schemas/protocol/public-method-catalog.json`](../../schemas/protocol/public-method-catalog.json).
-Requests use the public JSON-RPC `/rpc` surface. The method classification below distinguishes reads from commands; each call still follows the runtime's authentication and resource-scope checks. The six compatibility aliases remain listed under their own method names. The catalog contains 351 public methods (345 canonical and 6 aliases) and 221 notifications.
+Requests use the public JSON-RPC `/rpc` surface. The method classification below distinguishes reads from commands; each call still follows the runtime's authentication and resource-scope checks. The six compatibility aliases remain listed under their own method names. The catalog contains 362 public methods (356 canonical and 6 aliases) and 221 notifications.
## Local model credential boundary
@@ -18,6 +18,10 @@ Requests use the public JSON-RPC `/rpc` surface. The method classification below
These credential RPCs use the `system:workspace` scope in LOCAL mode. HOSTED_REVIEW denies them before the local handler. The local setup and settings screens consume the login status and cancel methods through `XaiDeviceLogin`; they do not convert a status read into a new login start. A command's operation record, broker worker state, and workspace profile are distinct effects.
+## Research request origin (trace row)
+
+No method was added. `thread/start` and `thread/input` with `contract_version=2` accept one optional `origin` that names a row of the project's verification trace: `kind` (`TRACE_VERDICT`), `project_id`, `subject_kind` (`CRITERION` or `REQUIREMENT`), `subject_id` and the `verdict_revision` digest the client was looking at. Nothing else is accepted. The server checks the name against the stored trace with the same project read rule as `trace/read`, refuses a missing row, another project or a revision that is no longer the row's current verdict, and writes the facts itself (state, computed reasons, rule summary, chosen result IDs, source positions). That server-written origin is stored with the request attempt, given to the model as context with the instruction that the rule's verdict is not to be re-judged, pins the row's source positions into the evidence shortlist (only spans that are already retrievable), and appears in the result as `origin`. `thread/input` with an origin must target the thread whose own origin is the same row; `thread/steer`, `contract_version=1` and an origin of any other shape are refused. A resend of an already accepted request is not checked again. `thread/list` adds `origin` (`subject_kind`, `subject_id`, `verdict_revision`, or null) to each thread so a client can open the conversation of a row. The origin adds no model call and is not part of the thread's data scope or memory scope.
+
## Method index
## 1. Action Namespace
@@ -45,6 +49,7 @@ action/authorization/decide
action/authorization/prepare
action/compensation/create
action/create
+action/draft/fromTest
action/generate
action/impact/recalculate
action/merge/propose
@@ -225,6 +230,7 @@ hypothesis/appraisal/read
hypothesis/assumption/list
hypothesis/audit/read
hypothesis/graph/read
+hypothesis/link/list
hypothesis/list
hypothesis/portfolio/list
hypothesis/portfolio/read
@@ -232,8 +238,10 @@ hypothesis/prediction/list
hypothesis/prediction/read
hypothesis/quality/read
hypothesis/read
+hypothesis/test/result/list
hypothesis/relation/list
hypothesis/review/list
+hypothesis/same/list
```
### Commands
@@ -246,16 +254,20 @@ hypothesis/counterevidence/request
hypothesis/create
hypothesis/generate
hypothesis/intent/update
+hypothesis/link/recheck
hypothesis/merge/propose
hypothesis/portfolio/compose
hypothesis/portfolio/revalidate
hypothesis/prediction/bind
+hypothesis/refutation/record
hypothesis/relation/add
hypothesis/relation/remove
hypothesis/review/request
hypothesis/revise
+hypothesis/same/record
hypothesis/split/propose
hypothesis/test/bind
+hypothesis/test/result/record
```
## 9. Improvement Namespace
@@ -598,7 +610,28 @@ thread/steer
thread/stop
```
-## 21. Workspace Namespace
+## 21. Trace Namespace
+
+### Queries
+
+```text
+trace/closure/list
+trace/export
+trace/history
+trace/importPreview
+trace/lesson/list
+trace/read
+```
+
+### Commands
+
+```text
+trace/closure/record
+trace/confirm
+trace/importApply
+```
+
+## 22. Workspace Namespace
### Queries
diff --git a/examples/synthetic-radar-demo-v1-scorer/40_ASSESSMENT_DRAFT_SYNTHETIC.yaml b/examples/synthetic-radar-demo-v1-scorer/40_ASSESSMENT_DRAFT_SYNTHETIC.yaml
new file mode 100644
index 0000000..9b22a9b
--- /dev/null
+++ b/examples/synthetic-radar-demo-v1-scorer/40_ASSESSMENT_DRAFT_SYNTHETIC.yaml
@@ -0,0 +1,15 @@
+# SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE
+notice: SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE
+identity: THOTH-SYNTH-RADAR-DEMO-v1
+document: assessment_draft
+# Scorer only: never connect this file to a demo project. It states what the rule should give.
+rule_summary: comparator and threshold of each criterion are in 10_REQUIREMENTS_SYNTHETIC.yaml; no condition is averaged with another
+phases:
+ 1:
+ available_results: [SYN-TC-DRY]
+ criteria: {SYN-C-DET-DRY: PASS_COMPUTED, SYN-C-FA-DRY: PASS_COMPUTED, SYN-C-DET-RAIN: HOLD_NO_RESULT, SYN-C-FA-RAIN: HOLD_NO_RESULT, SYN-C-DET-FOG: HOLD_NO_RESULT, SYN-C-FA-FOG: HOLD_NO_RESULT}
+ requirement: {SYN-REQ-RAD-001: HOLD_INCOMPLETE}
+ 2:
+ available_results: [SYN-TC-DRY, SYN-TC-RAIN]
+ criteria: {SYN-C-DET-DRY: PASS_COMPUTED, SYN-C-FA-DRY: PASS_COMPUTED, SYN-C-DET-RAIN: FAIL_COMPUTED, SYN-C-FA-RAIN: FAIL_COMPUTED, SYN-C-DET-FOG: HOLD_NO_RESULT, SYN-C-FA-FOG: HOLD_NO_RESULT}
+ requirement: {SYN-REQ-RAD-001: FAIL_WITH_INCOMPLETE_COVERAGE}
diff --git a/examples/synthetic-radar-demo-v1/00_MANIFEST_SYNTHETIC.yaml b/examples/synthetic-radar-demo-v1/00_MANIFEST_SYNTHETIC.yaml
new file mode 100644
index 0000000..e43d826
--- /dev/null
+++ b/examples/synthetic-radar-demo-v1/00_MANIFEST_SYNTHETIC.yaml
@@ -0,0 +1,15 @@
+# SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE
+notice: SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE
+identity: THOTH-SYNTH-RADAR-DEMO-v1
+document: manifest
+integrity_statement: "The SHA-256 values below only show that these files have not changed since the manifest was written. They do not show that the data is true, measured, approved or fit for any use."
+hash_scope: "Raw file bytes (files are stored with LF line endings). This manifest is not hashed."
+files:
+ - {path: "01_SOURCE_LINEAGE.yaml", sha256: "6b0991c6b74e779d6809717717abbd1bf7ee5b681a3db971b3d76e283fbd3a2b", bytes: 727}
+ - {path: "10_REQUIREMENTS_SYNTHETIC.yaml", sha256: "bf368e4e1391ffd76bcdce9b8a8ea1447da20a7cdac735aad337181802e714c7", bytes: 1569}
+ - {path: "20_TEST_PLAN_SYNTHETIC.yaml", sha256: "5838eba3660b34b763e60019892f6070523414cd9c896744b3b9f7fbcd880b32", bytes: 675}
+ - {path: "30_RESULT_DRY_SYNTHETIC.yaml", sha256: "77acaacd120bec4c30431bb32a204e183a83139111a231d2a21d89aef816914a", bytes: 676}
+ - {path: "31_RESULT_RAIN_SYNTHETIC.yaml", sha256: "a0b0ffc698ef898a8e9fd6e426d1ec12ff995a59fbedb1f025d2e8873f677d33", bytes: 752}
+ - {path: "data/30_DRY_TRIALS_SYNTHETIC.csv", sha256: "af10b01f1885b8d9147c88d609af0c428d205d69b151e2b29af198acf0fd18c6", bytes: 3684}
+ - {path: "data/31_RAIN_TRIALS_SYNTHETIC.csv", sha256: "e915da8c2a225784a27ecf5c0d9cdb0aa59498cfb57285eb56c4b85f99173a98", bytes: 4224}
+ - {path: "schema/synthetic-radar-demo-v1.schema.json", sha256: "91162758f0232e372703d5ecef6ede7102956df959728b8e1bdcd4acf3690996", bytes: 6804}
diff --git a/examples/synthetic-radar-demo-v1/01_SOURCE_LINEAGE.yaml b/examples/synthetic-radar-demo-v1/01_SOURCE_LINEAGE.yaml
new file mode 100644
index 0000000..ce182cf
--- /dev/null
+++ b/examples/synthetic-radar-demo-v1/01_SOURCE_LINEAGE.yaml
@@ -0,0 +1,11 @@
+# SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE
+notice: SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE
+identity: THOTH-SYNTH-RADAR-DEMO-v1
+document: source_lineage
+origin: written by the THOTH project for this demo
+external_data_bytes: none
+statements:
+ - Every threshold, weather condition, trial, date and count in this folder is an assumption invented for the demo.
+ - Counts and rates are derived from the trial files in data/ by the validator script.
+ - No measurement, dataset, figure, table or sentence of any external radar dataset or report is included.
+ - Generic terms such as detection rate and false track are used only as ordinary engineering words.
diff --git a/examples/synthetic-radar-demo-v1/10_REQUIREMENTS_SYNTHETIC.yaml b/examples/synthetic-radar-demo-v1/10_REQUIREMENTS_SYNTHETIC.yaml
new file mode 100644
index 0000000..7531a2f
--- /dev/null
+++ b/examples/synthetic-radar-demo-v1/10_REQUIREMENTS_SYNTHETIC.yaml
@@ -0,0 +1,15 @@
+# SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE
+notice: SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE
+identity: THOTH-SYNTH-RADAR-DEMO-v1
+document: requirements
+requirement:
+ id: SYN-REQ-RAD-001
+ title: Synthetic radar detects a target and avoids false tracks in dry, rain and fog (invented example)
+ statement: The synthetic radar meets its detection and false-track limits in every listed weather condition.
+ criteria:
+ - {id: SYN-C-DET-DRY, title: Detection rate in dry weather, measure: detection_rate, comparator: ">=", threshold: "0.90", unit: ratio, condition: weather=dry, required: true}
+ - {id: SYN-C-FA-DRY, title: False tracks per minute in dry weather, measure: false_track_rate, comparator: "<=", threshold: "0.50", unit: per_min, condition: weather=dry, required: true}
+ - {id: SYN-C-DET-RAIN, title: Detection rate in rain, measure: detection_rate, comparator: ">=", threshold: "0.90", unit: ratio, condition: weather=rain, required: true}
+ - {id: SYN-C-FA-RAIN, title: False tracks per minute in rain, measure: false_track_rate, comparator: "<=", threshold: "0.50", unit: per_min, condition: weather=rain, required: true}
+ - {id: SYN-C-DET-FOG, title: Detection rate in fog, measure: detection_rate, comparator: ">=", threshold: "0.90", unit: ratio, condition: weather=fog, required: true}
+ - {id: SYN-C-FA-FOG, title: False tracks per minute in fog, measure: false_track_rate, comparator: "<=", threshold: "0.50", unit: per_min, condition: weather=fog, required: true}
diff --git a/examples/synthetic-radar-demo-v1/20_TEST_PLAN_SYNTHETIC.yaml b/examples/synthetic-radar-demo-v1/20_TEST_PLAN_SYNTHETIC.yaml
new file mode 100644
index 0000000..86ab89d
--- /dev/null
+++ b/examples/synthetic-radar-demo-v1/20_TEST_PLAN_SYNTHETIC.yaml
@@ -0,0 +1,8 @@
+# SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE
+notice: SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE
+identity: THOTH-SYNTH-RADAR-DEMO-v1
+document: test_plan
+test_cases:
+ - {id: SYN-TC-DRY, title: Dry weather run, condition: weather=dry, covers: [SYN-C-DET-DRY, SYN-C-FA-DRY], target_trials: 20, observation_minutes: 10}
+ - {id: SYN-TC-RAIN, title: Rain run, condition: weather=rain, covers: [SYN-C-DET-RAIN, SYN-C-FA-RAIN], target_trials: 20, observation_minutes: 10}
+ - {id: SYN-TC-FOG, title: Fog run, condition: weather=fog, covers: [SYN-C-DET-FOG, SYN-C-FA-FOG], target_trials: 20, observation_minutes: 10}
diff --git a/examples/synthetic-radar-demo-v1/30_RESULT_DRY_SYNTHETIC.yaml b/examples/synthetic-radar-demo-v1/30_RESULT_DRY_SYNTHETIC.yaml
new file mode 100644
index 0000000..d0caf26
--- /dev/null
+++ b/examples/synthetic-radar-demo-v1/30_RESULT_DRY_SYNTHETIC.yaml
@@ -0,0 +1,23 @@
+# SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE
+notice: SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE
+identity: THOTH-SYNTH-RADAR-DEMO-v1
+document: result
+available_from_phase: 1
+condition: weather=dry
+test_case: SYN-TC-DRY
+observed_at: "2026-01-01T00:00:00Z" # assumed date
+trials_csv: data/30_DRY_TRIALS_SYNTHETIC.csv
+detection:
+ criterion: SYN-C-DET-DRY
+ numerator: 19
+ denominator: 20
+ unit: ratio
+ value: "0.95"
+false_track:
+ criterion: SYN-C-FA-DRY
+ numerator: 3
+ denominator: 10
+ unit: per_min
+ value: "0.30"
+ observation_minutes: 10
+ event_ids: [SYN-FT-DRY-001, SYN-FT-DRY-002, SYN-FT-DRY-003]
diff --git a/examples/synthetic-radar-demo-v1/31_RESULT_RAIN_SYNTHETIC.yaml b/examples/synthetic-radar-demo-v1/31_RESULT_RAIN_SYNTHETIC.yaml
new file mode 100644
index 0000000..4f437fc
--- /dev/null
+++ b/examples/synthetic-radar-demo-v1/31_RESULT_RAIN_SYNTHETIC.yaml
@@ -0,0 +1,23 @@
+# SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE
+notice: SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE
+identity: THOTH-SYNTH-RADAR-DEMO-v1
+document: result
+available_from_phase: 2
+condition: weather=rain
+test_case: SYN-TC-RAIN
+observed_at: "2026-01-02T00:00:00Z" # assumed date
+trials_csv: data/31_RAIN_TRIALS_SYNTHETIC.csv
+detection:
+ criterion: SYN-C-DET-RAIN
+ numerator: 16
+ denominator: 20
+ unit: ratio
+ value: "0.80"
+false_track:
+ criterion: SYN-C-FA-RAIN
+ numerator: 7
+ denominator: 10
+ unit: per_min
+ value: "0.70"
+ observation_minutes: 10
+ event_ids: [SYN-FT-RAIN-001, SYN-FT-RAIN-002, SYN-FT-RAIN-003, SYN-FT-RAIN-004, SYN-FT-RAIN-005, SYN-FT-RAIN-006, SYN-FT-RAIN-007]
diff --git a/examples/synthetic-radar-demo-v1/data/30_DRY_TRIALS_SYNTHETIC.csv b/examples/synthetic-radar-demo-v1/data/30_DRY_TRIALS_SYNTHETIC.csv
new file mode 100644
index 0000000..110de26
--- /dev/null
+++ b/examples/synthetic-radar-demo-v1/data/30_DRY_TRIALS_SYNTHETIC.csv
@@ -0,0 +1,34 @@
+synthetic_notice,row_kind,row_id,window_id,detected,observation_minutes
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-DRY-01,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-DRY-02,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-DRY-03,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-DRY-04,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-DRY-05,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-DRY-06,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-DRY-07,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-DRY-08,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-DRY-09,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-DRY-10,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-DRY-11,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-DRY-12,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-DRY-13,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-DRY-14,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-DRY-15,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-DRY-16,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-DRY-17,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-DRY-18,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-DRY-19,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-DRY-20,,0,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,OBSERVATION_WINDOW,SYN-W-DRY-01,,,1
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,OBSERVATION_WINDOW,SYN-W-DRY-02,,,1
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,OBSERVATION_WINDOW,SYN-W-DRY-03,,,1
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,OBSERVATION_WINDOW,SYN-W-DRY-04,,,1
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,OBSERVATION_WINDOW,SYN-W-DRY-05,,,1
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,OBSERVATION_WINDOW,SYN-W-DRY-06,,,1
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,OBSERVATION_WINDOW,SYN-W-DRY-07,,,1
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,OBSERVATION_WINDOW,SYN-W-DRY-08,,,1
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,OBSERVATION_WINDOW,SYN-W-DRY-09,,,1
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,OBSERVATION_WINDOW,SYN-W-DRY-10,,,1
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,FALSE_TRACK_EVENT,SYN-FT-DRY-001,SYN-W-DRY-02,,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,FALSE_TRACK_EVENT,SYN-FT-DRY-002,SYN-W-DRY-05,,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,FALSE_TRACK_EVENT,SYN-FT-DRY-003,SYN-W-DRY-09,,
diff --git a/examples/synthetic-radar-demo-v1/data/31_RAIN_TRIALS_SYNTHETIC.csv b/examples/synthetic-radar-demo-v1/data/31_RAIN_TRIALS_SYNTHETIC.csv
new file mode 100644
index 0000000..c07eac8
--- /dev/null
+++ b/examples/synthetic-radar-demo-v1/data/31_RAIN_TRIALS_SYNTHETIC.csv
@@ -0,0 +1,38 @@
+synthetic_notice,row_kind,row_id,window_id,detected,observation_minutes
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-RAIN-01,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-RAIN-02,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-RAIN-03,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-RAIN-04,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-RAIN-05,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-RAIN-06,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-RAIN-07,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-RAIN-08,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-RAIN-09,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-RAIN-10,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-RAIN-11,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-RAIN-12,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-RAIN-13,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-RAIN-14,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-RAIN-15,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-RAIN-16,,1,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-RAIN-17,,0,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-RAIN-18,,0,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-RAIN-19,,0,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,TARGET_TRIAL,SYN-T-RAIN-20,,0,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,OBSERVATION_WINDOW,SYN-W-RAIN-01,,,1
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,OBSERVATION_WINDOW,SYN-W-RAIN-02,,,1
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,OBSERVATION_WINDOW,SYN-W-RAIN-03,,,1
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,OBSERVATION_WINDOW,SYN-W-RAIN-04,,,1
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,OBSERVATION_WINDOW,SYN-W-RAIN-05,,,1
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,OBSERVATION_WINDOW,SYN-W-RAIN-06,,,1
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,OBSERVATION_WINDOW,SYN-W-RAIN-07,,,1
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,OBSERVATION_WINDOW,SYN-W-RAIN-08,,,1
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,OBSERVATION_WINDOW,SYN-W-RAIN-09,,,1
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,OBSERVATION_WINDOW,SYN-W-RAIN-10,,,1
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,FALSE_TRACK_EVENT,SYN-FT-RAIN-001,SYN-W-RAIN-01,,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,FALSE_TRACK_EVENT,SYN-FT-RAIN-002,SYN-W-RAIN-02,,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,FALSE_TRACK_EVENT,SYN-FT-RAIN-003,SYN-W-RAIN-04,,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,FALSE_TRACK_EVENT,SYN-FT-RAIN-004,SYN-W-RAIN-06,,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,FALSE_TRACK_EVENT,SYN-FT-RAIN-005,SYN-W-RAIN-07,,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,FALSE_TRACK_EVENT,SYN-FT-RAIN-006,SYN-W-RAIN-09,,
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE,FALSE_TRACK_EVENT,SYN-FT-RAIN-007,SYN-W-RAIN-10,,
diff --git a/examples/synthetic-radar-demo-v1/schema/synthetic-radar-demo-v1.schema.json b/examples/synthetic-radar-demo-v1/schema/synthetic-radar-demo-v1.schema.json
new file mode 100644
index 0000000..00ebd15
--- /dev/null
+++ b/examples/synthetic-radar-demo-v1/schema/synthetic-radar-demo-v1.schema.json
@@ -0,0 +1,167 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "THOTH synthetic radar demo v1 - document shapes",
+ "description": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE. Checked by scripts/validate_synthetic_radar_demo.py, which understands only the keywords used here (type, required, properties, additionalProperties, items, enum, const, pattern, minItems, oneOf, $ref).",
+ "type": "object",
+ "required": ["notice", "identity", "document"],
+ "oneOf": [
+ {"$ref": "#/$defs/manifest"},
+ {"$ref": "#/$defs/source_lineage"},
+ {"$ref": "#/$defs/requirements"},
+ {"$ref": "#/$defs/test_plan"},
+ {"$ref": "#/$defs/result"}
+ ],
+ "$defs": {
+ "notice": {"const": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"},
+ "identity": {"const": "THOTH-SYNTH-RADAR-DEMO-v1"},
+ "id": {"type": "string", "pattern": "^SYN-[A-Z0-9-]+$"},
+ "decimal": {"type": "string", "pattern": "^[0-9]+(\\.[0-9]+)?$"},
+ "condition": {"type": "string", "pattern": "^weather=(dry|rain|fog)$"},
+ "manifest": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["notice", "identity", "document", "integrity_statement", "hash_scope", "files"],
+ "properties": {
+ "notice": {"$ref": "#/$defs/notice"},
+ "identity": {"$ref": "#/$defs/identity"},
+ "document": {"const": "manifest"},
+ "integrity_statement": {"type": "string"},
+ "hash_scope": {"type": "string"},
+ "files": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["path", "sha256", "bytes"],
+ "properties": {
+ "path": {"type": "string"},
+ "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
+ "bytes": {"type": "integer"}
+ }
+ }
+ }
+ }
+ },
+ "source_lineage": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["notice", "identity", "document", "origin", "external_data_bytes", "statements"],
+ "properties": {
+ "notice": {"$ref": "#/$defs/notice"},
+ "identity": {"$ref": "#/$defs/identity"},
+ "document": {"const": "source_lineage"},
+ "origin": {"type": "string"},
+ "external_data_bytes": {"const": "none"},
+ "statements": {"type": "array", "minItems": 1, "items": {"type": "string"}}
+ }
+ },
+ "requirements": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["notice", "identity", "document", "requirement"],
+ "properties": {
+ "notice": {"$ref": "#/$defs/notice"},
+ "identity": {"$ref": "#/$defs/identity"},
+ "document": {"const": "requirements"},
+ "requirement": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "title", "statement", "criteria"],
+ "properties": {
+ "id": {"$ref": "#/$defs/id"},
+ "title": {"type": "string"},
+ "statement": {"type": "string"},
+ "criteria": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "title", "measure", "comparator", "threshold", "unit", "condition", "required"],
+ "properties": {
+ "id": {"$ref": "#/$defs/id"},
+ "title": {"type": "string"},
+ "measure": {"enum": ["detection_rate", "false_track_rate"]},
+ "comparator": {"enum": [">=", "<="]},
+ "threshold": {"$ref": "#/$defs/decimal"},
+ "unit": {"enum": ["ratio", "per_min"]},
+ "condition": {"$ref": "#/$defs/condition"},
+ "required": {"type": "boolean"}
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "test_plan": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["notice", "identity", "document", "test_cases"],
+ "properties": {
+ "notice": {"$ref": "#/$defs/notice"},
+ "identity": {"$ref": "#/$defs/identity"},
+ "document": {"const": "test_plan"},
+ "test_cases": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "title", "condition", "covers", "target_trials", "observation_minutes"],
+ "properties": {
+ "id": {"$ref": "#/$defs/id"},
+ "title": {"type": "string"},
+ "condition": {"$ref": "#/$defs/condition"},
+ "covers": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/id"}},
+ "target_trials": {"type": "integer"},
+ "observation_minutes": {"type": "integer"}
+ }
+ }
+ }
+ }
+ },
+ "result": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["notice", "identity", "document", "available_from_phase", "condition", "test_case", "observed_at", "trials_csv", "detection", "false_track"],
+ "properties": {
+ "notice": {"$ref": "#/$defs/notice"},
+ "identity": {"$ref": "#/$defs/identity"},
+ "document": {"const": "result"},
+ "available_from_phase": {"enum": [1, 2]},
+ "condition": {"$ref": "#/$defs/condition"},
+ "test_case": {"$ref": "#/$defs/id"},
+ "observed_at": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:]+Z$"},
+ "trials_csv": {"type": "string"},
+ "detection": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["criterion", "numerator", "denominator", "unit", "value"],
+ "properties": {
+ "criterion": {"$ref": "#/$defs/id"},
+ "numerator": {"type": "integer"},
+ "denominator": {"type": "integer"},
+ "unit": {"const": "ratio"},
+ "value": {"$ref": "#/$defs/decimal"}
+ }
+ },
+ "false_track": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["criterion", "numerator", "denominator", "unit", "value", "observation_minutes", "event_ids"],
+ "properties": {
+ "criterion": {"$ref": "#/$defs/id"},
+ "numerator": {"type": "integer"},
+ "denominator": {"type": "integer"},
+ "unit": {"const": "per_min"},
+ "value": {"$ref": "#/$defs/decimal"},
+ "observation_minutes": {"type": "integer"},
+ "event_ids": {"type": "array", "items": {"$ref": "#/$defs/id"}}
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/schemas/protocol/public-method-catalog.json b/schemas/protocol/public-method-catalog.json
index 3cf9d29..6faa389 100644
--- a/schemas/protocol/public-method-catalog.json
+++ b/schemas/protocol/public-method-catalog.json
@@ -1,9 +1,9 @@
{
"schema_version": "1.0.0",
"authority": "docs/architecture/rpc-method-catalog.md",
- "canonical_method_count": 345,
+ "canonical_method_count": 362,
"compatibility_alias_count": 6,
- "runtime_method_count": 351,
+ "runtime_method_count": 368,
"notification_count": 221,
"methods": [
{
@@ -96,6 +96,21 @@
"alias_target": null,
"runtime_status": "IMPLEMENTED"
},
+ {
+ "name": "action/draft/fromTest",
+ "namespace": "action",
+ "surface": "COMMAND",
+ "canonical_owner": "ACTION_PLAN_AUTHORIZATION",
+ "policy": "action:versioned-policy",
+ "normal_entrypoint": "thread/input",
+ "behavioral_evidence": [
+ "A04",
+ "A05"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED"
+ },
{
"name": "action/generate",
"namespace": "action",
@@ -1885,6 +1900,36 @@
"alias_target": null,
"runtime_status": "IMPLEMENTED"
},
+ {
+ "name": "hypothesis/link/list",
+ "namespace": "hypothesis",
+ "surface": "QUERY",
+ "canonical_owner": "HYPOTHESIS",
+ "policy": "hypothesis:versioned-policy",
+ "normal_entrypoint": "thread/input",
+ "behavioral_evidence": [
+ "A01",
+ "A03"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED"
+ },
+ {
+ "name": "hypothesis/link/recheck",
+ "namespace": "hypothesis",
+ "surface": "COMMAND",
+ "canonical_owner": "HYPOTHESIS",
+ "policy": "hypothesis:versioned-policy",
+ "normal_entrypoint": "thread/input",
+ "behavioral_evidence": [
+ "A01",
+ "A03"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED"
+ },
{
"name": "hypothesis/list",
"namespace": "hypothesis",
@@ -2050,6 +2095,21 @@
"alias_target": null,
"runtime_status": "IMPLEMENTED"
},
+ {
+ "name": "hypothesis/refutation/record",
+ "namespace": "hypothesis",
+ "surface": "COMMAND",
+ "canonical_owner": "HYPOTHESIS",
+ "policy": "hypothesis:versioned-policy",
+ "normal_entrypoint": "thread/input",
+ "behavioral_evidence": [
+ "A01",
+ "A03"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED"
+ },
{
"name": "hypothesis/relation/add",
"namespace": "hypothesis",
@@ -2140,6 +2200,36 @@
"alias_target": null,
"runtime_status": "IMPLEMENTED"
},
+ {
+ "name": "hypothesis/same/list",
+ "namespace": "hypothesis",
+ "surface": "QUERY",
+ "canonical_owner": "HYPOTHESIS",
+ "policy": "hypothesis:versioned-policy",
+ "normal_entrypoint": "thread/input",
+ "behavioral_evidence": [
+ "A01",
+ "A03"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED"
+ },
+ {
+ "name": "hypothesis/same/record",
+ "namespace": "hypothesis",
+ "surface": "COMMAND",
+ "canonical_owner": "HYPOTHESIS",
+ "policy": "hypothesis:versioned-policy",
+ "normal_entrypoint": "thread/input",
+ "behavioral_evidence": [
+ "A01",
+ "A03"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED"
+ },
{
"name": "hypothesis/split/propose",
"namespace": "hypothesis",
@@ -2170,6 +2260,36 @@
"alias_target": null,
"runtime_status": "IMPLEMENTED"
},
+ {
+ "name": "hypothesis/test/result/list",
+ "namespace": "hypothesis",
+ "surface": "QUERY",
+ "canonical_owner": "HYPOTHESIS",
+ "policy": "hypothesis:versioned-policy",
+ "normal_entrypoint": "thread/input",
+ "behavioral_evidence": [
+ "A01",
+ "A03"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED"
+ },
+ {
+ "name": "hypothesis/test/result/record",
+ "namespace": "hypothesis",
+ "surface": "COMMAND",
+ "canonical_owner": "HYPOTHESIS",
+ "policy": "hypothesis:versioned-policy",
+ "normal_entrypoint": "thread/input",
+ "behavioral_evidence": [
+ "A01",
+ "A03"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED"
+ },
{
"name": "improvement/audit/read",
"namespace": "improvement",
@@ -5020,6 +5140,132 @@
"alias_target": null,
"runtime_status": "IMPLEMENTED"
},
+ {
+ "name": "trace/closure/list",
+ "namespace": "trace",
+ "surface": "QUERY",
+ "canonical_owner": "VERIFICATION_TRACE",
+ "policy": "trace:versioned-policy",
+ "normal_entrypoint": "trace/read|trace/importApply",
+ "behavioral_evidence": [
+ "B3"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED"
+ },
+ {
+ "name": "trace/closure/record",
+ "namespace": "trace",
+ "surface": "COMMAND",
+ "canonical_owner": "VERIFICATION_TRACE",
+ "policy": "trace:versioned-policy",
+ "normal_entrypoint": "trace/read|trace/importApply",
+ "behavioral_evidence": [
+ "B3"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED"
+ },
+ {
+ "name": "trace/confirm",
+ "namespace": "trace",
+ "surface": "COMMAND",
+ "canonical_owner": "VERIFICATION_TRACE",
+ "policy": "trace:versioned-policy",
+ "normal_entrypoint": "trace/read|trace/importApply",
+ "behavioral_evidence": [
+ "B3"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED"
+ },
+ {
+ "name": "trace/export",
+ "namespace": "trace",
+ "surface": "QUERY",
+ "canonical_owner": "VERIFICATION_TRACE",
+ "policy": "trace:versioned-policy",
+ "normal_entrypoint": "trace/read|trace/importApply",
+ "behavioral_evidence": [
+ "B3"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED"
+ },
+ {
+ "name": "trace/history",
+ "namespace": "trace",
+ "surface": "QUERY",
+ "canonical_owner": "VERIFICATION_TRACE",
+ "policy": "trace:versioned-policy",
+ "normal_entrypoint": "trace/read|trace/importApply",
+ "behavioral_evidence": [
+ "B3"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED"
+ },
+ {
+ "name": "trace/importApply",
+ "namespace": "trace",
+ "surface": "COMMAND",
+ "canonical_owner": "VERIFICATION_TRACE",
+ "policy": "trace:versioned-policy",
+ "normal_entrypoint": "trace/read|trace/importApply",
+ "behavioral_evidence": [
+ "B3"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED"
+ },
+ {
+ "name": "trace/importPreview",
+ "namespace": "trace",
+ "surface": "QUERY",
+ "canonical_owner": "VERIFICATION_TRACE",
+ "policy": "trace:versioned-policy",
+ "normal_entrypoint": "trace/read|trace/importApply",
+ "behavioral_evidence": [
+ "B3"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED"
+ },
+ {
+ "name": "trace/lesson/list",
+ "namespace": "trace",
+ "surface": "QUERY",
+ "canonical_owner": "VERIFICATION_TRACE",
+ "policy": "trace:versioned-policy",
+ "normal_entrypoint": "trace/read|trace/importApply",
+ "behavioral_evidence": [
+ "B3"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED"
+ },
+ {
+ "name": "trace/read",
+ "namespace": "trace",
+ "surface": "QUERY",
+ "canonical_owner": "VERIFICATION_TRACE",
+ "policy": "trace:versioned-policy",
+ "normal_entrypoint": "trace/read|trace/importApply",
+ "behavioral_evidence": [
+ "B3"
+ ],
+ "canonical": true,
+ "alias_target": null,
+ "runtime_status": "IMPLEMENTED"
+ },
{
"name": "workspace/ready",
"namespace": "workspace",
@@ -5286,5 +5532,5 @@
"thread/waitingForInput",
"thread/waitingForProtectedAction"
],
- "catalog_digest": "9ab42a2fa3b67e648e1e7b5b9f38c609a93429597c769f7c00b780a7db1cae9b"
+ "catalog_digest": "1c7d4799ca9b778a5ba023f66a79b60671e68ffc1e8f57ba295144a403a35798"
}
diff --git a/scripts/build_public_method_catalog.py b/scripts/build_public_method_catalog.py
index 9b54724..29607dd 100644
--- a/scripts/build_public_method_catalog.py
+++ b/scripts/build_public_method_catalog.py
@@ -67,6 +67,7 @@
"export": "CLOSURE_EXPORT",
"projectpack": "PROJECTPACK_MANIFEST",
"field": "FIELD_MEASUREMENT_LEDGER",
+ "trace": "VERIFICATION_TRACE",
}
ACCEPTANCE = {
@@ -91,6 +92,7 @@
"export": "A10",
"projectpack": "A01",
"field": "A12",
+ "trace": "B3",
}
ENTRYPOINT = {
@@ -117,6 +119,7 @@
"export": "export/plan/create",
"projectpack": "projectpack/run",
"field": "field/protocol/seal|normal RPC fieldSessionId",
+ "trace": "trace/read|trace/importApply",
}
CREDENTIAL_METHOD_METADATA: dict[str, dict[str, object]] = {
diff --git a/scripts/check_module_budget.py b/scripts/check_module_budget.py
index ee482c4..d6b90aa 100644
--- a/scripts/check_module_budget.py
+++ b/scripts/check_module_budget.py
@@ -45,6 +45,48 @@ def _long_functions(path: Path, tree: ast.Module, limit: int) -> dict[str, int]:
return result
+def _check_size(
+ relative: str,
+ lines: int,
+ module_limit: int,
+ watched: dict[str, dict[str, object]],
+ seen_modules: set[str],
+ errors: list[str],
+) -> dict[str, object] | None:
+ """The size rule for one file, the same for Python and web files; returns its watch entry."""
+ if lines > module_limit and relative not in watched:
+ errors.append(f"new oversized module is not ratcheted: {relative}={lines}")
+ if relative not in watched:
+ return None
+ seen_modules.add(relative)
+ definition = watched[relative]
+ baseline = _integer(definition["baseline_lines"], f"{relative}.baseline_lines")
+ if lines != baseline:
+ direction = "grew" if lines > baseline else "shrank"
+ errors.append(
+ f"watched module {direction}; update/refactor the ratchet: "
+ f"{relative} actual={lines} baseline={baseline}"
+ )
+ return definition
+
+
+def _web_files(root: Path, manifest: dict[str, Any]) -> list[Path]:
+ """Web sources (.ts/.tsx) under the web scan roots, without the excluded suffixes.
+
+ Test files are not measured: like the Python tests (outside scan_roots) they are long by
+ nature and are not product responsibility. Lines only; there is no syntax tree for them.
+ """
+ excluded = tuple(
+ str(value) for value in cast(list[object], manifest.get("web_excluded_suffixes", []))
+ )
+ found: list[Path] = []
+ for scan_root in cast(list[object], manifest.get("web_scan_roots", [])):
+ base = root / str(scan_root)
+ for pattern in ("*.ts", "*.tsx"):
+ found.extend(path for path in base.rglob(pattern) if not path.name.endswith(excluded))
+ return sorted(found)
+
+
def build_report(root: Path, manifest_path: Path) -> dict[str, object]:
manifest = _load(manifest_path)
module_limit = int(manifest["module_line_limit"])
@@ -66,19 +108,9 @@ def build_report(root: Path, manifest_path: Path) -> dict[str, object]:
lines = len(text.splitlines())
tree = ast.parse(text, filename=str(path))
actual_long.update(_long_functions(Path(relative), tree, function_limit))
- if lines > module_limit and relative not in watched:
- errors.append(f"new oversized module is not ratcheted: {relative}={lines}")
- if relative not in watched:
+ definition = _check_size(relative, lines, module_limit, watched, seen_modules, errors)
+ if definition is None:
continue
- seen_modules.add(relative)
- definition = watched[relative]
- baseline = _integer(definition["baseline_lines"], f"{relative}.baseline_lines")
- if lines != baseline:
- direction = "grew" if lines > baseline else "shrank"
- errors.append(
- f"watched module {direction}; update/refactor the ratchet: "
- f"{relative} actual={lines} baseline={baseline}"
- )
expected_symbols = tuple(
str(value) for value in cast(list[object], definition["top_level_symbols"])
)
@@ -89,11 +121,15 @@ def build_report(root: Path, manifest_path: Path) -> dict[str, object]:
f"actual={list(symbols)} expected={list(expected_symbols)}"
)
+ for path in _web_files(root, manifest):
+ relative = path.relative_to(root).as_posix()
+ lines = len(path.read_text(encoding="utf-8").splitlines())
+ _check_size(relative, lines, module_limit, watched, seen_modules, errors)
+
missing_modules = sorted(set(watched) - seen_modules)
if missing_modules:
errors.append(
- "watched modules missing; update ratchet after extraction: "
- f"{missing_modules}"
+ f"watched modules missing; update ratchet after extraction: {missing_modules}"
)
if actual_long != expected_long:
diff --git a/scripts/hypothesis_v3_metrics.py b/scripts/hypothesis_v3_metrics.py
new file mode 100644
index 0000000..81d6cac
--- /dev/null
+++ b/scripts/hypothesis_v3_metrics.py
@@ -0,0 +1,110 @@
+"""Read-only: count what a hypothesis result gives under the v2 or v3 contract (no model call).
+
+Usage:
+ python scripts/hypothesis_v3_metrics.py result.json [more.json ...]
+
+The input is a result JSON that holds a hypothesis portfolio: the portfolio itself,
+`{"portfolio": ...}`, `{"result": {"portfolio": ...}}` or a `thread/read` answer with
+`current_result.result.portfolio`. A v2 result has no expected-result tables, so its table
+counts are zero; that is the comparison point.
+
+The test order on the screen (the READY group) is a web rule and is not ported here.
+`countable_tests` is the part of it that comes from the result alone: tests whose table names
+every hypothesis. Whether such a test also lands in the first group depends on its cost and
+executability, which the screen shows.
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+from collections.abc import Mapping
+from pathlib import Path
+from typing import Any, cast
+
+UNKNOWN_LABEL = "모름"
+
+
+def _normalized(label: str) -> str:
+ return " ".join(label.split()).lower()
+
+
+def _maps(value: object) -> list[dict[str, Any]]:
+ """The mapping items of a JSON list; anything else is an empty list."""
+ if not isinstance(value, list):
+ return []
+ items = cast("list[object]", value)
+ return [cast("dict[str, Any]", item) for item in items if isinstance(item, dict)]
+
+
+def _mapping(value: object) -> dict[str, Any] | None:
+ return cast("dict[str, Any]", value) if isinstance(value, dict) else None
+
+
+def find_portfolio(document: object) -> dict[str, Any] | None:
+ """The portfolio inside a result document, or None when there is none."""
+ paths = (("portfolio",), ("result", "portfolio"), ("current_result", "result", "portfolio"))
+ for path in ((), *paths):
+ node = _mapping(document)
+ for key in path:
+ node = _mapping(node.get(key)) if node is not None else None
+ if node is not None and isinstance(node.get("hypotheses"), list):
+ return node
+ return None
+
+
+def measure(portfolio: Mapping[str, Any]) -> dict[str, Any]:
+ hypotheses = _maps(portfolio.get("hypotheses"))
+ ids = {str(item.get("hypothesis_id")) for item in hypotheses}
+ tests = [test for item in hypotheses for test in _maps(item.get("discriminating_tests"))]
+ tables = [_maps(test.get("expected_by_hypothesis")) for test in tests]
+ rows = [row for table in tables for row in table]
+ unknown = [row for row in rows if _normalized(str(row.get("expected", ""))) == UNKNOWN_LABEL]
+ with_basis = [row for row in rows if row.get("basis")]
+ countable = [
+ table
+ for table in tables
+ if table and {str(row.get("hypothesis_id")) for row in table} >= ids
+ ]
+ splitting = [
+ table
+ for table in countable
+ if len({_normalized(str(row.get("expected", ""))) for row in table} - {UNKNOWN_LABEL}) >= 2
+ ]
+ conditions = sum(
+ len(cast("list[object]", item.get("refutation_conditions") or [])) for item in hypotheses
+ )
+ return {
+ "hypotheses": len(hypotheses),
+ "tests": len(tests),
+ "tests_with_table": sum(1 for table in tables if table),
+ "countable_tests": len(countable),
+ "splitting_tests": len(splitting),
+ "table_rows": len(rows),
+ "unknown_ratio": round(len(unknown) / len(rows), 3) if rows else None,
+ "refutation_conditions": conditions,
+ "rows_with_basis_ratio": round(len(with_basis) / len(rows), 3) if rows else None,
+ }
+
+
+def main(argv: list[str]) -> int:
+ if not argv:
+ print(__doc__, file=sys.stderr)
+ return 2
+ code = 0
+ for name in argv:
+ try:
+ document = json.loads(Path(name).read_text(encoding="utf-8"))
+ except (OSError, ValueError) as error:
+ print(json.dumps({"file": name, "error": type(error).__name__}, ensure_ascii=False))
+ code = 1
+ continue
+ portfolio = find_portfolio(document)
+ report = {"file": name, **(measure(portfolio) if portfolio else {"error": "NO_PORTFOLIO"})}
+ print(json.dumps(report, ensure_ascii=False))
+ code = code or (1 if portfolio is None else 0)
+ return code
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/scripts/measure_project_lists.py b/scripts/measure_project_lists.py
new file mode 100644
index 0000000..603d53f
--- /dev/null
+++ b/scripts/measure_project_lists.py
@@ -0,0 +1,390 @@
+"""Measure how long the project list reads take as a project grows. No model is called.
+
+Usage:
+ python scripts/measure_project_lists.py [--scales 1,10,100] [--runs 5]
+
+A temporary workspace is built for each scale (one unit is the demo size: 20 hypotheses, 60 test
+results, 10 closures, 60 lessons, 5 threads) and each list is read `--runs` times through the query
+route; the median time in milliseconds is printed as a table and as one JSON line. The records are
+written straight into the ledger as the services would write them, so nothing here asks a model,
+a person or a network. A list that stays under the threshold (300 ms) at the largest scale needs no
+change.
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import json
+import statistics
+import sys
+import tempfile
+import time
+from datetime import UTC, datetime
+from decimal import Decimal
+from pathlib import Path
+from typing import Any, cast
+
+from thoth.adapters.runtime import SystemClock, UuidIdGenerator
+from thoth.adapters.storage.threads import SqliteThreadStore
+from thoth.application.services import request_records
+from thoth.application.services.lesson_ledger import (
+ LessonLedger,
+ _dump, # pyright: ignore[reportPrivateUsage]
+)
+from thoth.application.services.request_records import RequestRecords
+from thoth.application.services.trace_closure import TraceClosures
+from thoth.application.services.trace_csv import export_csv
+from thoth.apps.runtime import create_runtime
+from thoth.domain.discrimination import DiscriminationLedgerRecord, DiscriminationResult
+from thoth.domain.enums import EntityType, ThreadExecutionState, ThreadLifecycle
+from thoth.domain.lesson_ref import (
+ LessonRef,
+ LessonReferenceRecord,
+ LessonSource,
+ fingerprint_of,
+ lesson_id_of,
+)
+from thoth.domain.project import WorkThread
+from thoth.domain.resource_scope import ResourceScopePolicy, ResourceScopeTemplate
+from thoth.domain.trace_closure import ClosureEvent, TraceClosureRecord
+from thoth.domain.verification_trace import (
+ Comparator,
+ CriterionRule,
+ TraceItem,
+ TraceKind,
+ TraceLink,
+ TraceRelation,
+ TraceSet,
+)
+from thoth.protocol.jsonrpc import JsonRpcRequest
+
+THRESHOLD_MS = 300.0
+DEMO = {"hypotheses": 20, "results": 60, "closures": 10, "lessons": 60, "threads": 5}
+LISTS: tuple[tuple[str, dict[str, object]], ...] = (
+ ("thread/list", {}),
+ ("hypothesis/link/list", {}),
+ ("trace/lesson/list", {}),
+ ("hypothesis/test/result/list", {}),
+ ("trace/closure/list", {}),
+ ("trace/export", {}),
+)
+PROJECT = "p"
+DIGEST = "d" * 64
+
+
+def call(method: str, key: str, values: dict[str, object]) -> JsonRpcRequest:
+ return JsonRpcRequest.model_validate(
+ {"id": key, "method": method, "params": {"_meta": {"idempotencyKey": key}, "input": values}}
+ )
+
+
+def trace_set(rows: int) -> TraceSet:
+ """One requirement and `rows` criteria that each have a rule and no result (all open rows)."""
+ items = [TraceItem(item_id="REQ-1", item_key="k-req", kind=TraceKind.REQUIREMENT, title="요구")]
+ links: list[TraceLink] = []
+ rules: list[CriterionRule] = []
+ for number in range(rows):
+ cid = f"C-{number}"
+ items.append(
+ TraceItem(item_id=cid, item_key=f"k-{number}", kind=TraceKind.CRITERION, title=cid)
+ )
+ links.append(
+ TraceLink(
+ link_id=f"L-{number}",
+ from_id=cid,
+ to_id="REQ-1",
+ relation=TraceRelation.REFINES,
+ )
+ )
+ rules.append(
+ CriterionRule(
+ rule_id=f"R-{number}",
+ criterion_id=cid,
+ measure="rate",
+ comparator=Comparator.AT_LEAST,
+ threshold=Decimal("0.9"),
+ unit="ratio",
+ condition="weather=rain",
+ )
+ )
+ return TraceSet(items=tuple(items), links=tuple(links), rules=tuple(rules))
+
+
+async def build(workspace: Path, scale: int) -> tuple[Any, dict[str, int]]:
+ sizes = {name: size * scale for name, size in DEMO.items()}
+ runtime = create_runtime(
+ workspace,
+ resource_scope_policy=ResourceScopePolicy(
+ default=ResourceScopeTemplate(owner_kind="PROJECT", visibility="PROJECT_SHARED")
+ ),
+ )
+ created = await runtime.bus.dispatch(
+ call(
+ "project/create",
+ "project",
+ {"project_id": PROJECT, "name": "measure", "cutoff_at": "2026-09-13T00:00:00Z"},
+ )
+ )
+ assert created.error is None, created.error
+ rows = max(sizes["closures"], 5)
+ text = export_csv(trace_set(rows))
+ params: dict[str, object] = {"project_id": PROJECT, "mode": "CREATE", "csv_text": text}
+ preview = await runtime.bus.query(call("trace/importPreview", "preview", params))
+ assert preview.error is None and preview.result is not None, preview.error
+ plan = cast(dict[str, Any], preview.result["value"])
+ applied = await runtime.bus.dispatch(
+ call(
+ "trace/importApply",
+ "apply",
+ {**params, "preview_id": plan["preview_id"], "input_sha256": plan["input_sha256"]},
+ )
+ )
+ assert applied.error is None, applied.error
+ records = RequestRecords(
+ runtime.ledger,
+ cast(Any, None), # the write path used here never reads the control records
+ SystemClock(),
+ UuidIdGenerator(),
+ )
+ verdicts = TraceClosures(records)._reader.current_verdicts( # pyright: ignore[reportPrivateUsage]
+ PROJECT,
+ TraceClosures(records)._reader.trace(PROJECT)[1], # pyright: ignore[reportPrivateUsage]
+ )
+ rows_now = [(kind, subject) for (kind, subject) in verdicts if kind.value == "CRITERION"]
+ now = datetime.now(UTC)
+ # threads
+ store = SqliteThreadStore(runtime.ledger.engine)
+ for number in range(sizes["threads"]):
+ store.create(
+ WorkThread(
+ thread_id=f"thread:{number}",
+ project_id=PROJECT,
+ cycle_id=f"cycle:{number}",
+ problem=f"question {number}",
+ lifecycle=ThreadLifecycle.OPEN,
+ execution_state=ThreadExecutionState.IDLE,
+ working_head_digest=DIGEST,
+ )
+ )
+ # hypotheses, each from one open row (written the way the revision service writes any record)
+ write_hypotheses(records, sizes["hypotheses"], rows_now, verdicts, now)
+ # test results, spread over the hypotheses and their tests
+ results = tuple(
+ DiscriminationResult(
+ event_id=f"e{number}",
+ hypothesis_id=f"hypothesis:{number % sizes['hypotheses']}",
+ hypothesis_revision_digest=DIGEST,
+ test_id=f"test-{number // sizes['hypotheses']}",
+ observation="what was seen",
+ matched=("THIS_HYPOTHESIS", "ALTERNATIVE", "NEITHER")[number % 3], # type: ignore[arg-type]
+ actor_id="human:local-user",
+ created_at=now,
+ )
+ for number in range(sizes["results"])
+ )
+ records.save(
+ PROJECT,
+ EntityType.THREAD,
+ "hypothesis-discrimination",
+ DiscriminationLedgerRecord(project_id=PROJECT, results=results),
+ "human:local-user",
+ )
+ # closures, spread over the open rows
+ closures: list[ClosureEvent] = []
+ for number in range(sizes["closures"]):
+ kind, subject = rows_now[number % len(rows_now)]
+ current = verdicts[(kind, subject)]
+ closures.append(
+ ClosureEvent(
+ event_id=f"c{number}",
+ subject_kind="CRITERION",
+ subject_id=subject,
+ kind="HUMAN_CLOSED",
+ basis_ref=f"doc-{number}",
+ verdict_revision=current.verdict_revision,
+ verdict_digest=current.verdict_digest,
+ verdict_state=current.state,
+ actor_id="human:local-user",
+ created_at=now,
+ )
+ )
+ records.save(
+ PROJECT,
+ EntityType.THREAD,
+ "trace-closures",
+ TraceClosureRecord(project_id=PROJECT, events=tuple(closures)),
+ "human:local-user",
+ )
+ write_lessons(records, sizes["lessons"], results, tuple(closures), rows_now)
+ return runtime, sizes
+
+
+def write_hypotheses(
+ records: RequestRecords,
+ count: int,
+ rows_now: list[tuple[Any, str]],
+ verdicts: dict[Any, Any],
+ now: datetime,
+) -> None:
+ from thoth.domain.hypothesis_full import HypothesisRecord
+ from thoth.domain.verdict_link import VerdictLink
+
+ # A hypothesis is not a request record: it has the 1.0.0 snapshot digest and needs no codec.
+ original = request_records.decode_research_record, request_records.domain_digest
+ digest = request_records.domain_digest
+
+ def no_codec(content: dict[str, object]) -> None:
+ return None
+
+ def old_digest(kind: str, version: str, payload: bytes) -> str:
+ return digest(kind, "1.0.0" if kind == "SNAPSHOT" else version, payload)
+
+ request_records.decode_research_record = cast(Any, no_codec)
+ request_records.domain_digest = old_digest
+ try:
+ for number in range(count):
+ kind, subject = rows_now[number % len(rows_now)]
+ current = verdicts[(kind, subject)]
+ record = HypothesisRecord(
+ hypothesis_revision_id=f"revision:{number}",
+ hypothesis_id=f"hypothesis:{number}",
+ project_id=PROJECT,
+ object_id="object:1",
+ portfolio_id="portfolio:1",
+ statement=f"hypothesis {number}",
+ observed_problem="observed",
+ primary_intent=None,
+ evidence_basis="basis",
+ scope={},
+ evidence_refs=(),
+ revision_digest=DIGEST,
+ created_at=now,
+ verdict_link=VerdictLink(
+ subject_kind="CRITERION",
+ subject_id=subject,
+ verdict_revision=current.verdict_revision,
+ verdict_digest=current.verdict_digest,
+ state=current.state,
+ ),
+ )
+ records.save(
+ PROJECT, EntityType.HYPOTHESIS, record.hypothesis_id, record, "agent:research"
+ )
+ finally:
+ request_records.decode_research_record, request_records.domain_digest = original
+
+
+def write_lessons(
+ records: RequestRecords,
+ count: int,
+ results: tuple[DiscriminationResult, ...],
+ closures: tuple[ClosureEvent, ...],
+ rows_now: list[tuple[Any, str]],
+) -> None:
+ ledger = LessonLedger(records)
+ now = datetime.now(UTC)
+ refs: list[LessonRef] = []
+ shown = ledger._reader.current_verdicts( # pyright: ignore[reportPrivateUsage]
+ PROJECT,
+ ledger._reader.trace(PROJECT)[1], # pyright: ignore[reportPrivateUsage]
+ )
+ for number in range(count):
+ kind, subject = rows_now[number % len(rows_now)]
+ found = ledger.row_now(PROJECT, kind.value, subject, shown)
+ if found is None:
+ continue
+ context, digest = found
+ if number % 5 == 4 and closures:
+ event = closures[number % len(closures)]
+ source = [LessonSource(record_kind="TRACE_CLOSURE", record_id=event.event_id)]
+ refs.append(
+ LessonRef(
+ lesson_id=lesson_id_of("HUMAN_CLOSURE", "HUMAN_CLOSED", source) + f"-{number}",
+ kind="HUMAN_CLOSURE",
+ outcome="HUMAN_CLOSED",
+ sources=tuple(source),
+ fingerprint=fingerprint_of({"event": _dump(event), "verdict_digest": digest}),
+ context=context,
+ actor_id="human:local-user",
+ created_at=now,
+ )
+ )
+ continue
+ event = results[number % len(results)]
+ source = [LessonSource(record_kind="DISCRIMINATION_RESULT", record_id=event.event_id)]
+ refs.append(
+ LessonRef(
+ lesson_id=lesson_id_of("TEST_RESULT", event.matched, source) + f"-{number}",
+ kind="TEST_RESULT",
+ outcome=event.matched,
+ sources=tuple(source),
+ fingerprint=fingerprint_of({"event": _dump(event)}),
+ context=context,
+ hypothesis_id=event.hypothesis_id,
+ test_id=event.test_id,
+ actor_id="human:local-user",
+ created_at=now,
+ )
+ )
+ records.save(
+ PROJECT,
+ EntityType.THREAD,
+ "lesson-references",
+ LessonReferenceRecord(project_id=PROJECT, refs=tuple(refs)),
+ "system:lesson-rule",
+ )
+
+
+async def measure(runtime: Any, runs: int) -> dict[str, float]:
+ found: dict[str, float] = {}
+ for method, values in LISTS:
+ times: list[float] = []
+ for number in range(runs):
+ begun = time.perf_counter()
+ response = await runtime.bus.query(
+ call(method, f"{method}-{number}", {"project_id": PROJECT, **values})
+ )
+ times.append((time.perf_counter() - begun) * 1000)
+ assert response.error is None, (method, response.error)
+ found[method] = round(statistics.median(times), 1)
+ return found
+
+
+async def main_async(scales: list[int], runs: int) -> dict[str, dict[str, float]]:
+ table: dict[str, dict[str, float]] = {}
+ for scale in scales:
+ with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as directory:
+ built = time.perf_counter()
+ runtime, sizes = await build(Path(directory), scale)
+ try:
+ table[f"x{scale} {json.dumps(sizes, sort_keys=True)}"] = await measure(
+ runtime, runs
+ )
+ finally:
+ runtime.close()
+ print(f"scale {scale}: built in {time.perf_counter() - built:.1f}s", file=sys.stderr)
+ return table
+
+
+def main(argv: list[str]) -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--scales", default="1,10,100")
+ parser.add_argument("--runs", type=int, default=5)
+ arguments = parser.parse_args(argv)
+ table = asyncio.run(
+ main_async([int(item) for item in arguments.scales.split(",")], arguments.runs)
+ )
+ names = [method for method, _ in LISTS]
+ print("| scale (sizes) | " + " | ".join(names) + " |")
+ print("|---|" + "---|" * len(names))
+ for label, row in table.items():
+ print(f"| {label} | " + " | ".join(f"{row[name]:.1f}" for name in names) + " |")
+ print(json.dumps({"median_ms": table, "threshold_ms": THRESHOLD_MS}, ensure_ascii=False))
+ worst = max(max(row.values()) for row in table.values())
+ print(f"slowest median: {worst:.1f} ms (threshold {THRESHOLD_MS:.0f} ms)", file=sys.stderr)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/scripts/memory_eval_families.py b/scripts/memory_eval_families.py
new file mode 100644
index 0000000..1c3c1a3
--- /dev/null
+++ b/scripts/memory_eval_families.py
@@ -0,0 +1,383 @@
+"""Recall evaluation by question family, with no model call.
+
+A family is a set of questions that ask for the same thing and have the same fixed answer. Each
+family has its own phrasings (the original wording, another wording, a short follow-up) and a gold
+answer written before any question is run: the memories that are needed, the memories that must
+never be given, whether the question can be answered at all, and whether a correction applies.
+
+families.jsonl holds one family per line:
+
+ {"family_id": "F001", "type": "DIRECT", "project": "project:example", "split": "dev",
+ "asked_at": "2026-10-01T09:00:00+00:00",
+ "phrasings": [{"id": "original", "text": "..."}, {"id": "other", "text": "..."},
+ {"id": "elliptic", "text": "..."}],
+ "gold": {"needed": ["memory:abc"], "forbidden": ["rev:old"], "answerable": true,
+ "correction_applied": false}}
+
+A memory is named by its memory_id or its memory_revision_id. asked_at is optional (the moment after
+the last stored memory is used). split is optional when a split.json ({"dev": [...], "sealed":
+[...]}) is given. The words a model would add to a question come from a cache file
+({"": {"synonyms": [...], "keywords": [...], ...}}) that is made outside
+this script; nothing here calls a model. A question missing from the cache is left out of the
+"expansion" condition and counted, never silently treated as answered.
+
+Three conditions are run: "off" (no memory is given), "current" (the present recall rules) and
+"expansion" (the present rules plus the cached added words). Results are given for all families
+and for each type, never only as an average. No score or weight is made.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import unicodedata
+from collections import defaultdict
+from collections.abc import Callable, Mapping, Sequence
+from dataclasses import dataclass, field
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+from typing import Any, Literal, Protocol
+
+from pydantic import Field, ValidationError, model_validator
+
+from thoth.domain.base import DomainModel
+from thoth.domain.memory import FullMemoryRevision
+from thoth.domain.memory_expansion import QueryExpansion
+
+FamilyType = Literal[
+ "DIRECT",
+ "REPHRASED",
+ "ELLIPTIC_FOLLOWUP",
+ "CORRECTION",
+ "TEMPORAL",
+ "MULTI_EVIDENCE",
+ "IRRELEVANT",
+ "UNANSWERABLE",
+]
+TYPES: tuple[str, ...] = (
+ "DIRECT",
+ "REPHRASED",
+ "ELLIPTIC_FOLLOWUP",
+ "CORRECTION",
+ "TEMPORAL",
+ "MULTI_EVIDENCE",
+ "IRRELEVANT",
+ "UNANSWERABLE",
+)
+CONDITIONS: tuple[str, ...] = ("off", "current", "expansion")
+# A type that asks for no stored memory: its gold names none.
+_NO_MEMORY_TYPES = frozenset({"IRRELEVANT", "UNANSWERABLE"})
+
+
+class FamilyError(ValueError):
+ """The families file or the split file is not in the agreed form."""
+
+
+class Phrasing(DomainModel):
+ id: str = Field(min_length=1, max_length=60)
+ text: str = Field(min_length=1, max_length=4_000)
+
+
+class Gold(DomainModel):
+ needed: tuple[str, ...] = ()
+ forbidden: tuple[str, ...] = ()
+ answerable: bool = True
+ correction_applied: bool = False
+
+ @model_validator(mode="after")
+ def _disjoint(self) -> Gold:
+ if set(self.needed) & set(self.forbidden):
+ raise ValueError("a memory cannot be both needed and forbidden")
+ return self
+
+
+class Family(DomainModel):
+ family_id: str = Field(min_length=1, max_length=80)
+ type: FamilyType
+ project: str = Field(min_length=1, max_length=160)
+ split: Literal["dev", "sealed"] | None = None
+ asked_at: datetime | None = None
+ phrasings: tuple[Phrasing, ...] = Field(min_length=1)
+ gold: Gold
+
+ @model_validator(mode="after")
+ def _consistent(self) -> Family:
+ ids = [item.id for item in self.phrasings]
+ if len(set(ids)) != len(ids):
+ raise ValueError("phrasing ids must be different within a family")
+ if self.type == "IRRELEVANT" and self.gold.needed:
+ raise ValueError("an IRRELEVANT family needs no memory")
+ if self.type not in _NO_MEMORY_TYPES and not self.gold.needed:
+ raise ValueError("this family type needs at least one needed memory")
+ if self.type == "CORRECTION" and not self.gold.correction_applied:
+ raise ValueError("a CORRECTION family says a correction applies")
+ return self
+
+
+def parse_families(text: str) -> list[Family]:
+ families: list[Family] = []
+ seen: set[str] = set()
+ for number, line in enumerate(text.splitlines(), start=1):
+ if not line.strip() or line.lstrip().startswith("#"):
+ continue
+ try:
+ family = Family.model_validate(json.loads(line))
+ except (json.JSONDecodeError, ValidationError) as exc:
+ raise FamilyError(f"line {number}: {exc}") from exc
+ if family.family_id in seen:
+ raise FamilyError(f"line {number}: family_id {family.family_id} is used twice")
+ seen.add(family.family_id)
+ families.append(family)
+ return families
+
+
+def select_split(
+ families: Sequence[Family], split: str, split_file: Mapping[str, Sequence[str]] | None
+) -> list[Family]:
+ """The families of one split: by the split file when there is one, else by each family's own."""
+ if split not in {"dev", "sealed"}:
+ raise FamilyError("split must be dev or sealed")
+ if split_file is not None:
+ named = {family_id for ids in split_file.values() for family_id in ids}
+ both = set(split_file.get("dev", ())) & set(split_file.get("sealed", ()))
+ if both:
+ raise FamilyError(f"families in both splits: {sorted(both)}")
+ unknown = named - {family.family_id for family in families}
+ if unknown:
+ raise FamilyError(f"split file names unknown families: {sorted(unknown)}")
+ wanted = set(split_file.get(split, ()))
+ return [family for family in families if family.family_id in wanted]
+ return [family for family in families if family.split == split]
+
+
+def question_key(text: str) -> str:
+ """The cache key of a question: the same words in the same order give the same key."""
+ normal = " ".join(unicodedata.normalize("NFKC", text).casefold().split())
+ return hashlib.sha256(normal.encode("utf-8")).hexdigest()
+
+
+def load_expansion_cache(raw: Mapping[str, Any]) -> dict[str, QueryExpansion]:
+ return {key: QueryExpansion.model_validate(value) for key, value in raw.items()}
+
+
+@dataclass
+class QuestionResult:
+ family_id: str
+ phrasing_id: str
+ type: str
+ text: str
+ needed: tuple[str, ...]
+ included: tuple[str, ...] = ()
+ needed_hit: tuple[str, ...] = ()
+ unwanted: tuple[str, ...] = ()
+ forbidden_injected: tuple[str, ...] = ()
+ expansion_missing: bool = False
+ detail: dict[str, Any] = field(default_factory=lambda: {})
+
+ @property
+ def complete(self) -> bool:
+ return len(self.needed_hit) == len(self.needed)
+
+
+def _ids(item: FullMemoryRevision) -> set[str]:
+ return {item.memory_id, item.memory_revision_id}
+
+
+def score_question(
+ family: Family, phrasing: Phrasing, included: Sequence[FullMemoryRevision]
+) -> QuestionResult:
+ """Count one question's recall against its gold. Counts only; no score is made."""
+ needed, forbidden = set(family.gold.needed), set(family.gold.forbidden)
+ given = [_ids(item) for item in included]
+ hit = tuple(name for name in family.gold.needed if any(name in ids for ids in given))
+ unwanted = tuple(
+ item.memory_id for item, ids in zip(included, given, strict=True) if not ids & needed
+ )
+ injected = tuple(
+ item.memory_id for item, ids in zip(included, given, strict=True) if ids & forbidden
+ )
+ return QuestionResult(
+ family_id=family.family_id,
+ phrasing_id=phrasing.id,
+ type=family.type,
+ text=phrasing.text,
+ needed=family.gold.needed,
+ included=tuple(item.memory_id for item in included),
+ needed_hit=hit,
+ unwanted=unwanted,
+ forbidden_injected=injected,
+ )
+
+
+def _rate(numerator: int, denominator: int) -> float | None:
+ return None if denominator == 0 else round(numerator / denominator, 4)
+
+
+def summarize(results: Sequence[QuestionResult]) -> dict[str, Any]:
+ """The recall measures of a set of questions (the questions' families count once each)."""
+ scored = [item for item in results if not item.expansion_missing]
+ asking = [item for item in scored if item.needed]
+ plain = [item for item in scored if not item.needed]
+ given = sum(len(item.included) for item in scored)
+ per_question = [len(item.needed_hit) / len(item.needed) for item in asking]
+ by_family: dict[str, list[QuestionResult]] = defaultdict(list)
+ for item in asking:
+ by_family[item.family_id].append(item)
+ return {
+ "questions": len(scored),
+ "questions_missing_expansion": len(results) - len(scored),
+ "families": len({item.family_id for item in scored}),
+ "questions_that_need_memory": len(asking),
+ # Share of the needed memories that were given, over all needed memories (micro) and as the
+ # mean over questions (macro).
+ "needed_recall_micro": _rate(
+ sum(len(item.needed_hit) for item in asking), sum(len(item.needed) for item in asking)
+ ),
+ "needed_recall_macro": None
+ if not per_question
+ else round(sum(per_question) / len(per_question), 4),
+ "all_needed_given_rate": _rate(sum(item.complete for item in asking), len(asking)),
+ "family_complete_rate": _rate(
+ sum(all(item.complete for item in group) for group in by_family.values()),
+ len(by_family),
+ ),
+ # Share of the given memories that are not needed (not in gold, or forbidden).
+ "unwanted_share": _rate(sum(len(item.unwanted) for item in scored), given),
+ "questions_with_unwanted": sum(bool(item.unwanted) for item in scored),
+ "forbidden_injected": sum(len(item.forbidden_injected) for item in scored),
+ # Of the questions that need no memory, the share that were given one anyway.
+ "false_positive_rate": _rate(sum(bool(item.included) for item in plain), len(plain)),
+ "questions_that_need_none": len(plain),
+ "memories_given": given,
+ }
+
+
+def summarize_by_type(results: Sequence[QuestionResult]) -> dict[str, dict[str, Any]]:
+ present = {item.type for item in results}
+ return {
+ name: summarize([item for item in results if item.type == name])
+ for name in TYPES
+ if name in present
+ }
+
+
+class PlayedQuestion(Protocol):
+ """What the recall rules did with one question (the replay's own result has these)."""
+
+ @property
+ def included(self) -> Sequence[FullMemoryRevision]: ...
+ @property
+ def excluded(self) -> Mapping[str, int]: ...
+ @property
+ def follow_up_markers(self) -> Sequence[str]: ...
+ @property
+ def matched_by(self) -> Mapping[str, str]: ...
+
+
+# (question text, the moment it is asked, the memories that exist, the added words) -> what the
+# present recall rules give. Supplied by the replay script so the rules stay in one place.
+Play = Callable[
+ [str, datetime, Sequence[FullMemoryRevision], QueryExpansion | None], PlayedQuestion
+]
+
+
+@dataclass(frozen=True)
+class RunSetup:
+ play: Play
+ cache: Mapping[str, QueryExpansion]
+ conditions: tuple[str, ...] = CONDITIONS
+
+
+def evaluate(
+ families: Sequence[Family],
+ revisions_by_project: Mapping[str, Sequence[FullMemoryRevision]],
+ setup: RunSetup,
+) -> dict[str, Any]:
+ """Run every question of the families under each condition and report the measures."""
+ per_condition: dict[str, list[QuestionResult]] = {name: [] for name in setup.conditions}
+ missing: dict[str, str] = {}
+ for family in families:
+ revisions = revisions_by_project.get(family.project)
+ if revisions is None:
+ raise FamilyError(f"family {family.family_id}: no memories loaded for {family.project}")
+ asked_at = family.asked_at or (
+ max((item.created_at for item in revisions), default=datetime.now(UTC))
+ + timedelta(microseconds=1)
+ )
+ for phrasing in family.phrasings:
+ key = question_key(phrasing.text)
+ for name in setup.conditions:
+ if name == "off":
+ per_condition[name].append(score_question(family, phrasing, ()))
+ continue
+ expansion = None
+ if name == "expansion":
+ expansion = setup.cache.get(key)
+ if expansion is None:
+ result = score_question(family, phrasing, ())
+ result.expansion_missing = True
+ per_condition[name].append(result)
+ missing[key] = phrasing.text
+ continue
+ played = setup.play(phrasing.text, asked_at, revisions, expansion)
+ result = score_question(family, phrasing, played.included)
+ result.detail = {
+ "excluded": dict(played.excluded),
+ "follow_up": list(played.follow_up_markers),
+ "matched_by": dict(played.matched_by),
+ }
+ per_condition[name].append(result)
+ return {
+ "conditions": {
+ name: {"overall": summarize(items), "by_type": summarize_by_type(items)}
+ for name, items in per_condition.items()
+ },
+ "questions": {
+ name: [
+ {
+ "family_id": r.family_id,
+ "phrasing_id": r.phrasing_id,
+ "type": r.type,
+ "included": list(r.included),
+ "needed_hit": list(r.needed_hit),
+ "needed": list(r.needed),
+ "unwanted": list(r.unwanted),
+ "forbidden_injected": list(r.forbidden_injected),
+ "expansion_missing": r.expansion_missing,
+ **r.detail,
+ }
+ for r in items
+ ]
+ for name, items in per_condition.items()
+ },
+ "missing_expansions": missing,
+ }
+
+
+def markdown(report: Mapping[str, Any]) -> str:
+ columns = (
+ ("needed_recall_micro", "필요 기억 회수율"),
+ ("all_needed_given_rate", "모두 불러온 질문"),
+ ("unwanted_share", "혼입률"),
+ ("forbidden_injected", "금지 기억 주입"),
+ ("false_positive_rate", "무관 질문 거짓 양성"),
+ ("questions", "질문"),
+ )
+ lines = [
+ "| 조건 | 유형 | " + " | ".join(label for _key, label in columns) + " |",
+ "|---|---|" + "---|" * len(columns),
+ ]
+ for name, block in report["conditions"].items():
+ rows = [("전체", block["overall"]), *block["by_type"].items()]
+ for kind, summary in rows:
+ cells = ["-" if summary[key] is None else str(summary[key]) for key, _ in columns]
+ lines.append(f"| {name} | {kind} | " + " | ".join(cells) + " |")
+ return "\n".join(lines)
+
+
+def read_text(path: Path) -> str:
+ return path.read_text(encoding="utf-8")
+
+
+def sealed_marker(families_path: Path) -> Path:
+ return families_path.with_name(families_path.name + ".sealed-opened")
diff --git a/scripts/memory_selection_replay.py b/scripts/memory_selection_replay.py
index e0ddd2e..3cf9a4a 100644
--- a/scripts/memory_selection_replay.py
+++ b/scripts/memory_selection_replay.py
@@ -3,7 +3,20 @@
Usage:
python scripts/memory_selection_replay.py --source /db/thoth.sqlite3 \
--project project:eval2-memory-on --extra-question-from project:eval2-memory-off \
- [--copy %TEMP%/memnarrow_eval_copy.sqlite3] [--markdown]
+ [--copy %TEMP%/memnarrow_eval_copy.sqlite3] [--markdown] [--expansion answers.json]
+
+ python scripts/memory_selection_replay.py --source /db/thoth.sqlite3 \
+ --families families.jsonl --split dev|sealed [--split-file split.json] \
+ [--expansion-cache cache.json] [--conditions off,current,expansion] \
+ [--missing-out missing.json] [--output result.json] [--markdown]
+
+The second form measures recall by question family (see memory_eval_families.py for the file
+format and the measures). Sealed families are opened once: --confirm-sealed is required and a
+second opening needs --reopen-sealed, which turns that set into a development set.
+
+With --expansion, a file of the form {"Q5": {"synonyms": [...], "keywords": [...], "related": [...],
+"note_line": "..."}} gives the words a model added to each question (ask once, outside this
+script); the replay then also puts those words through the recall rules, with no model call here.
The source database is opened read-only and copied with SQLite's backup API; every read after that
is from the copy. For each question of the project (its first recorded context pack gives the
@@ -18,6 +31,7 @@
from __future__ import annotations
import argparse
+import hashlib
import json
import os
import sqlite3
@@ -29,6 +43,10 @@
from pathlib import Path
from typing import Any
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+import memory_eval_families as families
+
from thoth.application.services.full_project_memory import FullProjectMemoryService
from thoth.application.services.memory_recall import (
RecallLimits,
@@ -39,6 +57,8 @@
from thoth.application.services.memory_supersession import superseded_memory_digests
from thoth.domain.enums import MemoryKind
from thoth.domain.memory import FullMemoryContextPack, FullMemoryRevision, MemoryTransition
+from thoth.domain.memory_expansion import QueryExpansion
+from thoth.domain.memory_terms import added_words, words
# What the fixed answers (memory-eval-gold-20261001.md, "Q1 기억과 정답 대응") call
# each automatic memory of the new-rule project, by the last part of its source reference.
@@ -68,8 +88,11 @@ class Replay:
excluded: dict[str, int]
common_terms: tuple[str, ...]
follow_up_markers: tuple[str, ...]
- # The earlier rule (any one shared word) on the same moment's pool, to check the replay itself.
+ # The earlier rule (any one word shared with the words stored at save time) on the same
+ # moment's pool, to check the replay itself.
earlier_rule_included: int
+ # How each included memory met the question when added words were given (memory name -> how).
+ matched_by: dict[str, str]
def copy_database(source: Path, destination: Path) -> None:
@@ -131,7 +154,10 @@ def allowed_at(
def replay_question(
- question: Question, revisions: Sequence[FullMemoryRevision], limits: RecallLimits
+ question: Question,
+ revisions: Sequence[FullMemoryRevision],
+ limits: RecallLimits,
+ expansion: QueryExpansion | None = None,
) -> Replay:
rules = allowed_at(revisions, question.asked_at)
if question.recorded is not None and question.recorded.selection is not None:
@@ -140,18 +166,22 @@ def replay_question(
pool = [item for item in rules if item.memory_revision_id in recorded]
else:
pool = rules
- query_terms = FullProjectMemoryService._tokens(question.text) # pyright: ignore[reportPrivateUsage]
+ split = FullProjectMemoryService._tokens(question.text) # pyright: ignore[reportPrivateUsage]
+ query_words = words(question.text)
+ added = () if expansion is None else added_words(expansion.text(), query_words)
allowed = pool
pool = [item for item in pool if not lacks_evidence(item)]
- earlier = [item for item in allowed if not query_terms or query_terms & set(item.query_terms)]
- earlier_rule = plan_recall(earlier, question.text, query_terms, limits)
- narrowing = narrow_recall(pool, question.text, query_terms)
+ earlier = [item for item in allowed if not split or split & set(item.query_terms)]
+ earlier_rule = plan_recall(earlier, question.text, query_words, limits)
+ narrowing = narrow_recall(pool, question.text, query_words, added)
plan = plan_recall(
narrowing.relevant,
question.text,
- query_terms,
+ query_words,
limits,
follow_up=bool(narrowing.follow_up_markers),
+ added_words=added,
+ expansion_only=narrowing.expansion_only,
)
return Replay(
question=question,
@@ -161,6 +191,11 @@ def replay_question(
common_terms=tuple(sorted(narrowing.common_terms)),
follow_up_markers=narrowing.follow_up_markers,
earlier_rule_included=len(earlier_rule.included),
+ matched_by={
+ name_of(item): narrowing.matches[item.memory_revision_id].matched_by
+ for item in plan.included
+ if item.memory_revision_id in narrowing.matches
+ },
)
@@ -219,13 +254,19 @@ def build_questions(
def run(
- db: Path, project: str, extra_project: str | None, limits: RecallLimits
+ db: Path,
+ project: str,
+ extra_project: str | None,
+ limits: RecallLimits,
+ expansions: dict[str, QueryExpansion] | None = None,
) -> list[dict[str, Any]]:
packs, revisions = load(db, project)
extra = load(db, extra_project)[0] if extra_project else None
rows: list[dict[str, Any]] = []
for question in build_questions(packs, extra):
- replay = replay_question(question, revisions, limits)
+ replay = replay_question(
+ question, revisions, limits, (expansions or {}).get(question.label)
+ )
ok, detail = judge(replay, revisions)
recorded = question.recorded
rows.append(
@@ -240,6 +281,7 @@ def run(
"excluded": replay.excluded,
"common_terms": list(replay.common_terms),
"follow_up": list(replay.follow_up_markers),
+ "matched_by": replay.matched_by,
"criterion": detail,
"pass": ok,
}
@@ -267,17 +309,42 @@ def markdown(rows: Sequence[dict[str, Any]]) -> str:
def main(argv: Sequence[str] | None = None, out: Callable[[str], object] = print) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--source", type=Path, required=True)
- parser.add_argument("--project", required=True)
+ parser.add_argument("--project")
parser.add_argument("--extra-question-from")
parser.add_argument("--copy", type=Path)
parser.add_argument("--markdown", action="store_true")
+ parser.add_argument(
+ "--expansion", type=Path, help="JSON of the words a model added, by question label"
+ )
parser.add_argument(
"--output", type=Path, help="write the result here (UTF-8) instead of printing"
)
+ parser.add_argument("--families", type=Path, help="families.jsonl: recall by question family")
+ parser.add_argument("--split", choices=("dev", "sealed"))
+ parser.add_argument("--split-file", type=Path, help="split.json: {dev: [ids], sealed: [ids]}")
+ parser.add_argument(
+ "--expansion-cache", type=Path, help="added words by question, made outside"
+ )
+ parser.add_argument("--conditions", default=",".join(families.CONDITIONS))
+ parser.add_argument("--missing-out", type=Path, help="questions that lack cached added words")
+ parser.add_argument("--confirm-sealed", action="store_true")
+ parser.add_argument("--reopen-sealed", action="store_true")
options = parser.parse_args(argv)
copy = options.copy or Path(os.environ.get("TEMP", ".")) / "memory_replay_copy.sqlite3"
+ if options.families is not None:
+ return run_families(options, copy, out)
+ if options.project is None:
+ parser.error("--project is required without --families")
copy_database(options.source, copy)
- rows = run(copy, options.project, options.extra_question_from, RecallLimits())
+ expansions = (
+ None
+ if options.expansion is None
+ else {
+ label: QueryExpansion.model_validate(answer)
+ for label, answer in json.loads(options.expansion.read_text(encoding="utf-8")).items()
+ }
+ )
+ rows = run(copy, options.project, options.extra_question_from, RecallLimits(), expansions)
text = markdown(rows) if options.markdown else json.dumps(rows, ensure_ascii=False, indent=2)
if options.output is not None:
options.output.write_text(text + "\n", encoding="utf-8")
@@ -286,5 +353,93 @@ def main(argv: Sequence[str] | None = None, out: Callable[[str], object] = print
return 0 if all(bool(row["pass"]) for row in rows[1:]) else 1
+def family_player(limits: RecallLimits) -> families.Play:
+ def play(
+ text: str,
+ asked_at: datetime,
+ revisions: Sequence[FullMemoryRevision],
+ expansion: QueryExpansion | None,
+ ) -> Replay:
+ return replay_question(
+ Question("family", text, asked_at, None), revisions, limits, expansion
+ )
+
+ return play
+
+
+def run_families(options: argparse.Namespace, copy: Path, out: Callable[[str], object]) -> int:
+ """Recall by question family on a read-only copy of the memories; no model call."""
+ try:
+ if options.split is None:
+ raise families.FamilyError("--split dev|sealed is required with --families")
+ names = tuple(item for item in options.conditions.split(",") if item)
+ unknown = [item for item in names if item not in families.CONDITIONS]
+ if unknown:
+ raise families.FamilyError(f"unknown conditions: {unknown}")
+ raw = families.read_text(options.families)
+ split_file = (
+ None
+ if options.split_file is None
+ else json.loads(options.split_file.read_text(encoding="utf-8"))
+ )
+ chosen = families.select_split(families.parse_families(raw), options.split, split_file)
+ marker = families.sealed_marker(options.families)
+ if options.split == "sealed":
+ if not options.confirm_sealed:
+ raise families.FamilyError("sealed families are opened once: pass --confirm-sealed")
+ if marker.exists() and not options.reopen_sealed:
+ raise families.FamilyError(
+ f"the sealed set was already opened ({marker.read_text(encoding='utf-8')!r}); "
+ "opening it again makes it a development set: pass --reopen-sealed"
+ )
+ cache = (
+ {}
+ if options.expansion_cache is None
+ else families.load_expansion_cache(
+ json.loads(options.expansion_cache.read_text(encoding="utf-8"))
+ )
+ )
+ copy_database(options.source, copy)
+ revisions = {
+ project: load(copy, project)[1] for project in sorted({f.project for f in chosen})
+ }
+ report = families.evaluate(
+ chosen, revisions, families.RunSetup(family_player(RecallLimits()), cache, names)
+ )
+ except (families.FamilyError, OSError, ValueError) as exc:
+ sys.stderr.write(f"memory families: {exc}\n")
+ return 2
+ report["meta"] = {
+ "split": options.split,
+ "families": len(chosen),
+ "questions": sum(len(f.phrasings) for f in chosen),
+ "conditions": list(names),
+ "families_file_sha256": hashlib.sha256(raw.encode("utf-8")).hexdigest(),
+ "expansion_cache_entries": len(cache),
+ "model_calls": 0,
+ }
+ if options.missing_out is not None:
+ options.missing_out.write_text(
+ json.dumps(report["missing_expansions"], ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ text = (
+ families.markdown(report)
+ if options.markdown
+ else json.dumps(report, ensure_ascii=False, indent=2)
+ )
+ if options.output is not None:
+ options.output.write_text(text + "\n", encoding="utf-8")
+ else:
+ out(text)
+ if options.split == "sealed":
+ marker.write_text(
+ f"opened {datetime.now().astimezone().isoformat()} sha256 "
+ f"{report['meta']['families_file_sha256']}",
+ encoding="utf-8",
+ )
+ return 0
+
+
if __name__ == "__main__":
sys.exit(main())
diff --git a/scripts/prepare_synthetic_radar_demo.py b/scripts/prepare_synthetic_radar_demo.py
new file mode 100644
index 0000000..cda8f84
--- /dev/null
+++ b/scripts/prepare_synthetic_radar_demo.py
@@ -0,0 +1,431 @@
+"""Prepare the synthetic radar demo in a THOTH workspace, through the public methods only.
+
+Usage (the API of a new, empty workspace must be running; nothing is read from or written to any
+other workspace):
+
+ python scripts/prepare_synthetic_radar_demo.py phase1 --api http://127.0.0.1:8861
+ python scripts/prepare_synthetic_radar_demo.py phase2 --api http://127.0.0.1:8861
+
+phase1 makes a project, connects the requirement, test-plan and dry-weather result files as
+project materials (the same way a person uses "파일 자료 연결"), and writes the phase 1 trace CSV.
+The CSV's "근거 위치" are the ids of the sentences THOTH read from the connected result file, so
+choosing one in the trace table highlights that sentence. The person then imports the CSV on the
+trace page ("새로 만들기").
+
+phase2 connects the rain result file to the same project and writes the CSV that adds the rain
+results ("기존 표 고치기"). It only writes a file; the import is done on the trace page.
+
+The demo files are invented example data. This script sends them as they are, under their own
+names (.yaml files are read as plain text). It never starts a model run.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+import urllib.error
+import urllib.request
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import datetime
+from decimal import Decimal
+from pathlib import Path
+from typing import Any
+from uuid import uuid4
+
+import yaml
+
+from thoth.application.services.trace_csv import export_rows, parse_csv, write_csv
+from thoth.domain.verification_trace import (
+ Comparator,
+ CriterionRule,
+ ResultRecord,
+ TraceItem,
+ TraceKind,
+ TraceLink,
+ TraceRelation,
+ TraceSet,
+)
+
+NOTICE = "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+DEFAULT_DEMO = Path("examples/synthetic-radar-demo-v1")
+DEFAULT_OUT = Path("outputs/b5-demo/files")
+REQUIREMENT_FILE = "10_REQUIREMENTS_SYNTHETIC.yaml"
+TEST_PLAN_FILE = "20_TEST_PLAN_SYNTHETIC.yaml"
+RESULT_PARTS = ("detection", "false_track")
+STATE_NAME = "state.json"
+
+# One call to the server: (path, body, headers) -> the decoded JSON answer.
+Transport = Callable[[str, bytes, dict[str, str]], dict[str, Any]]
+
+
+class DemoError(Exception):
+ pass
+
+
+def http_transport(base: str) -> Transport:
+ def send(path: str, body: bytes, headers: dict[str, str]) -> dict[str, Any]:
+ request = urllib.request.Request(base.rstrip("/") + path, data=body, headers=headers)
+ try:
+ with urllib.request.urlopen(request, timeout=60) as response:
+ return json.loads(response.read())
+ except urllib.error.HTTPError as error:
+ raise DemoError(f"{path}: HTTP {error.code} {error.read()[:200]!r}") from error
+
+ return send
+
+
+class Api:
+ def __init__(self, transport: Transport) -> None:
+ self._send = transport
+
+ def rpc(self, method: str, params: dict[str, Any], *, query: bool = False) -> dict[str, Any]:
+ body = json.dumps(
+ {
+ "jsonrpc": "2.0",
+ "id": str(uuid4()),
+ "method": method,
+ "params": {"_meta": {"idempotencyKey": str(uuid4())}, "input": params},
+ }
+ ).encode()
+ answer = self._send(
+ "/rpc/query" if query else "/rpc", body, {"content-type": "application/json"}
+ )
+ if "error" in answer:
+ raise DemoError(f"{method}: {json.dumps(answer['error'], ensure_ascii=False)[:300]}")
+ return answer["result"]["value"]
+
+ def stage(self, project_id: str, name: str, data: bytes) -> dict[str, Any]:
+ boundary = "----thoth" + uuid4().hex
+ body = (
+ (
+ f"--{boundary}\r\n"
+ f'Content-Disposition: form-data; name="file"; filename="{name}"\r\n'
+ "Content-Type: application/octet-stream\r\n\r\n"
+ ).encode()
+ + data
+ + f"\r\n--{boundary}--\r\n".encode()
+ )
+ return self._send(
+ "/files/stage",
+ body,
+ {
+ "content-type": "multipart/form-data; boundary=" + boundary,
+ "x-thoth-project-id": project_id,
+ },
+ )
+
+
+# --- connecting the demo files as project materials ------------------------------------------
+
+
+def connect_material(api: Api, project_id: str, path: Path) -> str:
+ """Connect one demo file the way the file panel does; returns the artifact id."""
+ staged = api.stage(project_id, path.name, path.read_bytes())
+ connected = api.rpc(
+ "project/source/connect",
+ {
+ "project_id": project_id,
+ "relative_path": staged["relative_path"],
+ "media_type": staged["media_type"],
+ "authority": "UNCLASSIFIED",
+ "cutoff_state": "ELIGIBLE",
+ "security_class": "INTERNAL",
+ "resource_scope": {"owner_kind": "PROJECT", "visibility": "PROJECT_SHARED"},
+ "version_label": "synthetic-radar-demo-v1",
+ },
+ )
+ return str(connected["artifact"]["artifact_id"])
+
+
+def value_lines(text: str) -> dict[str, int]:
+ """The line of the "value:" entry under detection and under false_track (first line is 1)."""
+ found: dict[str, int] = {}
+ section: str | None = None
+ for number, line in enumerate(text.splitlines(), start=1):
+ if line and not line[0].isspace() and line.rstrip().endswith(":"):
+ section = line.rstrip()[:-1]
+ elif section in RESULT_PARTS and line.strip().startswith("value:"):
+ found.setdefault(section, number)
+ return found
+
+
+def sentence_ids(api: Api, project_id: str, artifact_id: str) -> dict[int, str]:
+ """Line number -> the sentence id THOTH gave that line of the connected file."""
+ listed = api.rpc("evidence/list", {"project_id": project_id}, query=True)
+ return {
+ int(span["locator"]["line"]): str(span["span_id"])
+ for span in listed["evidence"]
+ if span["artifact_id"] == artifact_id and span["locator"].get("line") is not None
+ }
+
+
+def result_sentence_ids(api: Api, project_id: str, artifact_id: str, path: Path) -> dict[str, str]:
+ """For one result file: {"detection": sentence id, "false_track": sentence id}."""
+ lines = value_lines(path.read_text(encoding="utf-8"))
+ sentences = sentence_ids(api, project_id, artifact_id)
+ missing = [part for part in RESULT_PARTS if lines.get(part) not in sentences]
+ if missing:
+ raise DemoError(f"{path.name}: no connected sentence for {', '.join(missing)}")
+ return {part: sentences[lines[part]] for part in RESULT_PARTS}
+
+
+# --- the trace set -----------------------------------------------------------------------------
+
+
+def _yaml(path: Path) -> Any:
+ return yaml.safe_load(path.read_text(encoding="utf-8"))
+
+
+def result_files(demo: Path) -> list[Path]:
+ return sorted(demo.glob("3*_RESULT_*.yaml"))
+
+
+def build_trace_set(demo: Path, phase: int, spans: dict[str, dict[str, str]]) -> TraceSet:
+ """The demo as a trace set.
+
+ spans: result file name -> {"detection" / "false_track": sentence id of its value line}.
+ """
+ marked = {"synthetic_notice": NOTICE}
+ requirement = _yaml(demo / REQUIREMENT_FILE)["requirement"]
+ cases = _yaml(demo / TEST_PLAN_FILE)["test_cases"]
+ items = [
+ TraceItem(
+ item_id=requirement["id"],
+ item_key="k-req",
+ kind=TraceKind.REQUIREMENT,
+ title=requirement["title"],
+ fields=marked,
+ )
+ ]
+ links: list[TraceLink] = []
+ rules: list[CriterionRule] = []
+ for number, criterion in enumerate(requirement["criteria"]):
+ cid = criterion["id"]
+ items.append(
+ TraceItem(
+ item_id=cid,
+ item_key=f"k-c{number}",
+ kind=TraceKind.CRITERION,
+ title=criterion["title"],
+ fields=marked,
+ )
+ )
+ links.append(
+ TraceLink(
+ link_id=f"L-ref-{cid}",
+ from_id=cid,
+ to_id=requirement["id"],
+ relation=TraceRelation.REFINES,
+ )
+ )
+ rules.append(
+ CriterionRule(
+ rule_id=f"R-{cid}",
+ criterion_id=cid,
+ measure=criterion["measure"],
+ comparator=Comparator(criterion["comparator"]),
+ threshold=Decimal(criterion["threshold"]),
+ unit=criterion["unit"],
+ condition=criterion["condition"],
+ required=criterion["required"],
+ )
+ )
+ for number, case in enumerate(cases):
+ items.append(
+ TraceItem(
+ item_id=case["id"],
+ item_key=f"k-t{number}",
+ kind=TraceKind.TEST_CASE,
+ title=case["title"],
+ fields=marked,
+ )
+ )
+ links.extend(
+ TraceLink(
+ link_id=f"L-ver-{cid}",
+ from_id=cid,
+ to_id=case["id"],
+ relation=TraceRelation.VERIFIED_BY,
+ )
+ for cid in case["covers"]
+ )
+ results: list[ResultRecord] = []
+ for path in result_files(demo):
+ data = _yaml(path)
+ if data["available_from_phase"] > phase:
+ continue
+ if path.name not in spans:
+ raise DemoError(f"{path.name}: its sentences are not connected yet")
+ for part in RESULT_PARTS:
+ body = data[part]
+ rid = f"SYN-RES-{body['criterion']}"
+ items.append(
+ TraceItem(item_id=rid, item_key=f"k-{rid}", kind=TraceKind.RESULT, fields=marked)
+ )
+ links.append(
+ TraceLink(
+ link_id=f"L-prod-{rid}",
+ from_id=data["test_case"],
+ to_id=rid,
+ relation=TraceRelation.PRODUCES,
+ )
+ )
+ results.append(
+ ResultRecord(
+ result_id=rid,
+ criterion_id=body["criterion"],
+ condition=data["condition"],
+ value=Decimal(body["value"]),
+ unit=body["unit"],
+ numerator=body["numerator"],
+ denominator=body["denominator"],
+ observed_at=datetime.fromisoformat(data["observed_at"]),
+ source_span_refs=(spans[path.name][part],),
+ )
+ )
+ return TraceSet(
+ items=tuple(items), links=tuple(links), rules=tuple(rules), results=tuple(results)
+ )
+
+
+def _row_key(row: dict[str, str]) -> tuple[str, str]:
+ return (
+ row["row_type"],
+ row.get("item_id") or row.get("link_id") or row.get("rule_id") or row.get("result_id", ""),
+ )
+
+
+def phase1_csv(demo: Path, spans: dict[str, dict[str, str]]) -> str:
+ return write_csv(export_rows(build_trace_set(demo, 1, spans)))
+
+
+def phase2_csv(demo: Path, spans: dict[str, dict[str, str]], current_set_digest: str) -> str:
+ """Only the rows the rain results add, tied to the trace as it is now ("기존 표 고치기")."""
+ already = {_row_key(row) for row in export_rows(build_trace_set(demo, 1, spans))}
+ rows = [
+ {**row, "base_set_digest": current_set_digest}
+ for row in export_rows(build_trace_set(demo, 2, spans))
+ if _row_key(row) not in already
+ ]
+ return write_csv(rows)
+
+
+# --- the two steps -------------------------------------------------------------------------------
+
+
+@dataclass(frozen=True)
+class Prepared:
+ project_id: str
+ csv_path: Path
+ mode: str
+ preview: dict[str, Any]
+
+
+def _preview(api: Api, project_id: str, mode: str, text: str) -> dict[str, Any]:
+ """Ask the server what the import would change; nothing is saved."""
+ parsed = parse_csv(text)
+ if parsed.issues or parsed.ignored_columns:
+ raise DemoError(f"the CSV does not read cleanly: {[i.code for i in parsed.issues]}")
+ preview = api.rpc(
+ "trace/importPreview",
+ {"project_id": project_id, "mode": mode, "csv_text": text},
+ query=True,
+ )
+ if not preview["applicable"]:
+ raise DemoError(f"the server would refuse this CSV: {preview['conflicts']}")
+ return preview
+
+
+def _write(path: Path, text: str) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(text, encoding="utf-8", newline="") # the text already has its BOM and CRLF
+
+
+def prepare_phase1(api: Api, demo: Path, out: Path, name: str) -> Prepared:
+ project_id = f"project:web-{uuid4()}"
+ api.rpc(
+ "project/create",
+ {
+ "project_id": project_id,
+ "name": name,
+ "cutoff_at": datetime.now().astimezone().isoformat(),
+ "overlay": "general-rnd",
+ },
+ )
+ connect_material(api, project_id, demo / REQUIREMENT_FILE)
+ connect_material(api, project_id, demo / TEST_PLAN_FILE)
+ dry = next(path for path in result_files(demo) if _yaml(path)["available_from_phase"] == 1)
+ spans = {
+ dry.name: result_sentence_ids(api, project_id, connect_material(api, project_id, dry), dry)
+ }
+ text = phase1_csv(demo, spans)
+ preview = _preview(api, project_id, "CREATE", text)
+ csv_path = out / "trace_phase1_create.csv"
+ _write(csv_path, text)
+ _write(out / STATE_NAME, json.dumps({"project_id": project_id, "spans": spans}, indent=2))
+ return Prepared(project_id, csv_path, "CREATE", preview)
+
+
+def prepare_phase2(api: Api, demo: Path, out: Path) -> Prepared:
+ state = json.loads((out / STATE_NAME).read_text(encoding="utf-8"))
+ project_id: str = state["project_id"]
+ spans: dict[str, dict[str, str]] = state["spans"]
+ current = api.rpc("trace/read", {"project_id": project_id}, query=True)
+ expected = build_trace_set(demo, 1, spans).set_digest
+ if current["set_digest"] != expected:
+ raise DemoError(
+ "the project's trace is not the phase 1 import (import phase 1 first, unchanged)"
+ )
+ rain = next(path for path in result_files(demo) if _yaml(path)["available_from_phase"] == 2)
+ artifact = connect_material(api, project_id, rain)
+ spans[rain.name] = result_sentence_ids(api, project_id, artifact, rain)
+ text = phase2_csv(demo, spans, current["set_digest"])
+ preview = _preview(api, project_id, "UPDATE", text)
+ csv_path = out / "trace_phase2_rain_update.csv"
+ _write(csv_path, text)
+ _write(out / STATE_NAME, json.dumps({"project_id": project_id, "spans": spans}, indent=2))
+ return Prepared(project_id, csv_path, "UPDATE", preview)
+
+
+def _summary(done: Prepared) -> str:
+ found = done.preview["changes"]
+ mode = "새로 만들기" if done.mode == "CREATE" else "기존 표 고치기"
+ return (
+ f"project: {done.project_id}\n"
+ f"csv: {done.csv_path}\n"
+ f"trace page > CSV 들여오기 > {mode}: "
+ f"추가 {len(found['added'])} · 변경 {len(found['updated'])} · "
+ f"삭제 {len(found['deleted'])} · 그대로 {found['unchanged']} "
+ "(미리보기만 했고 저장하지 않았습니다)"
+ )
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(
+ description=__doc__, formatter_class=argparse.RawTextHelpFormatter
+ )
+ parser.add_argument("step", choices=("phase1", "phase2"))
+ parser.add_argument(
+ "--api", required=True, help="the API of the new workspace, e.g. http://127.0.0.1:8861"
+ )
+ parser.add_argument("--demo", type=Path, default=DEFAULT_DEMO)
+ parser.add_argument("--out", type=Path, default=DEFAULT_OUT)
+ parser.add_argument("--name", default="합성 레이더 데모")
+ args = parser.parse_args(argv)
+ api = Api(http_transport(args.api))
+ try:
+ if args.step == "phase1":
+ done = prepare_phase1(api, args.demo, args.out, args.name)
+ else:
+ done = prepare_phase2(api, args.demo, args.out)
+ except DemoError as error:
+ print(f"PROBLEM {error}", file=sys.stderr)
+ return 1
+ print(_summary(done))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/rule_candidate_contract.py b/scripts/rule_candidate_contract.py
index 5f54549..6d71f75 100644
--- a/scripts/rule_candidate_contract.py
+++ b/scripts/rule_candidate_contract.py
@@ -200,6 +200,15 @@ def _preserve_ratchets(root: Path, candidates: dict[str, bytes]) -> None:
raise ValueError("rule update cannot change size limits")
if new["scan_roots"] != old["scan_roots"]:
raise ValueError("rule update cannot change scan roots")
+ # The web size scope is guarded like scan_roots once the repository has it: changing or
+ # dropping a key would shrink what is measured. A repository from before the keys existed
+ # has nothing to protect, so adding them there (more checking) is allowed.
+ for key, label in (
+ ("web_scan_roots", "web scan roots"),
+ ("web_excluded_suffixes", "web scan exclusions"),
+ ):
+ if key in old and new.get(key) != old[key]:
+ raise ValueError(f"rule update cannot change {label}")
for name, baseline in new["watched_long_functions"].items():
if (
name not in old["watched_long_functions"]
diff --git a/scripts/update_source_manifest.py b/scripts/update_source_manifest.py
new file mode 100644
index 0000000..e7ce9c0
--- /dev/null
+++ b/scripts/update_source_manifest.py
@@ -0,0 +1,163 @@
+"""Rebuild or check SOURCE_MANIFEST.json, the byte inventory of the public source files.
+
+Run `python scripts/update_source_manifest.py` after the last file change and before opening a
+public pull request, then commit the result. `--check` writes nothing: it lists what differs and
+exits 1 when the list is stale.
+
+The list names every file that is tracked (or untracked but not ignored) except the list itself,
+whose hash would depend on its own bytes. This is the same rule the current list follows. The
+header fields are kept as written; only `created_at` is renewed.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import subprocess
+import sys
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from pathlib import Path
+
+MANIFEST_NAME = "SOURCE_MANIFEST.json"
+EXCLUDED_PATHS = frozenset({MANIFEST_NAME})
+HEADER_FIELDS = frozenset(
+ {
+ "schema_version",
+ "snapshot_type",
+ "created_at",
+ "license",
+ "git_history_included",
+ "full_suite_verified",
+ "known_selected_regression_failures",
+ "source_archive_sha256",
+ "public_repository",
+ "verification_basis",
+ "files",
+ }
+)
+MAX_LISTED = 20
+REFRESH_HINT = (
+ "Run `python scripts/update_source_manifest.py` to rebuild the list, then commit it. "
+ "(python scripts/update_source_manifest.py 를 실행해 다시 만든 뒤 커밋하세요.)"
+)
+
+Manifest = dict[str, object]
+
+
+@dataclass(frozen=True)
+class Difference:
+ changed: tuple[str, ...]
+ unlisted: tuple[str, ...]
+ missing: tuple[str, ...]
+
+ @property
+ def clean(self) -> bool:
+ return not (self.changed or self.unlisted or self.missing)
+
+
+def list_public_paths(root: Path) -> list[str]:
+ """Files that belong to the public tree: tracked or not ignored, present as regular files."""
+ output = subprocess.run(
+ ["git", "-C", str(root), "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
+ capture_output=True,
+ check=True,
+ ).stdout.decode("utf-8")
+ names = {name for name in output.split("\0") if name}
+ return sorted(
+ name for name in names if (root / name).is_file() and not (root / name).is_symlink()
+ )
+
+
+def _row(root: Path, relative: str) -> dict[str, object]:
+ data = (root / relative).read_bytes()
+ return {"path": relative, "sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)}
+
+
+def _listed(paths: list[str] | tuple[str, ...]) -> list[str]:
+ return sorted({path for path in paths if path not in EXCLUDED_PATHS})
+
+
+def build_manifest(
+ root: Path, paths: list[str], previous: Manifest, *, created_at: str
+) -> Manifest:
+ """A new list over `paths`; every header field of `previous` stays as it was."""
+ if set(previous) != HEADER_FIELDS:
+ raise ValueError("the previous list has a different header than the checker accepts")
+ built: Manifest = {}
+ for key, value in previous.items():
+ if key == "created_at":
+ built[key] = created_at
+ elif key == "files":
+ built[key] = [_row(root, relative) for relative in _listed(paths)]
+ else:
+ built[key] = value
+ return built
+
+
+def compare(root: Path, manifest: Manifest, paths: list[str]) -> Difference:
+ files = manifest["files"]
+ assert isinstance(files, list)
+ rows: dict[str, dict[str, object]] = {item["path"]: item for item in files}
+ current = set(_listed(paths))
+ changed = sorted(
+ relative
+ for relative in rows.keys() & current
+ if {k: v for k, v in _row(root, relative).items() if k != "path"}
+ != {k: v for k, v in rows[relative].items() if k != "path"}
+ )
+ return Difference(
+ changed=tuple(changed),
+ unlisted=tuple(sorted(current - rows.keys())),
+ missing=tuple(sorted(rows.keys() - current)),
+ )
+
+
+def render(manifest: Manifest) -> bytes:
+ return (json.dumps(manifest, indent=2) + "\n").encode("utf-8")
+
+
+def format_report(difference: Difference) -> str:
+ lines: list[str] = []
+ for label, names in (
+ ("changed", difference.changed),
+ ("not in the list", difference.unlisted),
+ ("listed but missing", difference.missing),
+ ):
+ lines.append(f"{label}: {len(names)}")
+ lines.extend(f" {name}" for name in names[:MAX_LISTED])
+ if len(names) > MAX_LISTED:
+ lines.append(f" ... and {len(names) - MAX_LISTED} more")
+ return "\n".join(lines)
+
+
+def main(argv: list[str] | None = None, root: Path | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ parser.add_argument("--check", action="store_true", help="compare only; exit 1 if stale")
+ options = parser.parse_args(argv)
+ root = (root or Path(__file__).resolve().parents[1]).resolve()
+ path = root / MANIFEST_NAME
+ previous: Manifest = json.loads(path.read_bytes())
+ paths = list_public_paths(root)
+ stdout = sys.stdout
+ if hasattr(stdout, "reconfigure"):
+ stdout.reconfigure(encoding="utf-8")
+ if options.check:
+ difference = compare(root, previous, paths)
+ if difference.clean:
+ print(f"{MANIFEST_NAME} is current.")
+ return 0
+ print(format_report(difference))
+ print(REFRESH_HINT)
+ return 1
+ built = build_manifest(root, paths, previous, created_at=datetime.now(UTC).isoformat())
+ path.write_bytes(render(built))
+ files = built["files"]
+ assert isinstance(files, list)
+ print(f"{MANIFEST_NAME} rebuilt: {len(files)} files.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/validate_synthetic_radar_demo.py b/scripts/validate_synthetic_radar_demo.py
new file mode 100644
index 0000000..978c885
--- /dev/null
+++ b/scripts/validate_synthetic_radar_demo.py
@@ -0,0 +1,370 @@
+"""Check the synthetic radar demo folder (invented data, never measurements).
+
+Usage:
+ python scripts/validate_synthetic_radar_demo.py [--root DIR] [--write-manifest]
+
+The default root is examples/synthetic-radar-demo-v1.
+
+It checks (1) every document against schema/synthetic-radar-demo-v1.schema.json, (2) the notice
+line, (3) that rates are recomputed from the trial CSVs and equal the YAML summaries, (4) that no id
+repeats, (5) that the scorer file only names known criteria, and (6) per-file SHA-256 against the
+manifest. The manifest is an integrity check ("these files were not changed"); it says nothing
+about whether the data is true or approved.
+"""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import hashlib
+import json
+import re
+import sys
+from decimal import ROUND_HALF_UP, Decimal
+from pathlib import Path
+from typing import Any
+
+import yaml
+
+NOTICE = "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+MANIFEST_NAME = "00_MANIFEST_SYNTHETIC.yaml"
+SCHEMA_RELATIVE = Path("schema") / "synthetic-radar-demo-v1.schema.json"
+SCORER_RELATIVE = (
+ Path("..") / "synthetic-radar-demo-v1-scorer" / "40_ASSESSMENT_DRAFT_SYNTHETIC.yaml"
+)
+INTEGRITY_STATEMENT = (
+ "The SHA-256 values below only show that these files have not changed since the manifest was "
+ "written. They do not show that the data is true, measured, approved or fit for any use."
+)
+HASH_SCOPE = "Raw file bytes (files are stored with LF line endings). This manifest is not hashed."
+RATE_QUANTUM = Decimal("0.01")
+CSV_COLUMNS = [
+ "synthetic_notice",
+ "row_kind",
+ "row_id",
+ "window_id",
+ "detected",
+ "observation_minutes",
+]
+
+
+def _type_ok(value: Any, expected: str) -> bool:
+ if expected == "object":
+ return isinstance(value, dict)
+ if expected == "array":
+ return isinstance(value, list)
+ if expected == "string":
+ return isinstance(value, str)
+ if expected == "integer":
+ return isinstance(value, int) and not isinstance(value, bool)
+ if expected == "boolean":
+ return isinstance(value, bool)
+ return False
+
+
+def check_schema(
+ value: Any, schema: dict[str, Any], root: dict[str, Any], where: str = "$"
+) -> list[str]:
+ """Check the small JSON-Schema subset used by the demo schema."""
+ if "$ref" in schema:
+ target: Any = root
+ for part in schema["$ref"].removeprefix("#/").split("/"):
+ target = target[part]
+ return check_schema(value, target, root, where)
+ problems: list[str] = []
+ if "const" in schema and value != schema["const"]:
+ return [f"{where}: expected {schema['const']!r}, got {value!r}"]
+ if "enum" in schema and value not in schema["enum"]:
+ return [f"{where}: {value!r} is not one of {schema['enum']}"]
+ if "type" in schema and not _type_ok(value, schema["type"]):
+ return [f"{where}: expected {schema['type']}, got {type(value).__name__}"]
+ if "pattern" in schema and not re.search(schema["pattern"], value):
+ problems.append(f"{where}: {value!r} does not match {schema['pattern']}")
+ if "oneOf" in schema:
+ passing = [
+ index
+ for index, option in enumerate(schema["oneOf"])
+ if not check_schema(value, option, root, where)
+ ]
+ if len(passing) != 1:
+ problems.append(f"{where}: matches {len(passing)} document shapes, expected exactly 1")
+ problems.extend(_closest_shape_problems(value, schema["oneOf"], root, where))
+ if isinstance(value, dict):
+ for key in schema.get("required", []):
+ if key not in value:
+ problems.append(f"{where}: missing {key!r}")
+ properties = schema.get("properties", {})
+ if schema.get("additionalProperties") is False:
+ problems.extend(
+ f"{where}: unexpected {key!r}" for key in value if key not in properties
+ )
+ for key, sub in properties.items():
+ if key in value:
+ problems.extend(check_schema(value[key], sub, root, f"{where}.{key}"))
+ if isinstance(value, list):
+ if len(value) < schema.get("minItems", 0):
+ problems.append(f"{where}: fewer than {schema['minItems']} items")
+ if "items" in schema:
+ for index, item in enumerate(value):
+ problems.extend(check_schema(item, schema["items"], root, f"{where}[{index}]"))
+ return problems
+
+
+def _closest_shape_problems(
+ value: Any, options: list[dict[str, Any]], root: dict[str, Any], where: str
+) -> list[str]:
+ """Report the problems of the shape named by the document field, to make the error readable."""
+ name = value.get("document") if isinstance(value, dict) else None
+ for option in options:
+ resolved = root["$defs"][option["$ref"].rsplit("/", 1)[-1]] if "$ref" in option else option
+ if resolved.get("properties", {}).get("document", {}).get("const") == name:
+ return check_schema(value, resolved, root, where)
+ return []
+
+
+def read_yaml(path: Path) -> Any:
+ return yaml.safe_load(path.read_text(encoding="utf-8"))
+
+
+def rate(numerator: int, denominator: int) -> Decimal:
+ return (Decimal(numerator) / Decimal(denominator)).quantize(
+ RATE_QUANTUM, rounding=ROUND_HALF_UP
+ )
+
+
+def read_trials(path: Path) -> tuple[list[dict[str, str]], list[str]]:
+ problems: list[str] = []
+ with path.open(encoding="utf-8", newline="") as handle:
+ reader = csv.DictReader(handle)
+ if reader.fieldnames != CSV_COLUMNS:
+ problems.append(f"{path.name}: columns are {reader.fieldnames}, expected {CSV_COLUMNS}")
+ rows = list(reader)
+ for number, row in enumerate(rows, start=2):
+ if row.get("synthetic_notice") != NOTICE:
+ problems.append(f"{path.name}:{number}: first column is not the synthetic notice")
+ return rows, problems
+
+
+def recompute_result(root: Path, result: dict[str, Any], where: str) -> list[str]:
+ """Recompute detection and false-track numbers from the trial CSV and compare with the YAML."""
+ csv_path = root / result["trials_csv"]
+ if not csv_path.is_file():
+ return [f"{where}: trials file {result['trials_csv']} not found"]
+ rows, problems = read_trials(csv_path)
+ trials = [row for row in rows if row["row_kind"] == "TARGET_TRIAL"]
+ windows = [row for row in rows if row["row_kind"] == "OBSERVATION_WINDOW"]
+ events = [row for row in rows if row["row_kind"] == "FALSE_TRACK_EVENT"]
+ unknown = {row["row_kind"] for row in rows} - {
+ "TARGET_TRIAL",
+ "OBSERVATION_WINDOW",
+ "FALSE_TRACK_EVENT",
+ }
+ problems.extend(f"{where}: unknown row_kind {kind!r}" for kind in sorted(unknown))
+ window_ids = {row["row_id"] for row in windows}
+ problems.extend(
+ f"{where}: event {row['row_id']} points at unknown window {row['window_id']!r}"
+ for row in events
+ if row["window_id"] not in window_ids
+ )
+ detection = result["detection"]
+ detected = sum(1 for row in trials if row["detected"] == "1")
+ minutes = sum(int(row["observation_minutes"]) for row in windows)
+ expected_detection = (detected, len(trials), rate(detected, len(trials)) if trials else None)
+ given_detection = (
+ detection["numerator"],
+ detection["denominator"],
+ Decimal(detection["value"]),
+ )
+ if expected_detection != given_detection:
+ problems.append(
+ f"{where}: detection recomputed {expected_detection}, YAML says {given_detection}"
+ )
+ false_track = result["false_track"]
+ expected_false = (len(events), minutes, rate(len(events), minutes) if minutes else None)
+ given_false = (
+ false_track["numerator"],
+ false_track["denominator"],
+ Decimal(false_track["value"]),
+ )
+ if expected_false != given_false:
+ problems.append(
+ f"{where}: false-track recomputed {expected_false}, YAML says {given_false}"
+ )
+ if false_track["observation_minutes"] != minutes:
+ problems.append(
+ f"{where}: observation_minutes {false_track['observation_minutes']} != {minutes}"
+ )
+ if sorted(false_track["event_ids"]) != sorted(row["row_id"] for row in events):
+ problems.append(f"{where}: event_ids differ from the false-track rows in the CSV")
+ return problems
+
+
+def duplicates(values: list[str]) -> list[str]:
+ return sorted({value for value in values if values.count(value) > 1})
+
+
+def validate_scorer(
+ root: Path, criteria: dict[str, dict[str, Any]], results: list[dict[str, Any]]
+) -> list[str]:
+ path = (root / SCORER_RELATIVE).resolve()
+ if not path.is_file():
+ return [f"scorer file not found: {SCORER_RELATIVE.as_posix()}"]
+ scorer = read_yaml(path)
+ problems: list[str] = []
+ if scorer.get("notice") != NOTICE:
+ problems.append("scorer: notice is missing or different")
+ test_cases = {result["test_case"] for result in results}
+ for phase, body in scorer.get("phases", {}).items():
+ for criterion in body.get("criteria", {}):
+ if criterion not in criteria:
+ problems.append(f"scorer phase {phase}: unknown criterion {criterion}")
+ problems.extend(
+ f"scorer phase {phase}: unknown test case {case}"
+ for case in body.get("available_results", [])
+ if case not in test_cases and case != "SYN-TC-FOG"
+ )
+ return problems
+
+
+def file_digests(root: Path) -> list[dict[str, Any]]:
+ entries: list[dict[str, Any]] = []
+ for path in sorted(
+ item for item in root.rglob("*") if item.is_file() and item.name != MANIFEST_NAME
+ ):
+ data = path.read_bytes()
+ entries.append(
+ {
+ "path": path.relative_to(root).as_posix(),
+ "sha256": hashlib.sha256(data).hexdigest(),
+ "bytes": len(data),
+ }
+ )
+ return entries
+
+
+def manifest_text(entries: list[dict[str, Any]]) -> str:
+ lines = [
+ f"# {NOTICE}",
+ f"notice: {NOTICE}",
+ "identity: THOTH-SYNTH-RADAR-DEMO-v1",
+ "document: manifest",
+ f"integrity_statement: {json.dumps(INTEGRITY_STATEMENT)}",
+ f"hash_scope: {json.dumps(HASH_SCOPE)}",
+ "files:",
+ ]
+ lines.extend(
+ f' - {{path: "{entry["path"]}", sha256: "{entry["sha256"]}", bytes: {entry["bytes"]}}}'
+ for entry in entries
+ )
+ return "\n".join(lines) + "\n"
+
+
+def validate_manifest(root: Path) -> list[str]:
+ path = root / MANIFEST_NAME
+ if not path.is_file():
+ return [f"{MANIFEST_NAME} not found"]
+ recorded = {entry["path"]: entry for entry in read_yaml(path).get("files", [])}
+ actual = {entry["path"]: entry for entry in file_digests(root)}
+ problems = [
+ f"manifest: {name} is not listed" for name in sorted(actual.keys() - recorded.keys())
+ ]
+ problems += [
+ f"manifest: {name} is listed but missing"
+ for name in sorted(recorded.keys() - actual.keys())
+ ]
+ problems += [
+ f"manifest: {name} changed since the manifest was written"
+ for name in sorted(recorded.keys() & actual.keys())
+ if recorded[name] != actual[name]
+ ]
+ return problems
+
+
+def validate(root: Path) -> list[str]:
+ """Return every problem found; an empty list means the folder is consistent."""
+ root = root.resolve()
+ schema = json.loads((root / SCHEMA_RELATIVE).read_text(encoding="utf-8"))
+ problems: list[str] = []
+ documents: dict[str, list[tuple[str, dict[str, Any]]]] = {}
+ for path in sorted(root.glob("*.yaml")):
+ text = path.read_text(encoding="utf-8")
+ if not text.startswith(f"# {NOTICE}"):
+ problems.append(f"{path.name}: first line is not the synthetic notice")
+ data = read_yaml(path)
+ problems.extend(check_schema(data, schema, schema, path.name))
+ if isinstance(data, dict):
+ documents.setdefault(str(data.get("document")), []).append((path.name, data))
+ requirements = [data for _, data in documents.get("requirements", [])]
+ test_cases = [
+ case for _, data in documents.get("test_plan", []) for case in data.get("test_cases", [])
+ ]
+ results = [data for _, data in documents.get("result", [])]
+ criteria = {
+ criterion["id"]: criterion
+ for data in requirements
+ for criterion in data["requirement"]["criteria"]
+ }
+ ids = [data["requirement"]["id"] for data in requirements] + list(criteria)
+ ids += [case["id"] for case in test_cases]
+ for name, result in documents.get("result", []):
+ problems.extend(_check_result_links(name, result, criteria, test_cases))
+ problems.extend(recompute_result(root, result, name))
+ csv_path = root / result["trials_csv"]
+ if csv_path.is_file():
+ ids += [row["row_id"] for row in read_trials(csv_path)[0]]
+ problems.extend(f"duplicate id {value}" for value in duplicates(ids))
+ problems.extend(validate_scorer(root, criteria, results))
+ problems.extend(validate_manifest(root))
+ return problems
+
+
+def _check_result_links(
+ name: str,
+ result: dict[str, Any],
+ criteria: dict[str, dict[str, Any]],
+ test_cases: list[dict[str, Any]],
+) -> list[str]:
+ problems: list[str] = []
+ case = next((item for item in test_cases if item["id"] == result["test_case"]), None)
+ if case is None:
+ problems.append(f"{name}: unknown test case {result['test_case']}")
+ for key in ("detection", "false_track"):
+ criterion = criteria.get(result[key]["criterion"])
+ if criterion is None:
+ problems.append(f"{name}: unknown criterion {result[key]['criterion']}")
+ continue
+ if criterion["unit"] != result[key]["unit"]:
+ problems.append(
+ f"{name}: unit {result[key]['unit']} differs from the criterion's "
+ f"{criterion['unit']}"
+ )
+ if criterion["condition"] != result["condition"]:
+ problems.append(f"{name}: condition differs from criterion {criterion['id']}")
+ if case is not None and criterion["id"] not in case["covers"]:
+ problems.append(f"{name}: test case {case['id']} does not cover {criterion['id']}")
+ return problems
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(
+ description=__doc__, formatter_class=argparse.RawTextHelpFormatter
+ )
+ parser.add_argument("--root", type=Path, default=Path("examples/synthetic-radar-demo-v1"))
+ parser.add_argument(
+ "--write-manifest", action="store_true", help="rewrite the manifest from the files"
+ )
+ args = parser.parse_args(argv)
+ if args.write_manifest:
+ root = args.root.resolve()
+ (root / MANIFEST_NAME).write_text(
+ manifest_text(file_digests(root)), encoding="utf-8", newline="\n"
+ )
+ problems = validate(args.root)
+ for problem in problems:
+ print(f"PROBLEM {problem}", file=sys.stderr)
+ print("OK" if not problems else f"{len(problems)} problem(s)")
+ return 1 if problems else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/thoth/adapters/http/app.py b/src/thoth/adapters/http/app.py
index 0a7e921..3d1038d 100644
--- a/src/thoth/adapters/http/app.py
+++ b/src/thoth/adapters/http/app.py
@@ -65,6 +65,8 @@
".md": "text/markdown",
".pdf": "application/pdf",
".txt": "text/plain",
+ ".yaml": "text/plain",
+ ".yml": "text/plain",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
}
LOCAL_ACTOR_ID = "human:local-user"
diff --git a/src/thoth/adapters/models/codex_oauth.py b/src/thoth/adapters/models/codex_oauth.py
index 9b638c6..e0f75b2 100644
--- a/src/thoth/adapters/models/codex_oauth.py
+++ b/src/thoth/adapters/models/codex_oauth.py
@@ -9,6 +9,7 @@
from pydantic import BaseModel, ValidationError
+from thoth.adapters.models.hypothesis_contract_text import hypothesis_contract_addendum
from thoth.adapters.models.reference_schema import (
apply_hypothesis_review_contract,
constrain_span_references,
@@ -463,13 +464,13 @@ def prompt_envelope(request: ModelRequest[BaseModel]) -> str:
+ "\n\nTASK\n"
+ request.role.value
+ "\nTASK_CONTRACT\n"
- + role_contract(request.role.value)
+ + role_contract(request.role.value, request.prompt_version)
+ "\nPROMPT_VERSION\n"
+ request.prompt_version
)
-def role_contract(role: str) -> str:
+def role_contract(role: str, prompt_version: str = "") -> str:
if role == "RESEARCH_PLANNER":
return (
"Plan the evidence checks needed for the user's current question, not the final "
@@ -503,6 +504,7 @@ def role_contract(role: str) -> str:
"INVALID, NOT_ASSESSABLE and unresolved references are not empirical support."
" Respect execution_security_tier; TEST_ONLY observations do not establish live "
"or field validity. A false substantive_update_allowed means diagnostic only."
+ + hypothesis_contract_addendum(prompt_version)
+ _user_visible_language_fields(
"statement, uncertainty, counterevidence_queries, predicted_observations, "
"and discriminating_tests procedure/expected text"
@@ -547,8 +549,8 @@ def user_visible_language_contract() -> str:
return (
"Write user-facing prose in the same language as context_pack.problem; "
"do not switch that prose to English when the question is not English. "
- "Keep identifiers, FACT/UNKNOWN labels, GPU/model names, table numbers, units, "
- "span IDs, file names, and quoted source text exactly as supplied."
+ "Keep identifiers, GPU/model names, table numbers, units, "
+ "span IDs, file names, and quoted source text exactly as supplied. " + _PLAIN_WORDING
)
@@ -557,5 +559,18 @@ def _user_visible_language_fields(fields: str) -> str:
" User-facing fields ("
+ fields
+ ") follow the question language. Do not translate identifiers, "
- "quoted evidence, FACT/UNKNOWN labels, or numeric/table citations."
+ "quoted evidence, or numeric/table citations."
)
+
+
+# The wording rule for everything the reader sees. It changes only how prose is written: the
+# structured fields, their names and values, and the source-linking rules stay as they are.
+_PLAIN_WORDING = (
+ "Write so the person who asked can read it at once, in short plain sentences. For a Korean "
+ "question write easy Korean. Keep the technical terms, units and standard abbreviations "
+ "that appear in the user's own material (for example SNR, RCS, CFAR, dB) exactly as they "
+ "are. Do not use program-internal codes, field names, variable names or made-up ids in "
+ "prose; say conditions in words (write '탐지율 0.90 이상', not 'detection_rate >= 0.90 "
+ "ratio') and use Korean words for internal terms such as sandbox-owner or gap. Write the "
+ "FACT/UNKNOWN labels as '사실' and '확인 불가'."
+)
diff --git a/src/thoth/adapters/models/hypothesis_contract_text.py b/src/thoth/adapters/models/hypothesis_contract_text.py
new file mode 100644
index 0000000..148b61e
--- /dev/null
+++ b/src/thoth/adapters/models/hypothesis_contract_text.py
@@ -0,0 +1,25 @@
+"""The contract text added for hypothesis generation v3 (nothing is added for v2)."""
+
+from __future__ import annotations
+
+V3_VERSION = "hypothesis_portfolio.v3"
+
+_V3 = (
+ " CONTRACT_V3. For every hypothesis prefer discriminating tests that tell it apart from the "
+ "other hypotheses of this portfolio. For each discriminating test fill expected_by_hypothesis "
+ "with one item per hypothesis_id of this portfolio: expected is the result that hypothesis "
+ "predicts for the test, as a short label of at most 80 characters, worded the same for the "
+ "same result; write expected as 모름 when the supplied evidence gives no basis for that "
+ "hypothesis, or leave the hypothesis out, and never guess. basis lists only supplied span "
+ "IDs that support that expectation and may be empty. Use only hypothesis_id values that "
+ "appear in this portfolio. For every hypothesis fill refutation_conditions with up to five "
+ "short sentences, each saying which result would make you drop that hypothesis (if this "
+ "result appears, drop it); use an empty list when none can be justified. These labels and "
+ "sentences follow the question language like the other user-facing fields. Add no "
+ "probability, score or ranking, and do not re-judge or change any verdict."
+)
+
+
+def hypothesis_contract_addendum(prompt_version: str) -> str:
+ """The extra text for this prompt version; empty for v2 and for every other version."""
+ return _V3 if prompt_version == V3_VERSION else ""
diff --git a/src/thoth/adapters/models/openai_responses.py b/src/thoth/adapters/models/openai_responses.py
index 2f88ed9..e5fa8af 100644
--- a/src/thoth/adapters/models/openai_responses.py
+++ b/src/thoth/adapters/models/openai_responses.py
@@ -243,7 +243,7 @@ def instructions(request: ModelRequest[BaseModel]) -> str:
+ " "
+ user_visible_language_contract()
+ " TASK_CONTRACT: "
- + role_contract(request.role.value)
+ + role_contract(request.role.value, request.prompt_version)
)
@staticmethod
@@ -292,5 +292,5 @@ def input_envelope(request: ModelRequest[BaseModel]) -> str:
+ "\n\nTASK\n"
+ request.role.value
+ "\nTASK_CONTRACT\n"
- + role_contract(request.role.value)
+ + role_contract(request.role.value, request.prompt_version)
)
diff --git a/src/thoth/adapters/parsers/docling_locations.py b/src/thoth/adapters/parsers/docling_locations.py
new file mode 100644
index 0000000..6a54824
--- /dev/null
+++ b/src/thoth/adapters/parsers/docling_locations.py
@@ -0,0 +1,127 @@
+"""Locations inside a Docling export: character spans, boxes and segment ids.
+
+These helpers turn the provenance of one exported item into THOTH locators. They read the export and
+return values; they do not build nodes.
+"""
+
+import hashlib
+from typing import Any, cast
+
+from thoth.domain.artifact import ArtifactEnvelope, SourceLocator
+
+
+def _segment_node_id(
+ artifact: ArtifactEnvelope, pointer: str, index: int, start: int, end: int
+) -> str:
+ payload = f"{artifact.artifact_id}\0{pointer}\0{index}\0{start}\0{end}\0DOCLING".encode()
+ return f"node:{hashlib.sha256(payload).hexdigest()[:24]}"
+
+
+def _charspan(provenance: dict[str, Any]) -> tuple[int, int] | None:
+ raw = provenance.get("charspan", provenance.get("char_span"))
+ if isinstance(raw, dict):
+ raw_map = cast(dict[str, object], raw)
+ start = raw_map.get("start", raw_map.get("char_start"))
+ end = raw_map.get("end", raw_map.get("char_end"))
+ elif isinstance(raw, list):
+ raw_list = cast(list[object], raw)
+ if len(raw_list) != 2:
+ return None
+ start, end = raw_list
+ elif isinstance(raw, tuple):
+ raw_tuple = cast(tuple[object, ...], raw)
+ if len(raw_tuple) != 2:
+ return None
+ start, end = raw_tuple
+ else:
+ start = provenance.get("char_start")
+ end = provenance.get("char_end")
+ if isinstance(start, int) and isinstance(end, int):
+ return start, end
+ return None
+
+
+def _located_segments(
+ text: str,
+ provenance: list[dict[str, Any]],
+) -> tuple[list[tuple[int, int, dict[str, Any]]] | None, str | None]:
+ segments: list[tuple[int, int, dict[str, Any]]] = []
+ for observed in provenance:
+ span = _charspan(observed)
+ if span is None:
+ return None, "Docling multi-provenance item omitted a charspan"
+ start, end = span
+ if start < 0 or end <= start or end > len(text):
+ return None, "Docling multi-provenance item reported an invalid charspan"
+ segments.append((start, end, observed))
+ segments.sort(key=lambda segment: (segment[0], segment[1]))
+ previous_end = 0
+ for start, end, _ in segments:
+ if start < previous_end:
+ return None, "Docling multi-provenance item reported overlapping charspans"
+ if text[previous_end:start].strip():
+ return None, "Docling multi-provenance item has unlocated non-whitespace text"
+ previous_end = end
+ if text[previous_end:].strip():
+ return None, "Docling multi-provenance item has trailing unlocated text"
+ return segments, None
+
+
+def _text_coordinate_basis(item: dict[str, Any]) -> tuple[str, str | None]:
+ text = item.get("text")
+ orig = item.get("orig")
+ if isinstance(text, str) and isinstance(orig, str) and text != orig:
+ return "", "Docling text and orig differ; charspan coordinate basis is unverified"
+ basis = orig if isinstance(orig, str) else text
+ if isinstance(basis, str):
+ return basis, None
+ return "", None
+
+
+def _validated_charspan(
+ item: dict[str, Any],
+ provenance: dict[str, Any],
+) -> tuple[tuple[int, int] | None, str | None]:
+ basis, error = _text_coordinate_basis(item)
+ span = _charspan(provenance)
+ if span is None:
+ return None, None
+ if error is not None:
+ return None, error
+ start, end = span
+ if start < 0 or end <= start or end > len(basis):
+ return None, "Docling provenance charspan is outside the item text"
+ return span, None
+
+
+def _source_locator(
+ export: dict[str, Any],
+ item: dict[str, Any],
+ pointer: str,
+ *,
+ provenance: dict[str, Any] | None = None,
+ char_range: tuple[int, int] | None = None,
+) -> SourceLocator:
+ prov = item.get("prov", []) if provenance is None else [provenance]
+ if len(prov) != 1:
+ return SourceLocator(json_pointer=pointer)
+ page = prov[0]["page_no"]
+ box = prov[0].get("bbox")
+ bbox = None
+ if box:
+ height = export.get("pages", {}).get(str(page), {}).get("size", {}).get("height")
+ if box.get("coord_origin") == "TOPLEFT":
+ bbox = (box["l"], box["t"], box["r"], box["b"])
+ elif height is not None:
+ bbox = (box["l"], height - box["t"], box["r"], height - box["b"])
+ char_start = None
+ char_end = None
+ if char_range is not None:
+ char_start, char_end = char_range
+ return SourceLocator(
+ page=page,
+ char_start=char_start,
+ char_end=char_end,
+ bbox=bbox,
+ json_pointer=pointer,
+ )
diff --git a/src/thoth/adapters/parsers/docling_structure.py b/src/thoth/adapters/parsers/docling_structure.py
index f82cb1d..b51804b 100644
--- a/src/thoth/adapters/parsers/docling_structure.py
+++ b/src/thoth/adapters/parsers/docling_structure.py
@@ -1,12 +1,18 @@
"""Translate the public Docling export into THOTH nodes, retaining relation uncertainty."""
-import hashlib
import re
from collections.abc import Callable
from decimal import Decimal
-from typing import Any, cast
+from typing import Any
from thoth.adapters.parsers.common import node_id, parser_artifact
+from thoth.adapters.parsers.docling_locations import (
+ _located_segments,
+ _segment_node_id,
+ _source_locator,
+ _text_coordinate_basis,
+ _validated_charspan,
+)
from thoth.domain.artifact import (
ArtifactEnvelope,
ParseIssue,
@@ -23,123 +29,6 @@
TABLE_MENTION = re.compile(r"\bTable\s+([A-Za-z]?\d+)\b", re.I)
-def _segment_node_id(
- artifact: ArtifactEnvelope, pointer: str, index: int, start: int, end: int
-) -> str:
- payload = f"{artifact.artifact_id}\0{pointer}\0{index}\0{start}\0{end}\0DOCLING".encode()
- return f"node:{hashlib.sha256(payload).hexdigest()[:24]}"
-
-
-def _charspan(provenance: dict[str, Any]) -> tuple[int, int] | None:
- raw = provenance.get("charspan", provenance.get("char_span"))
- if isinstance(raw, dict):
- raw_map = cast(dict[str, object], raw)
- start = raw_map.get("start", raw_map.get("char_start"))
- end = raw_map.get("end", raw_map.get("char_end"))
- elif isinstance(raw, list):
- raw_list = cast(list[object], raw)
- if len(raw_list) != 2:
- return None
- start, end = raw_list
- elif isinstance(raw, tuple):
- raw_tuple = cast(tuple[object, ...], raw)
- if len(raw_tuple) != 2:
- return None
- start, end = raw_tuple
- else:
- start = provenance.get("char_start")
- end = provenance.get("char_end")
- if isinstance(start, int) and isinstance(end, int):
- return start, end
- return None
-
-
-def _located_segments(
- text: str,
- provenance: list[dict[str, Any]],
-) -> tuple[list[tuple[int, int, dict[str, Any]]] | None, str | None]:
- segments: list[tuple[int, int, dict[str, Any]]] = []
- for observed in provenance:
- span = _charspan(observed)
- if span is None:
- return None, "Docling multi-provenance item omitted a charspan"
- start, end = span
- if start < 0 or end <= start or end > len(text):
- return None, "Docling multi-provenance item reported an invalid charspan"
- segments.append((start, end, observed))
- segments.sort(key=lambda segment: (segment[0], segment[1]))
- previous_end = 0
- for start, end, _ in segments:
- if start < previous_end:
- return None, "Docling multi-provenance item reported overlapping charspans"
- if text[previous_end:start].strip():
- return None, "Docling multi-provenance item has unlocated non-whitespace text"
- previous_end = end
- if text[previous_end:].strip():
- return None, "Docling multi-provenance item has trailing unlocated text"
- return segments, None
-
-
-def _text_coordinate_basis(item: dict[str, Any]) -> tuple[str, str | None]:
- text = item.get("text")
- orig = item.get("orig")
- if isinstance(text, str) and isinstance(orig, str) and text != orig:
- return "", "Docling text and orig differ; charspan coordinate basis is unverified"
- basis = orig if isinstance(orig, str) else text
- if isinstance(basis, str):
- return basis, None
- return "", None
-
-
-def _validated_charspan(
- item: dict[str, Any],
- provenance: dict[str, Any],
-) -> tuple[tuple[int, int] | None, str | None]:
- basis, error = _text_coordinate_basis(item)
- span = _charspan(provenance)
- if span is None:
- return None, None
- if error is not None:
- return None, error
- start, end = span
- if start < 0 or end <= start or end > len(basis):
- return None, "Docling provenance charspan is outside the item text"
- return span, None
-
-
-def _source_locator(
- export: dict[str, Any],
- item: dict[str, Any],
- pointer: str,
- *,
- provenance: dict[str, Any] | None = None,
- char_range: tuple[int, int] | None = None,
-) -> SourceLocator:
- prov = item.get("prov", []) if provenance is None else [provenance]
- if len(prov) != 1:
- return SourceLocator(json_pointer=pointer)
- page = prov[0]["page_no"]
- box = prov[0].get("bbox")
- bbox = None
- if box:
- height = export.get("pages", {}).get(str(page), {}).get("size", {}).get("height")
- if box.get("coord_origin") == "TOPLEFT":
- bbox = (box["l"], box["t"], box["r"], box["b"])
- elif height is not None:
- bbox = (box["l"], height - box["t"], box["r"], height - box["b"])
- char_start = None
- char_end = None
- if char_range is not None:
- char_start, char_end = char_range
- return SourceLocator(
- page=page,
- char_start=char_start,
- char_end=char_end,
- bbox=bbox,
- json_pointer=pointer,
- )
-
-
def _append_explicit_table_mentions(nodes: list[StructuralNode]) -> None:
label_targets: dict[str, set[str]] = {}
for node in nodes:
@@ -221,9 +110,7 @@ def locator(
provenance: dict[str, Any] | None = None,
char_range: tuple[int, int] | None = None,
) -> SourceLocator:
- return _source_locator(
- export, item, pointer, provenance=provenance, char_range=char_range
- )
+ return _source_locator(export, item, pointer, provenance=provenance, char_range=char_range)
captions: dict[str, list[tuple[str, str]]] = {}
for table in export.get("tables", []):
@@ -594,9 +481,7 @@ def _append_table_cells(
)
for cell_id in data_by_col.get(col, ())
)
- nodes.append(
- node.model_copy(update={"relations": node.relations + header_links})
- )
+ nodes.append(node.model_copy(update={"relations": node.relations + header_links}))
else:
nodes.append(node)
for unit in units:
diff --git a/src/thoth/adapters/parsers/text_parser.py b/src/thoth/adapters/parsers/text_parser.py
index 33cadf3..5ea3e85 100644
--- a/src/thoth/adapters/parsers/text_parser.py
+++ b/src/thoth/adapters/parsers/text_parser.py
@@ -14,7 +14,8 @@ class TextParser:
name = "text"
version = "1.0.0"
media_types = frozenset({"text/plain", "text/markdown"})
- suffixes = frozenset({".txt", ".md", ".markdown"})
+ # .yaml/.yml are read as plain lines of text. Nothing in them is interpreted or run.
+ suffixes = frozenset({".txt", ".md", ".markdown", ".yaml", ".yml"})
def parse(self, artifact: ArtifactEnvelope, raw: bytes) -> StructuralDocument:
validate_byte_hash(artifact, raw)
diff --git a/src/thoth/adapters/storage/artifacts.py b/src/thoth/adapters/storage/artifacts.py
index f99c818..609274b 100644
--- a/src/thoth/adapters/storage/artifacts.py
+++ b/src/thoth/adapters/storage/artifacts.py
@@ -402,16 +402,43 @@ def read_source_time(
return None if document is None else document.source_time_assessment
def list_source_times(self, project_id: str) -> tuple[SourceTimeAssessment, ...]:
+ # The list is read for every source on every request, so the nodes of each document are
+ # not read here: the assessment is kept in the version's structure metadata (as
+ # apply_source_time reads it). The node digest is still checked wherever nodes are read.
assessments: list[SourceTimeAssessment] = []
for artifact in self.list_artifacts(project_id):
versions = self.list_source_versions(project_id, artifact.artifact_id)
if not versions:
continue
- assessment = self.read_source_time(project_id, artifact.artifact_id, versions[-1])
+ assessment = self._read_source_time_metadata(
+ project_id, artifact.artifact_id, versions[-1]
+ )
if assessment is not None:
assessments.append(assessment)
return tuple(assessments)
+ def _read_source_time_metadata(
+ self, project_id: str, artifact_id: str, source_version_id: str
+ ) -> SourceTimeAssessment | None:
+ with read_connection(self._engine) as connection:
+ row = (
+ connection.execute(
+ select(artifact_versions.c.structure_metadata_json)
+ .join(artifacts, artifacts.c.artifact_id == artifact_versions.c.artifact_id)
+ .where(
+ artifacts.c.project_id == project_id,
+ artifacts.c.artifact_id == artifact_id,
+ artifact_versions.c.source_version_id == source_version_id,
+ )
+ )
+ .mappings()
+ .first()
+ )
+ if row is None or row["structure_metadata_json"] is None:
+ return None
+ recorded = orjson.loads(str(row["structure_metadata_json"])).get("source_time_assessment")
+ return None if recorded is None else SourceTimeAssessment.model_validate(recorded)
+
def apply_source_time(
self,
basis: SourceTimeMutationBasis,
diff --git a/src/thoth/application/commands/action_create_input.py b/src/thoth/application/commands/action_create_input.py
new file mode 100644
index 0000000..1f46695
--- /dev/null
+++ b/src/thoth/application/commands/action_create_input.py
@@ -0,0 +1,27 @@
+"""The input of action/create.
+
+Moved out of actions_full as it was, with one added field: the discriminating tests an action was
+drafted from. They are a field of the request now, so the specification carries only what the
+action is and does; the revision digest covers the field (a record made the old way, with the refs
+inside its specification, is still read by the record's own before-validator).
+"""
+
+from __future__ import annotations
+
+from pydantic import Field, JsonValue
+
+from thoth.domain.action_full import ActionTestRef
+from thoth.domain.base import DomainModel
+
+
+class CreateInput(DomainModel):
+ project_id: str = Field(min_length=1, max_length=160)
+ object_id: str = Field(min_length=1, max_length=160)
+ portfolio_id: str | None = Field(default=None, max_length=160)
+ hypothesis_refs: tuple[str, ...] = ()
+ primary_purpose: str = Field(min_length=1, max_length=80)
+ secondary_purposes: tuple[str, ...] = ()
+ specification: dict[str, JsonValue]
+ evidence_refs: tuple[str, ...]
+ expected_object_revision: str | None = Field(default=None, min_length=64, max_length=64)
+ test_refs: tuple[ActionTestRef, ...] = ()
diff --git a/src/thoth/application/commands/action_from_test.py b/src/thoth/application/commands/action_from_test.py
new file mode 100644
index 0000000..1ef00aa
--- /dev/null
+++ b/src/thoth/application/commands/action_from_test.py
@@ -0,0 +1,50 @@
+"""action/draft/fromTest: a draft action request made from one discriminating test.
+
+It composes the parameters of action/create from the stored hypothesis and calls that method, so
+every check and guard of action/create applies (a stale hypothesis is refused there). The client
+sends only which test and what the person declares the action would do.
+"""
+
+from __future__ import annotations
+
+from typing import cast
+
+from pydantic import Field, JsonValue
+
+from thoth.application.services.action_from_test import (
+ HYPOTHESIS_NOT_FOUND,
+ DraftRefused,
+ draft_request,
+)
+from thoth.application.services.hypothesis_link_view import HypothesisLinkReader
+from thoth.domain.base import DomainModel
+from thoth.protocol.deferred import AcceptedRunning, EphemeralCommandResult, PendingExecution
+from thoth.protocol.jsonrpc import RpcApplicationError, RpcErrorCode
+from thoth.protocol.registry import CommandHandler
+
+
+class FromTestInput(DomainModel):
+ project_id: str = Field(min_length=1, max_length=160)
+ hypothesis_id: str = Field(min_length=1, max_length=200)
+ test_id: str = Field(min_length=1, max_length=200)
+ # The effect flags a person declares for the action; empty means "nothing declared".
+ effect_declaration: dict[str, JsonValue] = Field(default_factory=dict)
+
+
+class ActionFromTestHandlers:
+ def __init__(self, *, reader: HypothesisLinkReader, create: CommandHandler) -> None:
+ self._reader, self._create = reader, create
+
+ async def draft(
+ self, value: dict[str, JsonValue]
+ ) -> dict[str, JsonValue] | AcceptedRunning | PendingExecution | EphemeralCommandResult:
+ request = FromTestInput.model_validate(value)
+ record = self._reader.hypothesis(request.project_id, request.hypothesis_id)
+ if record is None:
+ raise RpcApplicationError(RpcErrorCode.DOMAIN_REJECTED, HYPOTHESIS_NOT_FOUND)
+ try:
+ parameters = draft_request(record, request.test_id, dict(request.effect_declaration))
+ except DraftRefused as exc:
+ raise RpcApplicationError(RpcErrorCode.DOMAIN_REJECTED, str(exc)) from exc
+ create_input = cast(dict[str, JsonValue], {"project_id": request.project_id, **parameters})
+ return await self._create(create_input)
diff --git a/src/thoth/application/commands/actions_full.py b/src/thoth/application/commands/actions_full.py
index 69195d8..c90c97c 100644
--- a/src/thoth/application/commands/actions_full.py
+++ b/src/thoth/application/commands/actions_full.py
@@ -4,6 +4,7 @@
from pydantic import Field, JsonValue
+from thoth.application.commands.action_create_input import CreateInput
from thoth.application.commands.action_effort import HumanEffortEstimateInput, revise_action
from thoth.application.commands.action_generation import GenerateInput as GenerateInput
from thoth.application.commands.action_generation import generate_actions
@@ -74,17 +75,6 @@ class AuditReadInput(ProjectInput):
revision_digest: str | None = Field(default=None, min_length=64, max_length=64)
-class CreateInput(ProjectInput):
- object_id: str = Field(min_length=1, max_length=160)
- portfolio_id: str | None = Field(default=None, max_length=160)
- hypothesis_refs: tuple[str, ...] = ()
- primary_purpose: str = Field(min_length=1, max_length=80)
- secondary_purposes: tuple[str, ...] = ()
- specification: dict[str, JsonValue]
- evidence_refs: tuple[str, ...]
- expected_object_revision: str | None = Field(default=None, min_length=64, max_length=64)
-
-
class ActionRevisionBound(ActionReadInput):
expected_revision_digest: str = Field(min_length=64, max_length=64)
@@ -375,6 +365,7 @@ async def create(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]:
secondary_purposes=request.secondary_purposes,
specification=cast(dict[str, object], request.specification),
evidence_refs=request.evidence_refs,
+ test_refs=request.test_refs,
)
except ValueError as exc:
raise RpcApplicationError(RpcErrorCode.DOMAIN_REJECTED, str(exc)) from exc
diff --git a/src/thoth/application/commands/discrimination.py b/src/thoth/application/commands/discrimination.py
new file mode 100644
index 0000000..be0e9d3
--- /dev/null
+++ b/src/thoth/application/commands/discrimination.py
@@ -0,0 +1,108 @@
+"""hypothesis/test/result/record, hypothesis/refutation/record and hypothesis/test/result/list.
+
+A person records what a discriminating test showed and what would show a hypothesis wrong. No
+model is called and the hypothesis itself is not edited; the records are read beside it.
+"""
+
+from __future__ import annotations
+
+from pydantic import Field, JsonValue
+
+from thoth.application.commands.verification_trace import require_trace_project
+from thoth.application.services.discrimination_ledger import (
+ DiscriminationLedger,
+ DiscriminationRefused,
+)
+from thoth.application.services.human_actor import RECORD_HUMAN_ONLY
+from thoth.domain.auth import current_authenticated_actor
+from thoth.domain.base import DomainModel
+from thoth.domain.discrimination import MAX_CONDITIONS, MatchKind
+from thoth.domain.enums import ProjectLifecycle
+from thoth.ports.project import ProjectStorePort
+from thoth.protocol.jsonrpc import RpcApplicationError, RpcErrorCode
+
+
+class ListInput(DomainModel):
+ project_id: str = Field(min_length=1, max_length=160)
+
+
+class ResultInput(ListInput):
+ hypothesis_id: str = Field(min_length=1, max_length=200)
+ test_id: str = Field(min_length=1, max_length=200)
+ observation: str = Field(min_length=1, max_length=2_000)
+ matched: MatchKind
+ evidence_refs: tuple[str, ...] = Field(default=(), max_length=20)
+
+
+class ConditionsInput(ListInput):
+ hypothesis_id: str = Field(min_length=1, max_length=200)
+ conditions: tuple[str, ...] = Field(default=(), max_length=MAX_CONDITIONS)
+
+
+class DiscriminationHandlers:
+ def __init__(self, *, ledger: DiscriminationLedger, projects: ProjectStorePort) -> None:
+ self._ledger, self._projects = ledger, projects
+
+ def authorize_before_claim(self, method: str, value: dict[str, JsonValue]) -> None:
+ project = require_trace_project(self._projects, str(value.get("project_id", "")))
+ closed = {ProjectLifecycle.CLOSING, ProjectLifecycle.ARCHIVED_READ_ONLY}
+ if method != "hypothesis/test/result/list" and project.lifecycle in closed:
+ raise RpcApplicationError(
+ RpcErrorCode.DOMAIN_REJECTED, "closing or archived project cannot change a record"
+ )
+
+ @staticmethod
+ def _actor() -> str:
+ actor = current_authenticated_actor()
+ return "human:local-user" if actor is None else actor.actor_id
+
+ @staticmethod
+ def _refused(exc: Exception) -> RpcApplicationError:
+ code = (
+ RpcErrorCode.AUTHORIZATION_DENIED
+ if str(exc) == RECORD_HUMAN_ONLY
+ else RpcErrorCode.DOMAIN_REJECTED
+ )
+ return RpcApplicationError(code, str(exc))
+
+ async def list(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]:
+ request = ListInput.model_validate(value)
+ self.authorize_before_claim("hypothesis/test/result/list", value)
+ return {"items": self._ledger.view(request.project_id)} # type: ignore[dict-item]
+
+ async def record_result(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]:
+ request = ResultInput.model_validate(value)
+ self.authorize_before_claim("hypothesis/test/result/record", value)
+ try:
+ event = self._ledger.record_result(
+ project_id=request.project_id,
+ hypothesis_id=request.hypothesis_id,
+ test_id=request.test_id,
+ observation=request.observation,
+ matched=request.matched,
+ evidence_refs=request.evidence_refs,
+ actor_id=self._actor(),
+ )
+ except (DiscriminationRefused, PermissionError) as exc:
+ raise self._refused(exc) from exc
+ return {
+ "result": event.model_dump(mode="json"),
+ "items": self._ledger.view(request.project_id, request.hypothesis_id), # type: ignore[dict-item]
+ }
+
+ async def record_conditions(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]:
+ request = ConditionsInput.model_validate(value)
+ self.authorize_before_claim("hypothesis/refutation/record", value)
+ try:
+ event = self._ledger.record_conditions(
+ project_id=request.project_id,
+ hypothesis_id=request.hypothesis_id,
+ conditions=request.conditions,
+ actor_id=self._actor(),
+ )
+ except (DiscriminationRefused, PermissionError) as exc:
+ raise self._refused(exc) from exc
+ return {
+ "conditions": event.model_dump(mode="json"),
+ "items": self._ledger.view(request.project_id, request.hypothesis_id), # type: ignore[dict-item]
+ }
diff --git a/src/thoth/application/commands/executions_full.py b/src/thoth/application/commands/executions_full.py
index f273b53..d41243a 100644
--- a/src/thoth/application/commands/executions_full.py
+++ b/src/thoth/application/commands/executions_full.py
@@ -4,14 +4,17 @@
from pydantic import Field, JsonValue
+from thoth.application.services.execution_pending import PendingResultClearing
from thoth.application.services.execution_service import ExecutionService
from thoth.application.services.sandbox_service import SandboxService
from thoth.domain.action_full import ActionPlanRecord
+from thoth.domain.auth import current_authenticated_actor
from thoth.domain.base import DomainModel
from thoth.domain.execution_full import PlanExecutionRecord, StepExecutionAttemptRecord
from thoth.domain.sandbox import SandboxFailure, SandboxRunSpec
from thoth.ports.action import ActionStorePort
from thoth.ports.execution import ExecutionStorePort
+from thoth.ports.ledger import LedgerPort
from thoth.protocol.jsonrpc import RpcApplicationError, RpcErrorCode
@@ -99,6 +102,12 @@ class CompensationInput(ProjectInput):
class InvalidateInput(ExecutionReadInput):
cause_revision_ref: str = Field(min_length=1, max_length=260)
impact_refs: tuple[str, ...]
+ # Clearing an unsettled sandbox result so the same work may run again. The execution revision
+ # that was read is required then, and the earlier attempt and its result are kept.
+ clear_pending_attempt_id: str | None = Field(default=None, min_length=1, max_length=160)
+ expected_execution_revision: int | None = Field(default=None, ge=0)
+ reason: str | None = Field(default=None, min_length=1, max_length=2_000)
+ input_difference: dict[str, JsonValue] | None = None
class ExecutionHandlers:
@@ -109,11 +118,17 @@ def __init__(
actions: ActionStorePort,
service: ExecutionService,
sandbox: SandboxService | None = None,
+ ledger: LedgerPort | None = None,
) -> None:
self._store = store
self._actions = actions
self._service = service
self._sandbox = sandbox
+ self._clearing = (
+ None
+ if ledger is None
+ else PendingResultClearing(store=store, service=service, ledger=ledger)
+ )
async def list(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]:
request = ExecutionListInput.model_validate(value)
@@ -518,11 +533,35 @@ async def compensation_propose(self, value: dict[str, JsonValue]) -> dict[str, J
async def invalidate(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]:
request = InvalidateInput.model_validate(value)
current = self._read_execution(request)
- execution, commit = self._service.invalidate(
- current,
- cause_revision_ref=request.cause_revision_ref,
- impact_refs=request.impact_refs,
- )
+ if request.clear_pending_attempt_id is not None and (
+ request.expected_execution_revision is None
+ or request.expected_execution_revision != current.revision
+ ):
+ raise RpcApplicationError(
+ RpcErrorCode.STALE_CHECKPOINT, "Execution revision changed before clearing"
+ )
+ try:
+ if request.clear_pending_attempt_id is None:
+ execution, commit = self._service.invalidate(
+ current,
+ cause_revision_ref=request.cause_revision_ref,
+ impact_refs=request.impact_refs,
+ )
+ else:
+ if self._clearing is None or not request.reason:
+ raise ValueError("clearing a pending result requires a reason")
+ actor = current_authenticated_actor()
+ execution, commit = self._clearing.clear(
+ current,
+ attempt_id=request.clear_pending_attempt_id,
+ cause_revision_ref=request.cause_revision_ref,
+ impact_refs=request.impact_refs,
+ cleared_by="human:local-user" if actor is None else actor.actor_id,
+ reason=request.reason,
+ input_difference=cast(dict[str, object] | None, request.input_difference),
+ )
+ except ValueError as exc:
+ raise RpcApplicationError(RpcErrorCode.DOMAIN_REJECTED, str(exc)) from exc
return {
"execution": execution.model_dump(mode="json"),
"running_effect_warning": execution.blocked_steps.get("INVALIDATED"),
diff --git a/src/thoth/application/commands/hypothesis_link.py b/src/thoth/application/commands/hypothesis_link.py
new file mode 100644
index 0000000..b5a021f
--- /dev/null
+++ b/src/thoth/application/commands/hypothesis_link.py
@@ -0,0 +1,122 @@
+"""hypothesis/link/list and hypothesis/link/recheck.
+
+The list says, for each hypothesis that came from a trace row, whether that row's verdict has
+changed since; the re-check is a person's recorded decision about one such change. No model is
+called, and a hypothesis is never edited: the link is read against the trace every time.
+"""
+
+from __future__ import annotations
+
+from pydantic import Field, JsonValue
+
+from thoth.application.commands.verification_trace import require_trace_project
+from thoth.application.services.hypothesis_link_recheck import (
+ HypothesisLinkRechecks,
+ RecheckRefused,
+)
+from thoth.application.services.hypothesis_link_view import HypothesisLinkReader, LinkItem
+from thoth.domain.auth import current_authenticated_actor
+from thoth.domain.base import DomainModel
+from thoth.domain.enums import ProjectLifecycle
+from thoth.domain.verdict_link import RecheckReason
+from thoth.ports.project import ProjectStorePort
+from thoth.protocol.jsonrpc import RpcApplicationError, RpcErrorCode
+
+
+class LinkListInput(DomainModel):
+ project_id: str = Field(min_length=1, max_length=160)
+
+
+class LinkRecheckInput(DomainModel):
+ project_id: str = Field(min_length=1, max_length=160)
+ hypothesis_ids: tuple[str, ...] = Field(min_length=1, max_length=50)
+ reason_code: RecheckReason
+ note: str = Field(default="", max_length=2_000)
+ # The verdict revision the person was looking at (null when the row is gone), so that a change
+ # that came in meanwhile is not accepted on their behalf.
+ current_verdict_revision: str | None = Field(default=None, max_length=64)
+
+
+def link_json(item: LinkItem) -> dict[str, JsonValue]:
+ link, found = item.record.verdict_link, item.assessment
+ assert link is not None
+ event = found.recheck
+ return {
+ "hypothesis_id": item.hypothesis_id,
+ "hypothesis_revision_digest": item.hypothesis_revision_digest,
+ "statement": item.statement,
+ "subject_kind": link.subject_kind,
+ "subject_id": link.subject_id,
+ "subject_title": item.subject_title,
+ "state": found.state.value,
+ "change": found.change,
+ "link_state": found.link_state,
+ "current_state": found.current_state,
+ "current_verdict_revision": found.current_verdict_revision,
+ "recheck": None
+ if event is None
+ else {
+ "reason_code": event.reason_code,
+ "note": event.note,
+ "actor_id": event.actor_id,
+ "created_at": event.created_at.isoformat(),
+ "flipped": event.flipped,
+ },
+ }
+
+
+class HypothesisLinkHandlers:
+ def __init__(
+ self,
+ *,
+ reader: HypothesisLinkReader,
+ rechecks: HypothesisLinkRechecks,
+ projects: ProjectStorePort,
+ ) -> None:
+ self._reader, self._rechecks, self._projects = reader, rechecks, projects
+
+ def authorize_before_claim(self, method: str, value: dict[str, JsonValue]) -> None:
+ """The project rule of trace/read; changing a re-check also needs the project to be open."""
+ project = require_trace_project(self._projects, str(value.get("project_id", "")))
+ closed = {ProjectLifecycle.CLOSING, ProjectLifecycle.ARCHIVED_READ_ONLY}
+ if method == "hypothesis/link/recheck" and project.lifecycle in closed:
+ raise RpcApplicationError(
+ RpcErrorCode.DOMAIN_REJECTED, "closing or archived project cannot change a re-check"
+ )
+
+ async def list(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]:
+ request = LinkListInput.model_validate(value)
+ self.authorize_before_claim("hypothesis/link/list", value)
+ trace_digest, _ = self._reader.trace(request.project_id)
+ return {
+ "trace_digest": trace_digest,
+ "links": [link_json(item) for item in self._reader.items(request.project_id)],
+ "reason_distribution": self._reader.reason_distribution(request.project_id), # type: ignore[dict-item]
+ }
+
+ async def recheck(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]:
+ request = LinkRecheckInput.model_validate(value)
+ self.authorize_before_claim("hypothesis/link/recheck", value)
+ actor = current_authenticated_actor()
+ try:
+ events = self._rechecks.recheck(
+ project_id=request.project_id,
+ hypothesis_ids=request.hypothesis_ids,
+ reason=request.reason_code,
+ note=request.note,
+ actor_id="human:local-user" if actor is None else actor.actor_id,
+ current_verdict_revision=request.current_verdict_revision,
+ )
+ except RecheckRefused as exc:
+ code = (
+ RpcErrorCode.AUTHORIZATION_DENIED
+ if str(exc) == "RECHECK_HUMAN_ONLY"
+ else RpcErrorCode.DOMAIN_REJECTED
+ )
+ raise RpcApplicationError(code, str(exc)) from exc
+ ids = tuple(event.hypothesis_id for event in events)
+ return {
+ "events": [event.model_dump(mode="json") for event in events],
+ "links": [link_json(item) for item in self._reader.items(request.project_id, ids)],
+ "reason_distribution": self._reader.reason_distribution(request.project_id), # type: ignore[dict-item]
+ }
diff --git a/src/thoth/application/commands/hypothesis_same.py b/src/thoth/application/commands/hypothesis_same.py
new file mode 100644
index 0000000..ca782b8
--- /dev/null
+++ b/src/thoth/application/commands/hypothesis_same.py
@@ -0,0 +1,72 @@
+"""hypothesis/same/record and hypothesis/same/list.
+
+A person marks two hypotheses of different investigations as the same hypothesis, and may take the
+mark back. The marks are only a reference: nothing about either hypothesis is changed.
+"""
+
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import Field, JsonValue
+
+from thoth.application.commands.verification_trace import require_trace_project
+from thoth.application.services.human_actor import RECORD_HUMAN_ONLY
+from thoth.application.services.hypothesis_same import HypothesisSame, SameRefused
+from thoth.domain.auth import current_authenticated_actor
+from thoth.domain.base import DomainModel
+from thoth.domain.enums import ProjectLifecycle
+from thoth.ports.project import ProjectStorePort
+from thoth.protocol.jsonrpc import RpcApplicationError, RpcErrorCode
+
+
+class SameListInput(DomainModel):
+ project_id: str = Field(min_length=1, max_length=160)
+
+
+class SameRecordInput(SameListInput):
+ hypothesis_ids: tuple[str, str]
+ action: Literal["LINK", "UNLINK"]
+ note: str = Field(default="", max_length=500)
+
+
+class SameHandlers:
+ def __init__(self, *, same: HypothesisSame, projects: ProjectStorePort) -> None:
+ self._same, self._projects = same, projects
+
+ def authorize_before_claim(self, method: str, value: dict[str, JsonValue]) -> None:
+ project = require_trace_project(self._projects, str(value.get("project_id", "")))
+ closed = {ProjectLifecycle.CLOSING, ProjectLifecycle.ARCHIVED_READ_ONLY}
+ if method == "hypothesis/same/record" and project.lifecycle in closed:
+ raise RpcApplicationError(
+ RpcErrorCode.DOMAIN_REJECTED, "closing or archived project cannot change a mark"
+ )
+
+ async def list(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]:
+ request = SameListInput.model_validate(value)
+ self.authorize_before_claim("hypothesis/same/list", value)
+ return self._same.view(request.project_id) # type: ignore[return-value]
+
+ async def record(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]:
+ request = SameRecordInput.model_validate(value)
+ self.authorize_before_claim("hypothesis/same/record", value)
+ actor = current_authenticated_actor()
+ try:
+ event = self._same.record(
+ project_id=request.project_id,
+ hypothesis_ids=request.hypothesis_ids,
+ action=request.action,
+ note=request.note,
+ actor_id="human:local-user" if actor is None else actor.actor_id,
+ )
+ except (SameRefused, PermissionError) as exc:
+ code = (
+ RpcErrorCode.AUTHORIZATION_DENIED
+ if str(exc) == RECORD_HUMAN_ONLY
+ else RpcErrorCode.DOMAIN_REJECTED
+ )
+ raise RpcApplicationError(code, str(exc)) from exc
+ return {
+ "event": event.model_dump(mode="json"),
+ **self._same.view(request.project_id), # type: ignore[dict-item]
+ }
diff --git a/src/thoth/application/commands/lesson.py b/src/thoth/application/commands/lesson.py
new file mode 100644
index 0000000..5a9db49
--- /dev/null
+++ b/src/thoth/application/commands/lesson.py
@@ -0,0 +1,36 @@
+"""trace/lesson/list: the lessons recalled for each trace row, from its exact context.
+
+A read. A lesson is a reference to a record a rule computed or a person made; each row gets only the
+lessons of its own criterion, unit, condition and rule revision, marked stale or refuted when they
+are. Nothing is ranked or counted, and no model is called.
+"""
+
+from __future__ import annotations
+
+from pydantic import Field, JsonValue
+
+from thoth.application.commands.verification_trace import require_trace_project
+from thoth.application.services.lesson_ledger import LessonLedger
+from thoth.application.services.trace_closure import TraceClosures
+from thoth.domain.base import DomainModel
+from thoth.ports.project import ProjectStorePort
+
+
+class LessonListInput(DomainModel):
+ project_id: str = Field(min_length=1, max_length=160)
+
+
+class LessonHandlers:
+ def __init__(
+ self, *, lessons: LessonLedger, closures: TraceClosures, projects: ProjectStorePort
+ ) -> None:
+ self._lessons, self._closures, self._projects = lessons, closures, projects
+
+ def authorize_before_claim(self, method: str, value: dict[str, JsonValue]) -> None:
+ require_trace_project(self._projects, str(value.get("project_id", "")))
+
+ async def list(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]:
+ request = LessonListInput.model_validate(value)
+ self.authorize_before_claim("trace/lesson/list", value)
+ rows = self._closures.view(request.project_id)
+ return {"lessons": self._lessons.recall(request.project_id, rows)} # type: ignore[dict-item]
diff --git a/src/thoth/application/commands/memory_settings.py b/src/thoth/application/commands/memory_settings.py
index 4e5b8eb..cefe5b1 100644
--- a/src/thoth/application/commands/memory_settings.py
+++ b/src/thoth/application/commands/memory_settings.py
@@ -1,4 +1,4 @@
-"""memory/settings/read and memory/settings/update: the project's memory on/off switch."""
+"""memory/settings/read and memory/settings/update: the project's memory switches."""
from __future__ import annotations
@@ -18,6 +18,8 @@ class MemorySettingsRead(DomainModel):
class MemorySettingsUpdate(DomainModel):
project_id: str = Field(min_length=1, max_length=160)
memory_injection: bool
+ # Left out, it keeps what the project has; the web memory panel sends it with its own switch.
+ query_expansion: bool | None = None
expected_digest: str | None = Field(default=None, min_length=64, max_length=64)
@@ -30,8 +32,12 @@ def _require_project(self, project_id: str) -> None:
raise RpcApplicationError(RpcErrorCode.PROJECT_NOT_FOUND, "project not found")
def _value(self, project_id: str) -> dict[str, JsonValue]:
- digest, enabled = self._service.read(project_id)
- return {"memory_injection": enabled, "settings_digest": digest}
+ digest, settings = self._service.read_settings(project_id)
+ return {
+ "memory_injection": settings.memory_injection,
+ "query_expansion": settings.query_expansion,
+ "settings_digest": digest,
+ }
async def read(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]:
request = MemorySettingsRead.model_validate(value)
@@ -48,6 +54,7 @@ async def update(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]:
request.memory_injection,
request.expected_digest,
"human:local-user" if actor is None else actor.actor_id,
+ request.query_expansion,
)
except MemorySettingsConflict as exc:
raise RpcApplicationError(RpcErrorCode.STALE_CHECKPOINT, str(exc)) from exc
diff --git a/src/thoth/application/commands/model_call_settings.py b/src/thoth/application/commands/model_call_settings.py
index 41baf8b..7a8aa79 100644
--- a/src/thoth/application/commands/model_call_settings.py
+++ b/src/thoth/application/commands/model_call_settings.py
@@ -20,7 +20,9 @@ class ModelCallSettingsRead(DomainModel):
class ModelCallSettingsUpdate(DomainModel):
project_id: str = Field(min_length=1, max_length=160)
- auto_retry_interrupted_model_call: bool
+ # A switch left out keeps its value.
+ auto_retry_interrupted_model_call: bool | None = None
+ hypothesis_contract_v3: bool | None = None
expected_digest: str | None = Field(default=None, min_length=64, max_length=64)
@@ -33,8 +35,12 @@ def _require_project(self, project_id: str) -> None:
raise RpcApplicationError(RpcErrorCode.PROJECT_NOT_FOUND, "project not found")
def _value(self, project_id: str) -> dict[str, JsonValue]:
- digest, enabled = self._service.read(project_id)
- return {"auto_retry_interrupted_model_call": enabled, "settings_digest": digest}
+ digest, enabled, contract = self._service.snapshot(project_id)
+ return {
+ "auto_retry_interrupted_model_call": enabled,
+ "hypothesis_contract_v3": contract,
+ "settings_digest": digest,
+ }
async def read(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]:
request = ModelCallSettingsRead.model_validate(value)
@@ -51,6 +57,7 @@ async def update(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]:
request.auto_retry_interrupted_model_call,
request.expected_digest,
"human:local-user" if actor is None else actor.actor_id,
+ request.hypothesis_contract_v3,
)
except ModelCallSettingsConflict as exc:
raise RpcApplicationError(RpcErrorCode.STALE_CHECKPOINT, str(exc)) from exc
diff --git a/src/thoth/application/commands/research_resume.py b/src/thoth/application/commands/research_resume.py
new file mode 100644
index 0000000..cdeef6b
--- /dev/null
+++ b/src/thoth/application/commands/research_resume.py
@@ -0,0 +1,46 @@
+"""Continue an interrupted run: the same question, asked again as a new operation."""
+
+from __future__ import annotations
+
+from typing import Protocol
+
+from pydantic import JsonValue
+
+from thoth.application.services.request_records import RequestRecords
+from thoth.domain.enums import EntityType, OperationState
+from thoth.domain.research_request import ResearchAttempt, ThreadRequestRevision
+from thoth.ports.operation import OperationStorePort
+from thoth.protocol.jsonrpc import RpcApplicationError, RpcErrorCode
+
+
+class ResumeHost(Protocol):
+ records: RequestRecords
+ operations: OperationStorePort
+
+
+def resume_payload(host: ResumeHost, value: dict[str, JsonValue]) -> dict[str, JsonValue]:
+ """The same question as the interrupted run, asked again as a new operation.
+
+ The user asked to continue the latest, finished run of this thread. Its completed stages
+ may then be reused (research_stage_reuse.py); nothing is reused without this request.
+ """
+ project_id, thread_id = str(value["project_id"]), str(value["thread_id"])
+ source_id = str(value["resume_from_operation_id"])
+ head = host.records.read(project_id, EntityType.THREAD, f"request:{thread_id}")
+ source = host.records.journal_read(project_id, source_id, ResearchAttempt)
+ finished = host.operations.read(source_id)
+ if head is None or source is None or source.request_ref != head[0]:
+ raise RpcApplicationError(RpcErrorCode.DOMAIN_REJECTED, "RESUME_SOURCE_NOT_CURRENT")
+ if finished is None or finished.project_id != project_id:
+ raise RpcApplicationError(RpcErrorCode.DOMAIN_REJECTED, "RESUME_SOURCE_NOT_CURRENT")
+ if finished.state == OperationState.RUNNING:
+ raise RpcApplicationError(RpcErrorCode.DOMAIN_REJECTED, "RESUME_SOURCE_STILL_RUNNING")
+ current = ThreadRequestRevision.model_validate(head[1])
+ if len(current.effective_question) > 20_000:
+ raise RpcApplicationError(RpcErrorCode.DOMAIN_REJECTED, "RESUME_QUESTION_TOO_LONG")
+ return {
+ **value,
+ "instruction": current.effective_question,
+ "edit_kind": "REPLACE",
+ "expected_request_epoch": current.request_epoch,
+ }
diff --git a/src/thoth/application/commands/research_threads.py b/src/thoth/application/commands/research_threads.py
index de9b58a..8a634a7 100644
--- a/src/thoth/application/commands/research_threads.py
+++ b/src/thoth/application/commands/research_threads.py
@@ -5,8 +5,10 @@
from pydantic import JsonValue
+from thoth.application.commands.research_resume import resume_payload
from thoth.application.commands.thread_analysis import ThreadInputRequest
from thoth.application.commands.threads import ThreadCommandHandlers
+from thoth.application.commands.trace_origin_admission import admit_trace_origin
from thoth.application.services.connector_cleanup import cleanup_summary
from thoth.application.services.model_call_settings import ModelCallSettingsService
from thoth.application.services.model_settings import ModelSettingsService
@@ -23,10 +25,10 @@
from thoth.application.services.research_input_queue import ResearchInputQueue
from thoth.application.services.research_leases import ResearchLeases
from thoth.application.services.research_operation_access import ResearchOperationAccess
-from thoth.application.services.research_read_summary import (
- VIEWS,
- progress_thread_read,
- summarize_thread_read,
+from thoth.application.services.research_read_summary import (
+ VIEWS,
+ progress_thread_read,
+ summarize_thread_read,
)
from thoth.domain.account_usage import AccountQuotaSnapshot
from thoth.domain.auth import current_authenticated_actor
@@ -164,10 +166,11 @@ async def start(self, value: dict[str, JsonValue]) -> dict[str, JsonValue] | Acc
)
if existing is not None:
return self.replay_attempt(existing, operation)
+ value = admit_trace_origin(self, "thread/start", value)
data = {
k: v
for k, v in value.items()
- if k not in {"contract_version", "provider", "model", "reasoning_effort"}
+ if k not in {"contract_version", "provider", "model", "reasoning_effort", "origin"}
}
result = await self.legacy.start(data)
return self.submit(
@@ -176,8 +179,8 @@ async def start(self, value: dict[str, JsonValue]) -> dict[str, JsonValue] | Acc
"thread_id": result["thread_id"],
"instruction": value["problem"],
"provider": value.get("provider", "default"),
- "model": value.get("model"),
- "reasoning_effort": value.get("reasoning_effort"),
+ **{k: value.get(k) for k in ("model", "reasoning_effort")},
+ **({"origin": value["origin"]} if "origin" in value else {}),
},
initial=True,
)
@@ -197,43 +200,18 @@ async def input(
)
if existing is not None:
return self.replay_attempt(existing, operation)
+ payload = admit_trace_origin(self, "thread/input", payload)
if payload.get("resume_from_operation_id") is not None:
- payload = self.resume_payload(payload)
+ payload = resume_payload(self, payload)
queued = self.queue.accept_if_busy(self, operation, payload)
if queued is not None:
return queued
return self.submit(payload)
- def resume_payload(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]:
- """The same question as the interrupted run, asked again as a new operation.
-
- The user asked to continue the latest, finished run of this thread. Its completed stages
- may then be reused (research_stage_reuse.py); nothing is reused without this request.
- """
- project_id, thread_id = str(value["project_id"]), str(value["thread_id"])
- source_id = str(value["resume_from_operation_id"])
- head = self.records.read(project_id, EntityType.THREAD, f"request:{thread_id}")
- source = self.records.journal_read(project_id, source_id, ResearchAttempt)
- finished = self.operations.read(source_id)
- if head is None or source is None or source.request_ref != head[0]:
- raise RpcApplicationError(RpcErrorCode.DOMAIN_REJECTED, "RESUME_SOURCE_NOT_CURRENT")
- if finished is None or finished.project_id != project_id:
- raise RpcApplicationError(RpcErrorCode.DOMAIN_REJECTED, "RESUME_SOURCE_NOT_CURRENT")
- if finished.state == OperationState.RUNNING:
- raise RpcApplicationError(RpcErrorCode.DOMAIN_REJECTED, "RESUME_SOURCE_STILL_RUNNING")
- current = ThreadRequestRevision.model_validate(head[1])
- if len(current.effective_question) > 20_000:
- raise RpcApplicationError(RpcErrorCode.DOMAIN_REJECTED, "RESUME_QUESTION_TOO_LONG")
- return {
- **value,
- "instruction": current.effective_question,
- "edit_kind": "REPLACE",
- "expected_request_epoch": current.request_epoch,
- }
-
async def steer(self, value: dict[str, JsonValue]) -> dict[str, JsonValue] | AcceptedRunning:
if value.get("contract_version") != 2:
return await self.legacy.steer(value)
+ admit_trace_origin(self, "thread/steer", value)
with self.records.ledger.transaction():
submitted = self.submit(
{
@@ -874,3 +852,5 @@ def _read_current_scoped(
if value.get("view") == "PROGRESS":
return progress_thread_read(shown)
return summarize_thread_read(shown)
+
+
diff --git a/src/thoth/application/commands/thread_analysis.py b/src/thoth/application/commands/thread_analysis.py
index 063ba15..2aea54d 100644
--- a/src/thoth/application/commands/thread_analysis.py
+++ b/src/thoth/application/commands/thread_analysis.py
@@ -22,6 +22,7 @@
from thoth.application.services.criterion_projection import (
criterion_projection as _criterion_projection,
)
+from thoth.application.services.memory_query_expansion import ModelMemoryQueryExpander, widen_query
from thoth.application.services.post_execution_memory import PostExecutionMemory
from thoth.application.services.r2_closed_loop import R2ClosedLoopExecution
from thoth.application.services.research_basis_capture import (
@@ -37,7 +38,7 @@
from thoth.domain.auth import authenticated_data_scope_allows, current_authenticated_actor
from thoth.domain.base import DomainModel
from thoth.domain.baseline import MultiBaselineProjection
-from thoth.domain.canonical import head_set_digest
+from thoth.domain.canonical import head_set_digest, same_stored_instant
from thoth.domain.criterion import CriterionCandidate
from thoth.domain.enums import (
ActorKind,
@@ -46,7 +47,7 @@
ThreadExecutionState,
)
from thoth.domain.evidence import EvidenceSpan
-from thoth.domain.hypothesis import HypothesisPortfolio
+from thoth.domain.hypothesis import HypothesisPortfolio, is_v3, portfolio_from_json
from thoth.domain.improvement import RecursiveImprovementResult
from thoth.domain.memory_preparation import FullMemoryPromotionResult
from thoth.domain.post_execution_learning import PostExecutionLearningResult
@@ -234,6 +235,20 @@ async def analyze(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]:
analysis_problem += "\n\nAuthorized queued context:\n" + "\n".join(
f"[{item.kind}] {item.text}" for item in pending_inputs
)
+ # One model call widens the question for memory recall; a research run already made it.
+ expansion = await widen_query(
+ memory=self._full_memory,
+ expander=ModelMemoryQueryExpander(
+ model,
+ project_id=request.project_id,
+ cutoff_at=project.cutoff_at,
+ model_policy_ref=f"model-policy:{request.provider}-v1",
+ head_set_digest=head_set_digest(self._ledger.read_heads(request.project_id)),
+ ),
+ work=work,
+ project_id=request.project_id,
+ query=analysis_problem,
+ )
try:
full_memory_context = self._full_memory.build_context(
project_id=request.project_id,
@@ -242,6 +257,7 @@ async def analyze(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]:
target_use="WORKING_CONTEXT",
scope=thread.scope,
cutoff_at=project.cutoff_at,
+ expansion=expansion,
)
except ValidationError:
full_memory_context = None
@@ -441,10 +457,10 @@ async def _execute_cycle(
)
if critical_counter is not None:
critical_counter_projection = critical_counter.projection
- final_portfolio = critical_counter.projection.get("portfolio")
- if isinstance(final_portfolio, dict):
+ final = critical_counter.projection.get("portfolio")
+ if isinstance(final, dict):
result = result.model_copy(
- update={"portfolio": HypothesisPortfolio.model_validate(final_portfolio)}
+ update={"portfolio": portfolio_from_json(final, v3=is_v3(result.portfolio))}
)
check_research_boundary()
r2_execution = await self._execute_r2(
@@ -612,7 +628,7 @@ async def _execute_r2(
current = self._projects.read(project.project_id)
if (
current is None
- or current.cutoff_at != project.cutoff_at
+ or not same_stored_instant(current.cutoff_at, project.cutoff_at)
or current.policy_binding_ref != project.policy_binding_ref
or current.overlay != project.overlay
or current.lifecycle in {ProjectLifecycle.CLOSING, ProjectLifecycle.ARCHIVED_READ_ONLY}
diff --git a/src/thoth/application/commands/trace_closure.py b/src/thoth/application/commands/trace_closure.py
new file mode 100644
index 0000000..cfeb0ec
--- /dev/null
+++ b/src/thoth/application/commands/trace_closure.py
@@ -0,0 +1,84 @@
+"""trace/closure/record and trace/closure/list.
+
+A person records that a row that was not met was closed some way outside the system, with the
+document that says so. The verdict is not changed; the closure is shown beside it.
+"""
+
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import Field, JsonValue
+
+from thoth.application.commands.verification_trace import require_trace_project
+from thoth.application.services.human_actor import RECORD_HUMAN_ONLY
+from thoth.application.services.trace_closure import ClosureRefused, TraceClosures
+from thoth.domain.auth import current_authenticated_actor
+from thoth.domain.base import DomainModel
+from thoth.domain.enums import ProjectLifecycle
+from thoth.domain.trace_closure import ClosureKind
+from thoth.domain.verification_trace import SubjectKind
+from thoth.ports.project import ProjectStorePort
+from thoth.protocol.jsonrpc import RpcApplicationError, RpcErrorCode
+
+
+class ClosureListInput(DomainModel):
+ project_id: str = Field(min_length=1, max_length=160)
+
+
+class ClosureRecordInput(ClosureListInput):
+ subject_kind: Literal["CRITERION", "REQUIREMENT"]
+ subject_id: str = Field(min_length=1, max_length=500)
+ kind: ClosureKind
+ basis_ref: str = Field(min_length=1, max_length=500)
+ note: str = Field(default="", max_length=2_000)
+ scope: str | None = Field(default=None, max_length=500)
+ # The verdict revision the person was looking at, so a change that came in meanwhile is not
+ # closed on their behalf.
+ current_verdict_revision: str = Field(min_length=64, max_length=64)
+
+
+class ClosureHandlers:
+ def __init__(self, *, closures: TraceClosures, projects: ProjectStorePort) -> None:
+ self._closures, self._projects = closures, projects
+
+ def authorize_before_claim(self, method: str, value: dict[str, JsonValue]) -> None:
+ project = require_trace_project(self._projects, str(value.get("project_id", "")))
+ closed = {ProjectLifecycle.CLOSING, ProjectLifecycle.ARCHIVED_READ_ONLY}
+ if method == "trace/closure/record" and project.lifecycle in closed:
+ raise RpcApplicationError(
+ RpcErrorCode.DOMAIN_REJECTED, "closing or archived project cannot change a closure"
+ )
+
+ async def list(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]:
+ request = ClosureListInput.model_validate(value)
+ self.authorize_before_claim("trace/closure/list", value)
+ return {"closures": self._closures.view(request.project_id)} # type: ignore[dict-item]
+
+ async def record(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]:
+ request = ClosureRecordInput.model_validate(value)
+ self.authorize_before_claim("trace/closure/record", value)
+ actor = current_authenticated_actor()
+ try:
+ event = self._closures.record(
+ project_id=request.project_id,
+ subject_kind=SubjectKind(request.subject_kind),
+ subject_id=request.subject_id,
+ kind=request.kind,
+ basis_ref=request.basis_ref,
+ note=request.note,
+ scope=request.scope,
+ current_verdict_revision=request.current_verdict_revision,
+ actor_id="human:local-user" if actor is None else actor.actor_id,
+ )
+ except (ClosureRefused, PermissionError) as exc:
+ code = (
+ RpcErrorCode.AUTHORIZATION_DENIED
+ if str(exc) == RECORD_HUMAN_ONLY
+ else RpcErrorCode.DOMAIN_REJECTED
+ )
+ raise RpcApplicationError(code, str(exc)) from exc
+ return {
+ "event": event.model_dump(mode="json"),
+ "closures": self._closures.view(request.project_id), # type: ignore[dict-item]
+ }
diff --git a/src/thoth/application/commands/trace_origin_admission.py b/src/thoth/application/commands/trace_origin_admission.py
new file mode 100644
index 0000000..9f628a1
--- /dev/null
+++ b/src/thoth/application/commands/trace_origin_admission.py
@@ -0,0 +1,132 @@
+"""Admit a trace origin on a research request, and show it on thread/list.
+
+A request without an origin passes as it was. An origin is checked against the stored trace (the
+client only names the row) and replaced by the facts the server reads. A request that was already
+accepted (a resend with the same key) is not checked again, so a resend never fails because the
+verdict moved on. The check runs inside the research handlers, after their own replay check.
+"""
+
+from __future__ import annotations
+
+import functools
+from typing import Protocol, cast
+
+from pydantic import JsonValue, ValidationError
+
+from thoth.application.commands.verification_trace import require_trace_project
+from thoth.application.services.request_records import RequestRecords
+from thoth.application.services.trace_origin import TraceOriginRefused, build_trace_origin
+from thoth.application.services.verification_trace import VerificationTraceService
+from thoth.domain.research_request import ResearchAttempt
+from thoth.domain.trace_origin import TraceOriginInput
+from thoth.ports.operation import OperationStorePort
+from thoth.ports.project import ProjectStorePort
+from thoth.protocol.deferred import (
+ AcceptedRunning,
+ EphemeralCommandResult,
+ PendingExecution,
+ current_operation,
+)
+from thoth.protocol.jsonrpc import RpcApplicationError, RpcErrorCode
+from thoth.protocol.registry import CommandHandler
+
+_RESEARCH_METHODS = frozenset({"thread/start", "thread/input"})
+Origin = dict[str, JsonValue]
+HandlerResult = dict[str, JsonValue] | AcceptedRunning | PendingExecution | EphemeralCommandResult
+
+
+class OriginHost(Protocol):
+ records: RequestRecords
+ operations: OperationStorePort
+ projects: ProjectStorePort
+
+
+def thread_origins(
+ records: RequestRecords, operations: OperationStorePort, project_id: str
+) -> dict[str, Origin]:
+ """The row each thread belongs to: the newest origin among its accepted research requests."""
+ found: dict[str, Origin] = {}
+ for operation in operations.list_by_project(project_id):
+ if operation.method not in _RESEARCH_METHODS:
+ continue
+ attempt = records.journal_read(project_id, operation.operation_id, ResearchAttempt)
+ origin = None if attempt is None else attempt.continuation.get("origin")
+ thread_id = None if attempt is None else attempt.continuation.get("thread_id")
+ if isinstance(origin, dict) and isinstance(thread_id, str):
+ row = cast(dict[str, JsonValue], origin)
+ found[thread_id] = {
+ "subject_kind": row.get("subject_kind"),
+ "subject_id": row.get("subject_id"),
+ "verdict_revision": row.get("verdict_revision"),
+ }
+ return found
+
+
+def admit_trace_origin(
+ host: OriginHost, method: str, value: dict[str, JsonValue]
+) -> dict[str, JsonValue]:
+ """The request with its origin replaced by the server's own, or as it was without one."""
+ if "origin" not in value:
+ return value
+ if value["origin"] is None:
+ return {key: item for key, item in value.items() if key != "origin"}
+ if method == "thread/steer":
+ raise _refused("TRACE_ORIGIN_NOT_ALLOWED")
+ project_id = str(value.get("project_id", ""))
+ if _already_accepted(host.records, project_id):
+ return value
+ try:
+ claimed = TraceOriginInput.model_validate(value["origin"])
+ except ValidationError as exc:
+ raise _refused("TRACE_ORIGIN_INVALID", RpcErrorCode.INVALID_PARAMS) from exc
+ require_trace_project(host.projects, project_id)
+ try:
+ origin = build_trace_origin(VerificationTraceService(host.records), project_id, claimed)
+ except TraceOriginRefused as exc:
+ raise _refused(str(exc)) from exc
+ if method == "thread/input":
+ own = thread_origins(host.records, host.operations, project_id).get(
+ str(value.get("thread_id"))
+ )
+ if own is None or (own["subject_kind"], own["subject_id"]) != (
+ origin.subject_kind,
+ origin.subject_id,
+ ):
+ raise _refused("TRACE_ORIGIN_THREAD_MISMATCH")
+ return {**value, "origin": origin.model_dump(mode="json")}
+
+
+def with_thread_origins(
+ inner: CommandHandler, records: RequestRecords, operations: OperationStorePort
+) -> CommandHandler:
+ """thread/list with each thread's origin (null for an ordinary or older thread)."""
+
+ @functools.wraps(inner)
+ async def listed(value: dict[str, JsonValue]) -> HandlerResult:
+ result = await inner(value)
+ if not isinstance(result, dict) or not isinstance(result.get("threads"), list):
+ return result
+ found = thread_origins(records, operations, str(value["project_id"]))
+ shown: list[JsonValue] = []
+ for item in cast(list[JsonValue], result["threads"]):
+ if isinstance(item, dict):
+ shown.append({**item, "origin": found.get(str(item.get("thread_id")))})
+ else:
+ shown.append(item)
+ return {**result, "threads": shown}
+
+ return listed
+
+
+def _already_accepted(records: RequestRecords, project_id: str) -> bool:
+ operation = current_operation.get()
+ if operation is None:
+ return False
+ key = operation.operation_id
+ if records.journal_read(project_id, key, ResearchAttempt) is not None:
+ return True
+ return records.controls.read(project_id, "RESEARCH_EXECUTION", f"queue:{key}") is not None
+
+
+def _refused(reason: str, code: RpcErrorCode = RpcErrorCode.DOMAIN_REJECTED) -> RpcApplicationError:
+ return RpcApplicationError(code, reason)
diff --git a/src/thoth/application/commands/verification_trace.py b/src/thoth/application/commands/verification_trace.py
new file mode 100644
index 0000000..a3e42eb
--- /dev/null
+++ b/src/thoth/application/commands/verification_trace.py
@@ -0,0 +1,205 @@
+"""trace/read, trace/export, trace/importPreview, trace/importApply and trace/confirm.
+
+The trace is a project's requirement-to-result table with verdicts computed by rule. These handlers
+only carry the user's actions to the services: no model is called. Reads change nothing; a preview
+saves nothing; an apply stores the new set and its verdicts in one write.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Mapping
+from typing import Literal
+
+from pydantic import Field, JsonValue
+
+from thoth.application.services.trace_csv import (
+ FORMAT,
+ LOSS_MANIFEST,
+ MAX_CSV_CHARS,
+ export_csv,
+ input_sha256,
+)
+from thoth.application.services.verification_trace import (
+ TraceConflict,
+ TraceError,
+ VerificationTraceService,
+)
+from thoth.application.services.verification_trace_import import (
+ ImportRejected,
+ TraceImportService,
+)
+from thoth.application.services.verification_trace_view import revision_json, trace_view
+from thoth.domain.auth import current_authenticated_actor
+from thoth.domain.base import DomainModel
+from thoth.domain.enums import ProjectLifecycle
+from thoth.domain.project import Project as ProjectRecord
+from thoth.domain.verification_trace import SubjectKind, TraceSet
+from thoth.ports.project import ProjectStorePort
+from thoth.protocol.jsonrpc import RpcApplicationError, RpcErrorCode
+
+_STALE = ("TRACE_IMPORT_PREVIEW_STALE", "TRACE_IMPORT_INPUT_CHANGED")
+
+
+def require_trace_project(projects: ProjectStorePort, project_id: str) -> ProjectRecord:
+ """The project exists, and a signed-in user needs project scope (trace/read's rule)."""
+ project = projects.read(project_id)
+ if project is None:
+ raise RpcApplicationError(RpcErrorCode.PROJECT_NOT_FOUND, "project not found")
+ actor = current_authenticated_actor()
+ if actor is not None and "PROJECT" not in actor.data_scopes:
+ raise RpcApplicationError(
+ RpcErrorCode.AUTHORIZATION_DENIED, "PROJECT_SETTINGS_SCOPE_REQUIRED"
+ )
+ return project
+
+
+class TraceRead(DomainModel):
+ project_id: str = Field(min_length=1, max_length=160)
+
+
+class TraceHistoryRead(TraceRead):
+ subject_kind: Literal["CRITERION", "REQUIREMENT"]
+ subject_id: str = Field(min_length=1, max_length=500)
+ before_digest: str | None = Field(default=None, min_length=64, max_length=64)
+ limit: int = Field(default=20, ge=1, le=50)
+
+
+class TraceImportPreview(TraceRead):
+ mode: Literal["CREATE", "UPDATE"]
+ csv_text: str = Field(min_length=1, max_length=MAX_CSV_CHARS)
+
+
+class TraceImportApply(TraceImportPreview):
+ preview_id: str = Field(min_length=64, max_length=64)
+ input_sha256: str = Field(min_length=64, max_length=64)
+
+
+class TraceConfirm(TraceRead):
+ verdict_revision_digest: str = Field(min_length=64, max_length=64)
+ rationale: str = Field(min_length=1, max_length=2000)
+ expected_digest: str | None = Field(default=None, min_length=64, max_length=64)
+
+
+class TraceHandlers:
+ def __init__(
+ self,
+ *,
+ service: VerificationTraceService,
+ importer: TraceImportService,
+ projects: ProjectStorePort,
+ closure_notes: Callable[[str], Mapping[str, str]] | None = None,
+ ) -> None:
+ self._service, self._importer, self._projects = service, importer, projects
+ self._closure_notes = closure_notes # project id -> the closure_status note of each row
+
+ def authorize_before_claim(self, method: str, value: dict[str, JsonValue]) -> None:
+ """The same project rule as the model settings: the project exists, and a signed-in
+ user needs project scope. Changing the trace also needs the project to be open."""
+ project = require_trace_project(self._projects, str(value.get("project_id", "")))
+ writes = method in {"trace/importApply", "trace/confirm"}
+ closed = {ProjectLifecycle.CLOSING, ProjectLifecycle.ARCHIVED_READ_ONLY}
+ if writes and project.lifecycle in closed:
+ raise RpcApplicationError(
+ RpcErrorCode.DOMAIN_REJECTED, "closing or archived project cannot change the trace"
+ )
+
+ def _actor(self) -> str:
+ actor = current_authenticated_actor()
+ return "human:local-user" if actor is None else actor.actor_id
+
+ def _view(self, project_id: str) -> dict[str, JsonValue]:
+ digest, record = self._service.read(project_id)
+ return trace_view(self._service, project_id, digest, record)
+
+ async def read(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]:
+ request = TraceRead.model_validate(value)
+ self.authorize_before_claim("trace/read", value)
+ return self._view(request.project_id)
+
+ async def history(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]:
+ """Older revisions of one verdict, newest first, in pages."""
+ request = TraceHistoryRead.model_validate(value)
+ self.authorize_before_claim("trace/history", value)
+ _, record = self._service.read(request.project_id)
+ if record is None:
+ raise RpcApplicationError(RpcErrorCode.DOMAIN_REJECTED, "TRACE_NOT_FOUND")
+ try:
+ page = self._service.history(
+ request.project_id,
+ record,
+ SubjectKind(request.subject_kind),
+ request.subject_id,
+ before=request.before_digest,
+ limit=request.limit,
+ )
+ except TraceError as exc:
+ raise RpcApplicationError(RpcErrorCode.DOMAIN_REJECTED, str(exc)) from exc
+ return {
+ "subject_kind": request.subject_kind,
+ "subject_id": request.subject_id,
+ "history_total": page.total,
+ "revisions": [revision_json(item) for item in page.revisions],
+ "next_before_digest": page.next_before,
+ }
+
+ async def export(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]:
+ request = TraceRead.model_validate(value)
+ self.authorize_before_claim("trace/export", value)
+ _, record = self._service.read(request.project_id)
+ trace_set = TraceSet() if record is None else record.trace_set
+ text = export_csv(
+ trace_set, self._closure_notes and self._closure_notes(request.project_id)
+ )
+ return {
+ "format": FORMAT,
+ "filename": "thoth-trace.csv",
+ "csv_text": text,
+ "sha256": input_sha256(text),
+ "set_digest": trace_set.set_digest,
+ "loss_manifest": list(LOSS_MANIFEST),
+ }
+
+ async def import_preview(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]:
+ request = TraceImportPreview.model_validate(value)
+ self.authorize_before_claim("trace/importPreview", value)
+ plan = self._importer.preview(request.project_id, request.mode, request.csv_text)
+ return plan.as_json()
+
+ async def import_apply(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]:
+ request = TraceImportApply.model_validate(value)
+ self.authorize_before_claim("trace/importApply", value)
+ try:
+ result = self._importer.apply(
+ request.project_id,
+ request.mode,
+ request.csv_text,
+ request.preview_id,
+ request.input_sha256,
+ self._actor(),
+ )
+ except TraceError as exc:
+ raise self._error(exc) from exc
+ return {**result, "trace": self._view(request.project_id)}
+
+ async def confirm(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]:
+ request = TraceConfirm.model_validate(value)
+ self.authorize_before_claim("trace/confirm", value)
+ try:
+ self._service.confirm(
+ request.project_id,
+ request.verdict_revision_digest,
+ self._actor(),
+ request.rationale,
+ request.expected_digest,
+ )
+ except TraceError as exc:
+ raise self._error(exc) from exc
+ return self._view(request.project_id)
+
+ @staticmethod
+ def _error(exc: TraceError) -> RpcApplicationError:
+ if isinstance(exc, TraceConflict) or (
+ isinstance(exc, ImportRejected) and str(exc) in _STALE
+ ):
+ return RpcApplicationError(RpcErrorCode.STALE_CHECKPOINT, str(exc))
+ return RpcApplicationError(RpcErrorCode.DOMAIN_REJECTED, str(exc))
diff --git a/src/thoth/application/reducers/authority_router.py b/src/thoth/application/reducers/authority_router.py
index 53a02b2..dc19bf1 100644
--- a/src/thoth/application/reducers/authority_router.py
+++ b/src/thoth/application/reducers/authority_router.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from thoth.domain.action import ActionCandidate, ActionRiskFacts
+from thoth.domain.action import PROHIBITED_EFFECT_KEYS, ActionCandidate, ActionRiskFacts
from thoth.domain.base import DomainModel
from thoth.domain.enums import ActionState, ExecutionAuthority, RiskTier
from thoth.domain.errors import InvariantViolation
@@ -14,14 +14,7 @@ class AuthorityRoute(DomainModel):
def classify_authority(facts: ActionRiskFacts) -> AuthorityRoute:
- if any(
- (
- facts.changes_official_kpi,
- facts.grants_waiver,
- facts.changes_safety_threshold,
- facts.finalizes_model_weights,
- )
- ):
+ if any(getattr(facts, key) for key in PROHIBITED_EFFECT_KEYS):
return AuthorityRoute(
risk_tier=RiskTier.R4,
execution_authority=ExecutionAuthority.PROHIBITED_R4,
diff --git a/src/thoth/application/services/action_effect_vector.py b/src/thoth/application/services/action_effect_vector.py
new file mode 100644
index 0000000..45691ab
--- /dev/null
+++ b/src/thoth/application/services/action_effect_vector.py
@@ -0,0 +1,42 @@
+"""The effect declaration of an action, read so that a flag is exactly true or false."""
+
+from __future__ import annotations
+
+from typing import cast
+
+from thoth.domain.action import PROHIBITED_EFFECT_KEYS
+
+# Effect flags are exactly true or false. Anything else (a word, a number, null) would be read as
+# "not true", which can lower a forbidden effect to R0, so it is refused instead of classified.
+BOOLEAN_EFFECT_KEYS = (
+ *PROHIBITED_EFFECT_KEYS,
+ "effect_completeness_confirmed",
+ "external_write",
+ "physical_action",
+ "changes_official_baseline",
+ "operational_equipment_change",
+ "runs_untrusted_code",
+ "sandbox_required",
+ "changes_local_draft",
+)
+
+_UNSET = object()
+
+
+def checked_effect_vector(raw: object, completeness: object = _UNSET) -> dict[str, object]:
+ """The effect declaration as a dict, refused when a flag is not true or false.
+
+ A missing key is allowed; null is not. When given, `completeness` is the declaration-level value
+ used if the vector does not carry its own.
+ """
+ effect: dict[str, object] = (
+ {str(key): child for key, child in cast(dict[object, object], raw).items()}
+ if isinstance(raw, dict)
+ else {}
+ )
+ if completeness is not _UNSET:
+ effect.setdefault("effect_completeness_confirmed", completeness)
+ for key in BOOLEAN_EFFECT_KEYS:
+ if key in effect and not isinstance(effect[key], bool):
+ raise ValueError(f"effect_vector.{key} must be true or false")
+ return effect
diff --git a/src/thoth/application/services/action_from_test.py b/src/thoth/application/services/action_from_test.py
new file mode 100644
index 0000000..4f94a8c
--- /dev/null
+++ b/src/thoth/application/services/action_from_test.py
@@ -0,0 +1,52 @@
+"""An action request draft made from one discriminating test of a hypothesis.
+
+A person presses a button; no model is asked. The draft names the test it came from. Its risk
+comes from the effect declaration and the ordinary rules (the action service classifies the
+declaration), never from the tier the model wrote on the test: a declaration that says nothing
+is the rule's own R3 / POLICY_UNDEFINED. The draft is only a request being prepared. It is not an
+approval, and what it may do is decided by the roles the rules ask for.
+"""
+
+from __future__ import annotations
+
+from thoth.domain.hypothesis_full import HypothesisRecord
+
+PURPOSE = "HYPOTHESIS_DISCRIMINATION"
+HYPOTHESIS_NOT_FOUND = "HYPOTHESIS_NOT_FOUND"
+TEST_NOT_FOUND = "TEST_NOT_FOUND"
+
+
+class DraftRefused(ValueError):
+ """The hypothesis or the test is not there; nothing is written."""
+
+
+def draft_request(
+ record: HypothesisRecord, test_id: str, declaration: dict[str, object]
+) -> dict[str, object]:
+ """The parameters of action/create for a draft from this test (without the project id)."""
+ details = record.generation_details
+ tests = () if details is None else details.discriminating_test_candidates
+ test = next((item for item in tests if item.test_id == test_id), None)
+ if test is None:
+ raise DraftRefused(TEST_NOT_FOUND)
+ return {
+ "object_id": record.object_id,
+ "hypothesis_refs": [record.hypothesis_id],
+ "primary_purpose": PURPOSE,
+ "specification": {
+ "description": test.procedure_candidate,
+ "expected_observation_or_change": {
+ "description": test.procedure_candidate,
+ "if_hypothesis_holds": test.expected_if_true,
+ "if_alternative_holds": test.expected_if_alternative,
+ "status": "CANDIDATE",
+ },
+ # What the person declares, as written. Nothing here is filled in on their behalf.
+ "effect_vector": dict(declaration),
+ "effect_completeness_confirmed": declaration.get("effect_completeness_confirmed")
+ is True,
+ },
+ # a field of the request (covered by the revision digest), not part of the specification
+ "test_refs": [{"hypothesis_id": record.hypothesis_id, "test_id": test.test_id}],
+ "evidence_refs": list(record.evidence_refs),
+ }
diff --git a/src/thoth/application/services/action_service.py b/src/thoth/application/services/action_service.py
index 48f4d0f..3136058 100644
--- a/src/thoth/application/services/action_service.py
+++ b/src/thoth/application/services/action_service.py
@@ -5,6 +5,7 @@
from pydantic import JsonValue
+from thoth.application.services.action_effect_vector import checked_effect_vector
from thoth.application.services.action_plan_validation import validate_action_plan_dag
from thoth.application.services.authorization_consumption import (
consume_authorization,
@@ -13,11 +14,13 @@
from thoth.application.services.authorization_currentness import mark_stale, stale_decisions
from thoth.application.services.authorization_prepare import prepare_authorization
from thoth.application.services.revision_service import CommitResult, RevisionCommitService
+from thoth.domain.action import PROHIBITED_EFFECT_KEYS
from thoth.domain.action_full import (
ActionAuditRecord,
ActionPlanRecord,
ActionPortfolioRecord,
ActionRecord,
+ ActionTestRef,
AuthorizationEnvelopeRecord,
)
from thoth.domain.actor import ActorRef
@@ -56,29 +59,22 @@
def classify_effect_vector(
effect: dict[str, object],
) -> tuple[str, str, tuple[str, ...], tuple[str, ...]]:
- complete = effect.get("effect_completeness_confirmed") is True
- if not complete:
- return (
- "R3",
- "POLICY_UNDEFINED",
- ("EFFECT_COMPLETENESS_REVIEW",),
- ("effect-owner",),
- )
- if any(
- effect.get(key) is True
- for key in (
- "changes_official_kpi",
- "grants_waiver",
- "changes_safety_threshold",
- "finalizes_model_weights",
- )
- ):
+ # A forbidden effect is R4 whether or not the declaration is complete; completeness is only
+ # asked of effects that are not forbidden.
+ if any(effect.get(key) is True for key in PROHIBITED_EFFECT_KEYS):
return (
"R4",
"PROHIBITED",
("PROHIBITED_SEMANTIC_AUTHORITY",),
("institution-authority",),
)
+ if effect.get("effect_completeness_confirmed") is not True:
+ return (
+ "R3",
+ "POLICY_UNDEFINED",
+ ("EFFECT_COMPLETENESS_REVIEW",),
+ ("effect-owner",),
+ )
if any(
effect.get(key) is True
for key in (
@@ -168,6 +164,7 @@ def create(
secondary_purposes: tuple[str, ...],
specification: dict[str, object],
evidence_refs: tuple[str, ...],
+ test_refs: tuple[ActionTestRef, ...] = (),
) -> tuple[ActionRecord, tuple[str, ...], CommitResult]:
self._object(project_id, object_id)
self._validate_purposes(primary_purpose, secondary_purposes)
@@ -200,6 +197,7 @@ def create(
if item.object_id == object_id
and item.primary_purpose == primary_purpose
and item.specification == specification
+ and item.test_refs == test_refs
and item.freshness != "INVALIDATED"
)
draft: dict[str, object] = {
@@ -209,6 +207,7 @@ def create(
"object_id": object_id,
"portfolio_id": portfolio_id,
"hypothesis_refs": hypothesis_refs,
+ "test_refs": test_refs,
"primary_purpose": primary_purpose,
"secondary_purposes": secondary_purposes,
"specification": {**specification, "missing_fields": missing},
@@ -250,10 +249,7 @@ def revise(
specification = updates["specification"]
if not isinstance(specification, dict):
raise ValueError("Action specification must be an object")
- specification_value = {
- str(key): child for key, child in cast(dict[object, object], specification).items()
- }
- effect = self._effect_vector(specification_value)
+ effect = self._effect_vector(self._mapping(specification))
risk_tier, policy_state, processes, roles = classify_effect_vector(effect)
draft.update(
{
@@ -844,17 +840,10 @@ def _validate_purposes(primary: str, secondary: tuple[str, ...]) -> None:
@staticmethod
def _effect_vector(specification: dict[str, object]) -> dict[str, object]:
- raw = specification.get("effect_vector", {})
- effect: dict[str, object] = (
- {str(key): child for key, child in cast(dict[object, object], raw).items()}
- if isinstance(raw, dict)
- else {}
- )
- effect.setdefault(
- "effect_completeness_confirmed",
+ return checked_effect_vector(
+ specification.get("effect_vector", {}),
specification.get("effect_completeness_confirmed", False),
)
- return effect
@staticmethod
def _impact(effect: dict[str, object]) -> dict[str, object]:
@@ -879,12 +868,7 @@ def _normalize_step(self, project_id: str, value: dict[str, object]) -> dict[str
step_id = step.get("step_id")
if not isinstance(step_id, str) or not step_id:
step["step_id"] = self._ids.new("action-step")
- effect_raw = step.get("effect_vector", {})
- effect: dict[str, object] = (
- {str(key): child for key, child in cast(dict[object, object], effect_raw).items()}
- if isinstance(effect_raw, dict)
- else {}
- )
+ effect = checked_effect_vector(step.get("effect_vector", {}))
risk, policy, processes, roles = classify_effect_vector(effect)
step.update(
{
diff --git a/src/thoth/application/services/authorization_consumption.py b/src/thoth/application/services/authorization_consumption.py
index 2366609..68e3b4f 100644
--- a/src/thoth/application/services/authorization_consumption.py
+++ b/src/thoth/application/services/authorization_consumption.py
@@ -4,6 +4,7 @@
from collections.abc import Callable
+from thoth.application.services.hypothesis_link_view import HypothesisLinkReader
from thoth.domain.action_full import ActionAuditRecord, AuthorizationEnvelopeRecord
from thoth.domain.auth import require_authenticated_authority
from thoth.domain.canonical import canonical_payload, domain_digest
@@ -157,6 +158,12 @@ def decide_authorization(
raise ValueError("authorization revision changed before decision")
if current.state != "PENDING":
raise ValueError("authorization is not pending")
+ if decision == "APPROVE":
+ # Prepared in time is not enough: no approval is given on a hypothesis whose trace
+ # verdict has changed since, unless a person looked at that change (hypothesis link).
+ HypothesisLinkReader(ledger).require_plan_revision_hypotheses_current(
+ current.project_id, current.plan_revision_digest
+ )
if approved_digest != current.exact_scope_digest:
raise ValueError("approved digest does not match the content-bound scope")
role = next(
diff --git a/src/thoth/application/services/discrimination_ledger.py b/src/thoth/application/services/discrimination_ledger.py
new file mode 100644
index 0000000..67d3dc3
--- /dev/null
+++ b/src/thoth/application/services/discrimination_ledger.py
@@ -0,0 +1,146 @@
+"""Test results and refutation conditions: a person's records about a hypothesis.
+
+A write adds an event to one project record and removes nothing. Nothing here calls a model, and
+the hypothesis the model produced is never edited: what a person recorded is read beside it.
+"""
+
+from __future__ import annotations
+
+from thoth.application.services.human_actor import require_human_actor
+from thoth.application.services.hypothesis_link_view import HypothesisLinkReader
+from thoth.application.services.lesson_ledger import LessonLedger
+from thoth.application.services.request_records import RequestRecords
+from thoth.domain.discrimination import (
+ DiscriminationLedgerRecord,
+ DiscriminationResult,
+ MatchKind,
+ RefutationConditions,
+ elimination,
+ latest_results,
+ recorded_standing,
+)
+from thoth.domain.enums import EntityType
+from thoth.domain.hypothesis_full import HypothesisRecord
+
+KEY = "hypothesis-discrimination"
+HYPOTHESIS_NOT_FOUND = "HYPOTHESIS_NOT_FOUND"
+TEST_NOT_FOUND = "TEST_NOT_FOUND"
+OBSERVATION_REQUIRED = "OBSERVATION_REQUIRED"
+CONDITION_BLANK = "CONDITION_BLANK"
+
+
+class DiscriminationRefused(ValueError):
+ """The record is not made; the message is the reason code."""
+
+
+class DiscriminationLedger:
+ def __init__(self, records: RequestRecords) -> None:
+ self._records = records
+ self._reader = HypothesisLinkReader(records.ledger)
+ self._lessons = LessonLedger(records)
+
+ def read(self, project_id: str) -> DiscriminationLedgerRecord:
+ content = self._reader.content(project_id, EntityType.THREAD, KEY)
+ if content is None:
+ return DiscriminationLedgerRecord(project_id=project_id)
+ return DiscriminationLedgerRecord.model_validate(content)
+
+ def _hypothesis(self, project_id: str, hypothesis_id: str) -> HypothesisRecord:
+ found = self._reader.hypothesis(project_id, hypothesis_id)
+ if found is None:
+ raise DiscriminationRefused(HYPOTHESIS_NOT_FOUND)
+ return found
+
+ def record_result(
+ self,
+ *,
+ project_id: str,
+ hypothesis_id: str,
+ test_id: str,
+ observation: str,
+ matched: MatchKind,
+ evidence_refs: tuple[str, ...],
+ actor_id: str,
+ ) -> DiscriminationResult:
+ require_human_actor(actor_id)
+ if not observation.strip():
+ raise DiscriminationRefused(OBSERVATION_REQUIRED)
+ with self._records.ledger.transaction():
+ hypothesis = self._hypothesis(project_id, hypothesis_id)
+ details = hypothesis.generation_details
+ known = () if details is None else details.discriminating_test_candidates
+ if test_id not in {item.test_id for item in known}:
+ raise DiscriminationRefused(TEST_NOT_FOUND)
+ event = DiscriminationResult(
+ event_id=self._records.ids.new("discrimination-result"),
+ hypothesis_id=hypothesis_id,
+ hypothesis_revision_digest=hypothesis.revision_digest,
+ test_id=test_id,
+ observation=observation.strip(),
+ matched=matched,
+ evidence_refs=tuple(item.strip() for item in evidence_refs if item.strip()),
+ actor_id=actor_id,
+ created_at=self._records.clock.now(),
+ )
+ current = self.read(project_id)
+ self._save(current.model_copy(update={"results": (*current.results, event)}), actor_id)
+ # the result is a lesson in the context of its row, in the same commit
+ self._lessons.after_result(project_id, hypothesis, current.results, event, actor_id)
+ return event
+
+ def record_conditions(
+ self, *, project_id: str, hypothesis_id: str, conditions: tuple[str, ...], actor_id: str
+ ) -> RefutationConditions:
+ require_human_actor(actor_id)
+ if any(not item.strip() for item in conditions):
+ raise DiscriminationRefused(CONDITION_BLANK)
+ with self._records.ledger.transaction():
+ self._hypothesis(project_id, hypothesis_id)
+ event = RefutationConditions(
+ event_id=self._records.ids.new("refutation-conditions"),
+ hypothesis_id=hypothesis_id,
+ conditions=tuple(item.strip() for item in conditions),
+ actor_id=actor_id,
+ created_at=self._records.clock.now(),
+ )
+ current = self.read(project_id)
+ self._save(
+ current.model_copy(update={"conditions": (*current.conditions, event)}), actor_id
+ )
+ return event
+
+ def _save(self, record: DiscriminationLedgerRecord, actor_id: str) -> None:
+ self._records.save(record.project_id, EntityType.THREAD, KEY, record, actor_id)
+
+ def view(self, project_id: str, only: str | None = None) -> list[dict[str, object]]:
+ """What was recorded for each hypothesis, with the elimination the results amount to."""
+ record = self.read(project_id)
+ named = [item.hypothesis_id for item in (*record.results, *record.conditions)]
+ # each hypothesis' own records, grouped once rather than searched for in the whole record
+ results: dict[str, list[DiscriminationResult]] = {}
+ for result in record.results:
+ results.setdefault(result.hypothesis_id, []).append(result)
+ conditions: dict[str, list[RefutationConditions]] = {}
+ for entry in record.conditions:
+ conditions.setdefault(entry.hypothesis_id, []).append(entry)
+ found: list[dict[str, object]] = []
+ for hypothesis_id in dict.fromkeys(named):
+ if only is not None and hypothesis_id != only:
+ continue
+ own = tuple(results.get(hypothesis_id, ()))
+ listed = conditions.get(hypothesis_id, [])
+ found.append(
+ {
+ "hypothesis_id": hypothesis_id,
+ "results": [
+ item.model_dump(mode="json")
+ for item in latest_results(own, hypothesis_id).values()
+ ],
+ "result_history_count": len(own),
+ "refutation_conditions": list(listed[-1].conditions) if listed else [],
+ "conditions_history_count": len(listed),
+ "elimination": elimination(own, hypothesis_id),
+ "standing": recorded_standing(own, hypothesis_id),
+ }
+ )
+ return found
diff --git a/src/thoth/application/services/execution_pending.py b/src/thoth/application/services/execution_pending.py
new file mode 100644
index 0000000..af7a7c3
--- /dev/null
+++ b/src/thoth/application/services/execution_pending.py
@@ -0,0 +1,84 @@
+"""Unsettled sandbox results, and how a person clears one so the same work may run again.
+
+An attempt is unsettled while it is still going, of unknown end, or finished but not admitted. The
+R2 duplicate-run guard holds new runs of the same work while one is unsettled. Clearing is the
+person's decision to run that work again: it needs the execution revision they read, a reason, and
+it keeps the earlier attempt and its result as they are.
+"""
+
+from __future__ import annotations
+
+from thoth.application.services.execution_service import ExecutionService
+from thoth.application.services.revision_service import CommitResult
+from thoth.domain.execution_full import PlanExecutionRecord, StepExecutionAttemptRecord
+from thoth.ports.execution import ExecutionStorePort
+from thoth.ports.ledger import LedgerPort
+
+# An attempt still going, or of unknown end: its result is not settled.
+PENDING_ATTEMPT_STATES = frozenset(
+ {"DISPATCHED", "RUNNING", "CANCEL_REQUESTED", "UNKNOWN_COMPLETION"}
+)
+# Written to the execution's audit when a person clears an unsettled result. The guard lets an
+# attempt through only when it carries this record.
+PENDING_RESULT_CLEARED_EVENT = "execution/pendingResultCleared"
+
+
+def is_unsettled(attempt: StepExecutionAttemptRecord) -> bool:
+ unadmitted = attempt.observation_completeness == "NOT_ADMITTED"
+ return attempt.state in PENDING_ATTEMPT_STATES or unadmitted
+
+
+class PendingResultClearing:
+ def __init__(
+ self, *, store: ExecutionStorePort, service: ExecutionService, ledger: LedgerPort
+ ) -> None:
+ self._store = store
+ self._service = service
+ self._ledger = ledger
+
+ def clear(
+ self,
+ current: PlanExecutionRecord,
+ *,
+ attempt_id: str,
+ cause_revision_ref: str,
+ impact_refs: tuple[str, ...],
+ cleared_by: str,
+ reason: str,
+ input_difference: dict[str, object] | None,
+ ) -> tuple[PlanExecutionRecord, CommitResult]:
+ with self._ledger.transaction():
+ attempt = next(
+ (
+ item
+ for item in self._store.list_attempts(
+ current.project_id, current.plan_execution_id
+ )
+ if item.attempt_id == attempt_id
+ ),
+ None,
+ )
+ if attempt is None:
+ raise ValueError("pending attempt not found in this execution")
+ if not is_unsettled(attempt):
+ raise ValueError("attempt has a settled result; there is nothing to clear")
+ execution, commit = self._service.invalidate(
+ current, cause_revision_ref=cause_revision_ref, impact_refs=impact_refs
+ )
+ # The earlier attempt and its result stay as they are. This records who cleared it,
+ # when (the audit time), why, and what differed in the inputs.
+ self._service.audit(
+ current.project_id,
+ current.plan_execution_id,
+ PENDING_RESULT_CLEARED_EVENT,
+ {
+ "attempt_id": attempt_id,
+ "cleared_by": cleared_by,
+ "reason": reason,
+ "input_difference": input_difference,
+ "execution_revision_before": current.revision,
+ "attempt_state": attempt.state,
+ "observation_completeness": attempt.observation_completeness,
+ },
+ )
+ return execution, commit
diff --git a/src/thoth/application/services/full_project_memory.py b/src/thoth/application/services/full_project_memory.py
index 0fed364..000280c 100644
--- a/src/thoth/application/services/full_project_memory.py
+++ b/src/thoth/application/services/full_project_memory.py
@@ -13,6 +13,7 @@
OMITTED_BY_BUDGET,
RecallLimits,
auto_memory_lacks_evidence,
+ follow_up_markers,
lacks_evidence,
narrow_recall,
plan_recall,
@@ -36,7 +37,12 @@
from thoth.application.services.scoped_memory import MemoryResourceAccess
from thoth.domain.actor import ActorRef
from thoth.domain.auth import current_authenticated_actor
-from thoth.domain.canonical import canonical_payload, domain_digest, head_set_digest
+from thoth.domain.canonical import (
+ canonical_payload,
+ domain_digest,
+ head_set_digest,
+ same_stored_instant,
+)
from thoth.domain.memory import (
FullMemoryContextPack,
FullMemoryRevision,
@@ -49,6 +55,7 @@
MemoryTransition,
MemoryTransitionReceipt,
)
+from thoth.domain.memory_expansion import MemoryExpansionOutcome
from thoth.domain.memory_preparation import (
FullMemoryPromotionResult,
MemoryPreparationBasis,
@@ -57,11 +64,13 @@
PreparedMemoryPromotion,
)
from thoth.domain.memory_relation import MemoryRelationJudgment
+from thoth.domain.memory_terms import added_words, words
from thoth.domain.revision import StagedRevision
from thoth.ports.ledger import LedgerPort
from thoth.ports.memory import (
FullMemoryStorePort,
MemoryEmbeddingPort,
+ MemoryExpansionSwitchPort,
MemoryInjectionPort,
MemoryProjectionBuilderPort,
MemoryRelationJudgePort,
@@ -106,6 +115,7 @@ def __init__(
admission: MemoryAdmissionService | None = None,
resource_access: MemoryResourceAccess | None = None,
injection: MemoryInjectionPort | None = None,
+ expansion_switch: MemoryExpansionSwitchPort | None = None,
relation_judge: MemoryRelationJudgePort | None = None,
limits: RecallLimits | None = None,
) -> None:
@@ -122,6 +132,7 @@ def __init__(
self._admission = admission
self._resource_access = resource_access
self._injection = injection
+ self._expansion_switch = expansion_switch
self._relations = MemoryRelationResolver(ledger, relation_judge)
self._limits = limits or RecallLimits()
@@ -357,15 +368,15 @@ async def prepare_thread_results(
)
# A free-text memory is judged by its own words, never by ids in its source reference.
if candidate.assertion is not None:
- words = candidate.assertion
+ words_text = candidate.assertion
else:
- words = content_excerpt if body is None else body[1]
- query_terms = tuple(sorted(self._tokens(words)))
+ words_text = content_excerpt if body is None else body[1]
+ query_terms = tuple(sorted(self._tokens(words_text)))
unsafe = (
memory_text_is_unsafe(content_excerpt) or candidate.assertion == REDACTED_MEMORY
)
owner_valid = owner is not None and owner.project_id == project_id
- content_reusable = len(query_terms) >= 2
+ content_reusable = len(words(words_text)) >= 2
verdict = await self._relations.resolve_candidate(
candidate,
content=content,
@@ -513,6 +524,32 @@ def commit_prepared(
self._store.replace_projections(prepared.basis.project_id, prepared.projections)
return prepared.result
+ def expansion_skip_reason(self, project_id: str, query: str) -> str | None:
+ """Why the question should not be widened by a model call, or None when it should.
+
+ A candidate is a stored memory that was committed and is allowed to be recalled; whether
+ it also passes the finer recall conditions is decided later, by build_context.
+ """
+
+ if self._injection is not None and not self._injection.enabled(project_id):
+ return "MEMORY_OFF"
+ if self._expansion_switch is None:
+ return "NOT_CONFIGURED"
+ if not self._expansion_switch.expansion_enabled(project_id):
+ return "SETTING_OFF"
+ if follow_up_markers(query):
+ return "FOLLOW_UP"
+ if not any(
+ item.project_id == project_id
+ and item.transition == MemoryTransition.COMMIT
+ and item.recall_eligible
+ and item.support_status == "SUPPORTED"
+ and item.authority_status == "AUTHORITATIVE"
+ for item in self._store.list_revisions(project_id)
+ ):
+ return "NO_CANDIDATES"
+ return None
+
def build_context(
self,
*,
@@ -522,10 +559,11 @@ def build_context(
target_use: Literal["WORKING_CONTEXT", "ACTION_CONTEXT"],
scope: dict[str, str],
cutoff_at: AwareDatetime,
+ expansion: MemoryExpansionOutcome | None = None,
) -> FullMemoryContextPack:
excluded: dict[str, list[str]] = defaultdict(list)
eligible: list[FullMemoryRevision] = []
- query_terms = self._tokens(query)
+ query_words = words(query)
stored = self._store.list_revisions(project_id)
if self._injection is not None and not self._injection.enabled(project_id):
excluded["MEMORY_INJECTION_OFF"] = [item.memory_revision_id for item in stored]
@@ -555,7 +593,7 @@ def build_context(
reason = "AMBIGUOUS_OR_CONFLICTING"
elif item.authority_status != "AUTHORITATIVE":
reason = "AUTHORITY_INVALID"
- elif not item.cutoff_valid or item.cutoff_at != cutoff_at:
+ elif not item.cutoff_valid or not same_stored_instant(item.cutoff_at, cutoff_at):
reason = "CUTOFF_INVALID"
elif item.owner_revision_ref not in current_heads:
reason = "OWNER_REVISION_NOT_CURRENT"
@@ -582,12 +620,23 @@ def build_context(
excluded[reason].append(item.memory_revision_id)
continue
eligible.append(item)
- narrowing = narrow_recall(eligible, query, query_terms)
+ added = (
+ added_words(expansion.expansion.text(), query_words)
+ if expansion is not None and expansion.used and expansion.expansion is not None
+ else ()
+ )
+ narrowing = narrow_recall(eligible, query, query_words, added)
relevant = narrowing.relevant
for reason, ids in narrowing.excluded.items():
excluded[reason].extend(ids)
plan = plan_recall(
- relevant, query, query_terms, self._limits, follow_up=bool(narrowing.follow_up_markers)
+ relevant,
+ query,
+ query_words,
+ self._limits,
+ follow_up=bool(narrowing.follow_up_markers),
+ added_words=added,
+ expansion_only=narrowing.expansion_only,
)
excluded[OMITTED_BY_BUDGET] = [item.memory_revision_id for item in plan.omitted]
selection = MemorySelectionRecord(
@@ -603,6 +652,16 @@ def build_context(
common_terms=tuple(sorted(narrowing.common_terms)),
follow_up=bool(narrowing.follow_up_markers),
follow_up_markers=narrowing.follow_up_markers,
+ matches={
+ item.memory_revision_id: narrowing.matches[item.memory_revision_id]
+ for item in plan.retrieved
+ if item.memory_revision_id in narrowing.matches
+ },
+ expansion=None
+ if expansion is None
+ else expansion.record.model_copy(
+ update={"added_words": tuple(word.run for word in added)}
+ ),
)
return self._save_context(
project_id,
diff --git a/src/thoth/application/services/human_actor.py b/src/thoth/application/services/human_actor.py
new file mode 100644
index 0000000..6c007f1
--- /dev/null
+++ b/src/thoth/application/services/human_actor.py
@@ -0,0 +1,11 @@
+"""Who may write a judgment record: a person. A model, an agent or a system actor may not."""
+
+from __future__ import annotations
+
+NOT_HUMAN_PREFIXES = ("system:", "agent:", "model:", "bot:")
+RECORD_HUMAN_ONLY = "RECORD_HUMAN_ONLY"
+
+
+def require_human_actor(actor_id: str) -> None:
+ if not actor_id or actor_id.startswith(NOT_HUMAN_PREFIXES):
+ raise PermissionError(RECORD_HUMAN_ONLY)
diff --git a/src/thoth/application/services/hypothesis_contract_v3.py b/src/thoth/application/services/hypothesis_contract_v3.py
new file mode 100644
index 0000000..86167d1
--- /dev/null
+++ b/src/thoth/application/services/hypothesis_contract_v3.py
@@ -0,0 +1,124 @@
+"""Hypothesis generation contract v3 (off unless the project turns it on).
+
+The same single generator call is made either way; only the form it is shown and the contract text
+change. In v3 each discriminating test may say what every hypothesis expects from it (a short label,
+"모름" when not known) and each hypothesis may say what result would make it give the hypothesis up.
+What the model gets wrong about ids is dropped item by item and recorded, never failing the call.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TypedDict
+
+from thoth.domain.evidence import EvidenceSpan
+from thoth.domain.hypothesis import (
+ DiscriminatingTest,
+ DiscriminatingTestV3,
+ ExpectedResult,
+ Hypothesis,
+ HypothesisPortfolio,
+ HypothesisPortfolioV3,
+ HypothesisV3,
+)
+from thoth.domain.research_projection import DroppedExpectation, ExpectedByTest
+
+CONTRACT_V2 = "hypothesis_portfolio.v2"
+CONTRACT_V3 = "hypothesis_portfolio.v3"
+
+
+class V3DetailFields(TypedDict, total=False):
+ refutation_conditions: tuple[str, ...]
+ expected_results: tuple[ExpectedByTest, ...]
+ dropped_expected_results: tuple[DroppedExpectation, ...]
+ contract_version: str
+
+
+class GeneratorContract(TypedDict):
+ output_model: type[HypothesisPortfolio]
+ prompt_version: str
+
+
+def generator_contract(v3: bool) -> GeneratorContract:
+ """The form and contract version for the generator call; v2 exactly as it always was."""
+ if v3:
+ return {"output_model": HypothesisPortfolioV3, "prompt_version": CONTRACT_V3}
+ return {"output_model": HypothesisPortfolio, "prompt_version": CONTRACT_V2}
+
+
+def base_tests(candidate: Hypothesis) -> tuple[DiscriminatingTest, ...]:
+ """The tests as the v2 record keeps them; a v3 test's extra fields are kept apart."""
+ if not isinstance(candidate, HypothesisV3):
+ return candidate.discriminating_tests
+ return tuple(
+ DiscriminatingTest.model_validate(item.model_dump(exclude={"expected_by_hypothesis"}))
+ for item in candidate.discriminating_tests
+ )
+
+
+def v3_details(candidate: Hypothesis) -> V3DetailFields:
+ """The generation-details fields a v3 hypothesis adds; a v2 hypothesis adds none."""
+ if not isinstance(candidate, HypothesisV3):
+ return {}
+ return {
+ "refutation_conditions": candidate.refutation_conditions,
+ "expected_results": tuple(
+ ExpectedByTest(test_id=item.test_id, rows=item.expected_by_hypothesis)
+ for item in candidate.discriminating_tests
+ if item.expected_by_hypothesis
+ ),
+ "dropped_expected_results": tuple(candidate.dropped),
+ "contract_version": CONTRACT_V3,
+ }
+
+
+@dataclass(frozen=True)
+class _Settled:
+ hypothesis: HypothesisV3
+ dropped: tuple[DroppedExpectation, ...]
+
+
+def _settle_one(
+ hypothesis: HypothesisV3, known_hypotheses: set[str], known_spans: set[str]
+) -> _Settled:
+ dropped: list[DroppedExpectation] = []
+ tests: list[DiscriminatingTestV3] = []
+ for test in hypothesis.discriminating_tests:
+ kept: list[ExpectedResult] = []
+ for row in test.expected_by_hypothesis:
+ reason = (
+ "UNKNOWN_HYPOTHESIS"
+ if row.hypothesis_id not in known_hypotheses
+ else "DUPLICATE_HYPOTHESIS"
+ if any(item.hypothesis_id == row.hypothesis_id for item in kept)
+ else "UNKNOWN_SPAN"
+ if not set(row.basis) <= known_spans
+ else None
+ )
+ if reason is None:
+ kept.append(row)
+ else:
+ dropped.append(
+ DroppedExpectation(
+ test_id=test.test_id, hypothesis_id=row.hypothesis_id, reason=reason
+ )
+ )
+ tests.append(test.model_copy(update={"expected_by_hypothesis": tuple(kept)}))
+ settled = hypothesis.model_copy(update={"discriminating_tests": tuple(tests)})
+ return _Settled(settled, tuple(dropped))
+
+
+def settle_contract(
+ portfolio: HypothesisPortfolio, evidence: tuple[EvidenceSpan, ...]
+) -> HypothesisPortfolio:
+ """Keep the rows of a v3 output that name known hypotheses and spans; v2 is untouched."""
+ if not isinstance(portfolio, HypothesisPortfolioV3):
+ return portfolio
+ known_hypotheses = {item.hypothesis_id for item in portfolio.hypotheses}
+ known_spans = {span.span_id for span in evidence}
+ settled: list[HypothesisV3] = []
+ for hypothesis in portfolio.hypotheses:
+ found = _settle_one(hypothesis, known_hypotheses, known_spans)
+ found.hypothesis.note_dropped(found.dropped)
+ settled.append(found.hypothesis)
+ return portfolio.model_copy(update={"hypotheses": tuple(settled)})
diff --git a/src/thoth/application/services/hypothesis_link_recheck.py b/src/thoth/application/services/hypothesis_link_recheck.py
new file mode 100644
index 0000000..ec0fc2b
--- /dev/null
+++ b/src/thoth/application/services/hypothesis_link_recheck.py
@@ -0,0 +1,134 @@
+"""A person re-checks hypotheses whose verdict changed: an event each, nothing overwritten."""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+from thoth.application.services.hypothesis_link_view import (
+ RECHECK_KEY,
+ HypothesisLinkReader,
+ LinkItem,
+)
+from thoth.application.services.request_records import RequestRecords
+from thoth.domain.enums import EntityType
+from thoth.domain.verdict_link import (
+ HypothesisLinkRecheckRecord,
+ LinkRecheckEvent,
+ LinkState,
+ RecheckReason,
+ VerdictLink,
+ note_required,
+)
+
+NOT_HUMAN_PREFIXES = ("system:", "agent:", "model:", "bot:")
+
+
+class RecheckRefused(ValueError):
+ """The re-check is not allowed; the message is the reason code."""
+
+
+def require_human(actor_id: str) -> None:
+ """Only a person re-checks: a model, an agent or a system actor is refused."""
+ if not actor_id or actor_id.startswith(NOT_HUMAN_PREFIXES):
+ raise RecheckRefused("RECHECK_HUMAN_ONLY")
+
+
+def _link(item: LinkItem) -> VerdictLink:
+ link = item.record.verdict_link
+ if link is None:
+ raise RecheckRefused("HYPOTHESIS_LINK_MISSING")
+ return link
+
+
+class HypothesisLinkRechecks:
+ def __init__(self, records: RequestRecords) -> None:
+ self._records = records
+ self._reader = HypothesisLinkReader(records.ledger)
+
+ def _validated(
+ self,
+ project_id: str,
+ wanted: tuple[str, ...],
+ reason: RecheckReason,
+ note: str,
+ current_verdict_revision: str | None,
+ ) -> list[LinkItem]:
+ items = {item.hypothesis_id: item for item in self._reader.items(project_id, wanted)}
+ if set(items) != set(wanted):
+ raise RecheckRefused("HYPOTHESIS_LINK_MISSING")
+ chosen = [items[hypothesis_id] for hypothesis_id in wanted]
+ if any(item.assessment.state is LinkState.CURRENT for item in chosen):
+ raise RecheckRefused("HYPOTHESIS_LINK_NOT_STALE")
+ changes = {
+ (
+ _link(item).subject_kind,
+ _link(item).subject_id,
+ item.assessment.current_verdict_digest,
+ )
+ for item in chosen
+ }
+ if len(changes) != 1:
+ raise RecheckRefused("HYPOTHESIS_LINKS_DIFFER") # one verdict change at a time
+ if any(
+ item.assessment.current_verdict_revision != current_verdict_revision for item in chosen
+ ):
+ raise RecheckRefused("LINK_VERDICT_CHANGED_AGAIN") # the person saw an older one
+ flipped = any(item.assessment.change == "FLIPPED" for item in chosen)
+ if note_required(reason, flipped) and not note.strip():
+ raise RecheckRefused("RECHECK_NOTE_REQUIRED")
+ return chosen
+
+ def recheck(
+ self,
+ *,
+ project_id: str,
+ hypothesis_ids: tuple[str, ...],
+ reason: RecheckReason,
+ note: str,
+ actor_id: str,
+ current_verdict_revision: str | None,
+ ) -> list[LinkRecheckEvent]:
+ require_human(actor_id)
+ wanted = tuple(dict.fromkeys(hypothesis_ids))
+ with self._records.ledger.transaction():
+ chosen = self._validated(project_id, wanted, reason, note, current_verdict_revision)
+ now, batch = self._records.clock.now(), self._records.ids.new("link-recheck-batch")
+ events = [self._event(item, batch, reason, note, actor_id, now) for item in chosen]
+ _, earlier = self._reader.rechecks(project_id)
+ self._records.save(
+ project_id,
+ EntityType.THREAD,
+ RECHECK_KEY,
+ HypothesisLinkRecheckRecord(project_id=project_id, events=(*earlier, *events)),
+ actor_id,
+ )
+ return events
+
+ def _event(
+ self,
+ item: LinkItem,
+ batch: str,
+ reason: RecheckReason,
+ note: str,
+ actor_id: str,
+ now: datetime,
+ ) -> LinkRecheckEvent:
+ link, found = _link(item), item.assessment
+ return LinkRecheckEvent(
+ event_id=self._records.ids.new("link-recheck"),
+ batch_id=batch,
+ hypothesis_id=item.hypothesis_id,
+ hypothesis_revision_digest=item.hypothesis_revision_digest,
+ subject_kind=link.subject_kind,
+ subject_id=link.subject_id,
+ link_verdict_digest=link.verdict_digest,
+ link_state=link.state,
+ current_verdict_revision=found.current_verdict_revision or "",
+ current_verdict_digest=found.current_verdict_digest or "",
+ current_state=found.current_state or "",
+ flipped=found.change == "FLIPPED",
+ reason_code=reason,
+ note=note.strip(),
+ actor_id=actor_id,
+ created_at=now,
+ )
diff --git a/src/thoth/application/services/hypothesis_link_view.py b/src/thoth/application/services/hypothesis_link_view.py
new file mode 100644
index 0000000..2ff1a19
--- /dev/null
+++ b/src/thoth/application/services/hypothesis_link_view.py
@@ -0,0 +1,255 @@
+"""Read hypothesis links against today's trace, from the ledger alone.
+
+Every question here is answered from committed records (hypothesis heads, the trace record and its
+verdict revisions, the re-check record), so the same answer is available to a screen, to the
+approval path and to the execution checks without handing each of them more stores.
+"""
+
+from __future__ import annotations
+
+from collections import Counter
+from collections.abc import Collection, Mapping
+from dataclasses import dataclass
+from typing import cast
+
+from pydantic import ValidationError
+
+from thoth.application.services.verification_trace import KEY as TRACE_KEY
+from thoth.application.services.verification_trace import REVISION_PREFIX, mark_stale
+from thoth.domain.enums import EntityType
+from thoth.domain.hypothesis_full import HypothesisRecord
+from thoth.domain.verdict_link import (
+ CurrentVerdict,
+ HypothesisLinkRecheckRecord,
+ LinkAssessment,
+ LinkRecheckEvent,
+ LinkState,
+ VerdictLink,
+ assess_link,
+ verdict_core_digest,
+)
+from thoth.domain.verification_trace import (
+ SubjectKind,
+ VerificationTraceRecord,
+ VerificationVerdictRecord,
+)
+from thoth.ports.ledger import LedgerPort
+
+RECHECK_KEY = "hypothesis-link-rechecks"
+HYPOTHESIS_BASIS_CHANGED = "HYPOTHESIS_BASIS_CHANGED"
+
+
+@dataclass(frozen=True)
+class LinkItem:
+ hypothesis_id: str
+ hypothesis_revision_digest: str
+ statement: str
+ subject_title: str
+ record: HypothesisRecord
+ assessment: LinkAssessment
+
+
+def _items(value: object) -> list[object]:
+ return (
+ list(cast(list[object] | tuple[object, ...], value))
+ if isinstance(value, (list, tuple))
+ else []
+ )
+
+
+class HypothesisLinkReader:
+ def __init__(self, ledger: LedgerPort) -> None:
+ self._ledger = ledger
+
+ def content(
+ self,
+ project_id: str,
+ entity_type: EntityType,
+ entity_id: str,
+ heads: Mapping[str, str] | None = None,
+ ) -> dict[str, object] | None:
+ """The current content of one entity; `heads` is the project's heads when already read."""
+ digest = (self._ledger.read_heads(project_id) if heads is None else heads).get(
+ f"{entity_type.value}:{entity_id}"
+ )
+ return None if digest is None else self.content_at(project_id, digest)
+
+ def content_at(self, project_id: str, digest: str) -> dict[str, object] | None:
+ revision = self._ledger.read_revision_by_digest(project_id, digest)
+ snapshot = None if revision is None else self._ledger.read_snapshot(revision.snapshot_id)
+ return None if snapshot is None else dict(snapshot.content)
+
+ def trace(self, project_id: str) -> tuple[str | None, VerificationTraceRecord | None]:
+ digest = self._ledger.read_heads(project_id).get(f"{EntityType.THREAD.value}:{TRACE_KEY}")
+ content = None if digest is None else self.content_at(project_id, digest)
+ return digest, None if content is None else VerificationTraceRecord.model_validate(content)
+
+ def current_verdicts(
+ self,
+ project_id: str,
+ record: VerificationTraceRecord | None,
+ only: Collection[tuple[SubjectKind, str]] | None = None,
+ ) -> dict[tuple[SubjectKind, str], CurrentVerdict]:
+ """The verdict of each row the trace still shows; a row that left the set is not here.
+
+ `only` limits it to the rows a reader will look at, so the others are not read at all.
+ """
+ if record is None:
+ return {}
+ shown = mark_stale(record.trace_set, record.pending_changes)
+ found: dict[tuple[SubjectKind, str], CurrentVerdict] = {}
+ heads = self._ledger.read_heads(project_id) # once, not once per row
+ for key, revision_digest in record.current_digests().items():
+ if key not in shown or (only is not None and key not in only):
+ continue
+ content = self.content(
+ project_id, EntityType.THREAD, REVISION_PREFIX + revision_digest, heads
+ )
+ if content is None:
+ continue
+ revision = VerificationVerdictRecord.model_validate(content).revision
+ found[key] = CurrentVerdict(
+ verdict_revision=revision.revision_digest,
+ verdict_digest=revision.verdict_digest,
+ state=revision.state.value,
+ core_digest=verdict_core_digest(revision),
+ )
+ return found
+
+ def link_core_digest(self, project_id: str, link: VerdictLink) -> str | None:
+ """The core of the verdict the hypothesis stood on, read from its stored revision."""
+ content = self.content(
+ project_id, EntityType.THREAD, REVISION_PREFIX + link.verdict_revision
+ )
+ if content is None:
+ return None
+ return verdict_core_digest(VerificationVerdictRecord.model_validate(content).revision)
+
+ def rechecks(self, project_id: str) -> tuple[str | None, tuple[LinkRecheckEvent, ...]]:
+ digest = self._ledger.read_heads(project_id).get(f"{EntityType.THREAD.value}:{RECHECK_KEY}")
+ content = None if digest is None else self.content_at(project_id, digest)
+ if content is None:
+ return digest, ()
+ return digest, HypothesisLinkRecheckRecord.model_validate(content).events
+
+ def hypothesis(self, project_id: str, hypothesis_id: str) -> HypothesisRecord | None:
+ """The current revision of one hypothesis, or None when there is no such hypothesis."""
+ content = self.content(project_id, EntityType.HYPOTHESIS, hypothesis_id)
+ if content is None or "hypothesis_revision_id" not in content:
+ return None # a portfolio shares the entity type
+ try:
+ return HypothesisRecord.model_validate(content)
+ except ValidationError:
+ return None
+
+ def linked_hypotheses(
+ self, project_id: str, only: tuple[str, ...] | None = None
+ ) -> list[HypothesisRecord]:
+ """The current revision of each hypothesis that has a verdict link."""
+ found: list[HypothesisRecord] = []
+ for key, digest in sorted(self._ledger.read_heads(project_id).items()):
+ if not key.startswith(f"{EntityType.HYPOTHESIS.value}:"):
+ continue
+ if only is not None and key.split(":", 1)[1] not in only:
+ continue
+ content = self.content_at(project_id, digest)
+ if content is None or "hypothesis_revision_id" not in content:
+ continue # a portfolio shares the entity type
+ try:
+ record = HypothesisRecord.model_validate(content)
+ except ValidationError:
+ continue
+ if record.verdict_link is not None:
+ found.append(record)
+ return found
+
+ def items(self, project_id: str, only: tuple[str, ...] | None = None) -> list[LinkItem]:
+ records = self.linked_hypotheses(project_id, only)
+ if not records:
+ return []
+ _, trace = self.trace(project_id)
+ titles = {} if trace is None else {i.item_id: i.title for i in trace.trace_set.items}
+ verdicts = self.current_verdicts(
+ project_id,
+ trace,
+ {
+ (SubjectKind(item.verdict_link.subject_kind), item.verdict_link.subject_id)
+ for item in records
+ if item.verdict_link is not None
+ },
+ )
+ _, events = self.rechecks(project_id)
+ items: list[LinkItem] = []
+ for record in records:
+ link = record.verdict_link
+ assert link is not None
+ current = verdicts.get((SubjectKind(link.subject_kind), link.subject_id))
+ moved = current is not None and current.verdict_digest != link.verdict_digest
+ items.append(
+ LinkItem(
+ hypothesis_id=record.hypothesis_id,
+ hypothesis_revision_digest=record.revision_digest,
+ statement=record.statement,
+ subject_title=titles.get(link.subject_id, ""),
+ record=record,
+ assessment=assess_link(
+ link,
+ current,
+ events,
+ hypothesis_id=record.hypothesis_id,
+ link_core_digest=self.link_core_digest(project_id, link) if moved else None,
+ ),
+ )
+ )
+ return items
+
+ def reason_distribution(self, project_id: str) -> dict[str, object]:
+ """Why people re-checked, project-wide: events per reason, and how many were flips."""
+ _, events = self.rechecks(project_id)
+ return {
+ "total": len(events),
+ "flipped": sum(1 for event in events if event.flipped),
+ "by_reason": dict(Counter(event.reason_code for event in events)),
+ }
+
+ def stale_hypothesis_ids(
+ self, project_id: str, hypothesis_ids: tuple[str, ...]
+ ) -> tuple[str, ...]:
+ """Of these hypotheses, those whose link says they stand on a verdict that has changed."""
+ wanted = tuple(dict.fromkeys(hypothesis_ids))
+ if not wanted:
+ return ()
+ return tuple(
+ item.hypothesis_id
+ for item in self.items(project_id, wanted)
+ if item.assessment.state is LinkState.STALE
+ )
+
+ def action_hypotheses(self, project_id: str, key: str) -> tuple[str, ...]:
+ """The hypotheses an action, or the actions of a plan, rest on ("ACTION:" keys)."""
+ content = self.content(project_id, EntityType.ACTION, key.split(":", 1)[-1])
+ if content is None:
+ return ()
+ refs = content.get("hypothesis_refs")
+ found = [str(item) for item in _items(refs)]
+ for action in _items(content.get("selected_action_refs")):
+ found.extend(self.action_hypotheses(project_id, f"ACTION:{action}"))
+ return tuple(dict.fromkeys(found))
+
+ def require_action_hypotheses_current(self, project_id: str, key: str) -> None:
+ """Refuse an action or plan that rests on a hypothesis whose verdict has changed."""
+ if self.stale_hypothesis_ids(project_id, self.action_hypotheses(project_id, key)):
+ raise ValueError(HYPOTHESIS_BASIS_CHANGED)
+
+ def require_plan_revision_hypotheses_current(
+ self, project_id: str, plan_revision_digest: str
+ ) -> None:
+ """The same check for a stored plan revision (the approval path names it by digest)."""
+ content = self.content_at(project_id, plan_revision_digest)
+ if content is None:
+ return
+ found: list[str] = []
+ for action in _items(content.get("selected_action_refs")):
+ found.extend(self.action_hypotheses(project_id, f"ACTION:{action}"))
+ if self.stale_hypothesis_ids(project_id, tuple(found)):
+ raise ValueError(HYPOTHESIS_BASIS_CHANGED)
diff --git a/src/thoth/application/services/hypothesis_projection.py b/src/thoth/application/services/hypothesis_projection.py
index ce8040e..2dd0bf6 100644
--- a/src/thoth/application/services/hypothesis_projection.py
+++ b/src/thoth/application/services/hypothesis_projection.py
@@ -4,10 +4,19 @@
from datetime import datetime
+from thoth.application.services.hypothesis_contract_v3 import CONTRACT_V3, base_tests, v3_details
+from thoth.application.services.hypothesis_verdict_link import running_verdict_link
from thoth.domain.canonical import canonical_payload, domain_digest
-from thoth.domain.hypothesis import Hypothesis, HypothesisPortfolio
+from thoth.domain.hypothesis import (
+ Hypothesis,
+ HypothesisPortfolio,
+ HypothesisV3,
+)
from thoth.domain.hypothesis_full import HypothesisPortfolioRecord, HypothesisRecord
-from thoth.domain.research_projection import HypothesisGenerationDetails, PortfolioGenerationDetails
+from thoth.domain.research_projection import (
+ HypothesisGenerationDetails,
+ PortfolioGenerationDetails,
+)
from thoth.domain.test_validity import hypothesis_semantic_digest
@@ -15,30 +24,46 @@ def hypothesis_view(record: HypothesisRecord) -> Hypothesis | None:
details = record.generation_details
if details is None:
return None
- return Hypothesis.model_validate(
+ fields = {
+ "hypothesis_id": record.hypothesis_id,
+ "object_id": record.object_id,
+ "statement": record.statement,
+ "observed_problem": record.observed_problem,
+ "primary_locus": details.primary_locus,
+ "contributing_loci": details.contributing_loci,
+ "causal_depth": details.causal_depth,
+ "scope_conditions": record.scope,
+ "support_evidence_refs": record.evidence_refs,
+ "counterevidence_refs": record.counterevidence_refs,
+ "missing_evidence": details.missing_evidence,
+ "counterevidence_queries": record.counterevidence_queries,
+ "assumptions": details.assumptions,
+ "uncertainty": details.uncertainty,
+ "predicted_observations": details.predicted_observation_candidates,
+ "discriminating_tests": details.discriminating_test_candidates,
+ "status": details.candidate_status,
+ "primary_intent": record.primary_intent,
+ "critical_review": details.critical_review,
+ "execution_appraisal": details.execution_appraisal,
+ "prediction_proposal": details.prediction_proposal,
+ "semantic_review_ref": details.semantic_review_ref,
+ }
+ if details.contract_version != CONTRACT_V3:
+ return Hypothesis.model_validate(fields)
+ # The generator was shown the v3 form: give back what it said beside the v2 content.
+ rows = {item.test_id: item.rows for item in details.expected_results}
+ tests = [
{
- "hypothesis_id": record.hypothesis_id,
- "object_id": record.object_id,
- "statement": record.statement,
- "observed_problem": record.observed_problem,
- "primary_locus": details.primary_locus,
- "contributing_loci": details.contributing_loci,
- "causal_depth": details.causal_depth,
- "scope_conditions": record.scope,
- "support_evidence_refs": record.evidence_refs,
- "counterevidence_refs": record.counterevidence_refs,
- "missing_evidence": details.missing_evidence,
- "counterevidence_queries": record.counterevidence_queries,
- "assumptions": details.assumptions,
- "uncertainty": details.uncertainty,
- "predicted_observations": details.predicted_observation_candidates,
- "discriminating_tests": details.discriminating_test_candidates,
- "status": details.candidate_status,
- "primary_intent": record.primary_intent,
- "critical_review": details.critical_review,
- "execution_appraisal": details.execution_appraisal,
- "prediction_proposal": details.prediction_proposal,
- "semantic_review_ref": details.semantic_review_ref,
+ **test.model_dump(),
+ "expected_by_hypothesis": [r.model_dump() for r in rows.get(test.test_id, ())],
+ }
+ for test in details.discriminating_test_candidates
+ ]
+ return HypothesisV3.model_validate(
+ {
+ **fields,
+ "discriminating_tests": tests,
+ "refutation_conditions": details.refutation_conditions,
}
)
@@ -97,12 +122,13 @@ def full_hypothesis(
assumptions=candidate.assumptions,
uncertainty=candidate.uncertainty,
predicted_observation_candidates=candidate.predicted_observations,
- discriminating_test_candidates=candidate.discriminating_tests,
+ discriminating_test_candidates=base_tests(candidate),
candidate_status=candidate.status,
critical_review=candidate.critical_review,
execution_appraisal=candidate.execution_appraisal,
prediction_proposal=candidate.prediction_proposal,
semantic_review_ref=candidate.semantic_review_ref,
+ **v3_details(candidate),
)
draft: dict[str, object] = {} if current is None else current.model_dump(mode="python")
invalidated = (
@@ -136,8 +162,10 @@ def full_hypothesis(
else candidate.primary_locus.value,
"causal_depth": candidate.causal_depth.value,
}
+ link = running_verdict_link()
draft.update(
{
+ **({} if link is None else {"verdict_link": link}),
"hypothesis_revision_id": revision_id,
"hypothesis_id": candidate.hypothesis_id,
"project_id": project_id,
diff --git a/src/thoth/application/services/hypothesis_same.py b/src/thoth/application/services/hypothesis_same.py
new file mode 100644
index 0000000..ea9fddf
--- /dev/null
+++ b/src/thoth/application/services/hypothesis_same.py
@@ -0,0 +1,112 @@
+"""A person's mark that two hypotheses of different investigations are the same hypothesis.
+
+A mark adds an event to one project record and removes nothing; taking it back is another event.
+Only a person writes one, and both hypotheses must be of this project and come from an
+investigation of a trace row. No model is called and nothing is paired by how sentences read.
+"""
+
+from __future__ import annotations
+
+from thoth.application.services.human_actor import require_human_actor
+from thoth.application.services.hypothesis_link_view import HypothesisLinkReader
+from thoth.application.services.request_records import RequestRecords
+from thoth.domain.enums import EntityType
+from thoth.domain.hypothesis_same import (
+ SameAction,
+ SameHypothesisEvent,
+ SameHypothesisRecord,
+ current_pairs,
+ groups,
+)
+
+KEY = "hypothesis-same"
+SAME_NOT_FOUND = "SAME_NOT_FOUND"
+SAME_IDENTICAL = "SAME_IDENTICAL"
+SAME_NOT_INVESTIGATION = "SAME_NOT_INVESTIGATION"
+SAME_ALREADY_LINKED = "SAME_ALREADY_LINKED"
+SAME_NOT_LINKED = "SAME_NOT_LINKED"
+
+
+class SameRefused(ValueError):
+ """The mark is not written; the message is the reason code."""
+
+
+def read_same(reader: HypothesisLinkReader, project_id: str) -> SameHypothesisRecord:
+ content = reader.content(project_id, EntityType.THREAD, KEY)
+ if content is None:
+ return SameHypothesisRecord(project_id=project_id)
+ return SameHypothesisRecord.model_validate(content)
+
+
+class HypothesisSame:
+ def __init__(self, records: RequestRecords) -> None:
+ self._records = records
+ self._reader = HypothesisLinkReader(records.ledger)
+
+ def read(self, project_id: str) -> SameHypothesisRecord:
+ return read_same(self._reader, project_id)
+
+ def record(
+ self,
+ *,
+ project_id: str,
+ hypothesis_ids: tuple[str, str],
+ action: SameAction,
+ note: str,
+ actor_id: str,
+ ) -> SameHypothesisEvent:
+ require_human_actor(actor_id)
+ first, second = hypothesis_ids
+ if first == second:
+ raise SameRefused(SAME_IDENTICAL)
+ pair = (first, second) if first < second else (second, first)
+ with self._records.ledger.transaction():
+ current = self.read(project_id)
+ linked = pair in current_pairs(current)
+ if action == "UNLINK":
+ if not linked:
+ raise SameRefused(SAME_NOT_LINKED)
+ else:
+ found = [self._reader.hypothesis(project_id, item) for item in pair]
+ if any(item is None for item in found):
+ raise SameRefused(SAME_NOT_FOUND)
+ if any(item is not None and item.verdict_link is None for item in found):
+ raise SameRefused(SAME_NOT_INVESTIGATION)
+ if linked:
+ raise SameRefused(SAME_ALREADY_LINKED)
+ event = SameHypothesisEvent(
+ event_id=self._records.ids.new("hypothesis-same"),
+ hypothesis_ids=pair,
+ action=action,
+ note=note.strip(),
+ actor_id=actor_id,
+ created_at=self._records.clock.now(),
+ )
+ updated = current.model_copy(update={"events": (*current.events, event)})
+ self._records.save(project_id, EntityType.THREAD, KEY, updated, actor_id)
+ return event
+
+ def view(self, project_id: str) -> dict[str, object]:
+ """The current groups with what a screen shows about each member, and every mark."""
+ record = self.read(project_id)
+ members: dict[str, dict[str, object]] = {}
+ listed = [sorted(group) for group in groups(record)]
+ for hypothesis_id in dict.fromkeys(item for group in listed for item in group):
+ found = self._reader.hypothesis(project_id, hypothesis_id)
+ link = None if found is None else found.verdict_link
+ members[hypothesis_id] = (
+ {}
+ if found is None or link is None
+ else {
+ "statement": found.statement,
+ "investigated_at": found.created_at.isoformat(),
+ "subject_kind": link.subject_kind,
+ "subject_id": link.subject_id,
+ }
+ )
+ return {
+ "groups": listed,
+ "pairs": [list(pair) for pair in sorted(current_pairs(record))],
+ "members": members,
+ "events": [item.model_dump(mode="json") for item in record.events],
+ }
diff --git a/src/thoth/application/services/hypothesis_test_lifecycle.py b/src/thoth/application/services/hypothesis_test_lifecycle.py
index 650311c..d9206c4 100644
--- a/src/thoth/application/services/hypothesis_test_lifecycle.py
+++ b/src/thoth/application/services/hypothesis_test_lifecycle.py
@@ -8,7 +8,7 @@
from thoth.application.services.revision_service import RevisionCommitService
from thoth.domain.actor import ActorRef
-from thoth.domain.canonical import canonical_payload, domain_digest
+from thoth.domain.canonical import canonical_payload, domain_digest, same_stored_instant
from thoth.domain.enums import ActorKind, CutoffState, EntityType
from thoth.domain.execution_full import PlanExecutionRecord, StepExecutionAttemptRecord
from thoth.domain.hypothesis_full import HypothesisRecord, HypothesisTestBinding, PredictionRecord
@@ -154,7 +154,7 @@ def prepare_prediction(
raise ValueError("PREDICTION_CUTOFF_INVALID")
# The source eligibility seal is for the Project cutoff. It contains no finer
# temporal provenance that could establish eligibility at an earlier cutoff.
- if knowledge_cutoff != project.cutoff_at:
+ if not same_stored_instant(knowledge_cutoff, project.cutoff_at):
raise ValueError("PREDICTION_CUTOFF_BASIS_UNRESOLVED")
if current.primary_intent is None:
raise ValueError("PREDICTION_INTENT_REQUIRED")
@@ -212,7 +212,7 @@ def seal_prediction(
or artifact is None
or artifact.byte_sha256 != basis.contract_digest
or artifact.cutoff_state != CutoffState.ELIGIBLE
- or prediction.knowledge_cutoff != basis.knowledge_cutoff
+ or not same_stored_instant(prediction.knowledge_cutoff, basis.knowledge_cutoff)
or prediction.conditions != basis.contract.conditions
or ExpectedRange.model_validate(prediction.expected_outcome) != basis.expected_outcome
):
diff --git a/src/thoth/application/services/hypothesis_verdict_link.py b/src/thoth/application/services/hypothesis_verdict_link.py
new file mode 100644
index 0000000..acb1522
--- /dev/null
+++ b/src/thoth/application/services/hypothesis_verdict_link.py
@@ -0,0 +1,32 @@
+"""The verdict link of a hypothesis made while an investigation from a trace row is running."""
+
+from __future__ import annotations
+
+from typing import cast
+
+from pydantic import ValidationError
+
+from thoth.domain.research_execution import research_work
+from thoth.domain.verdict_link import VerdictLink
+
+LINK_KEYS = ("subject_kind", "subject_id", "verdict_revision", "verdict_digest", "state")
+
+
+def running_verdict_link() -> VerdictLink | None:
+ """The link for hypotheses staged now, or None when this run did not start from a trace row.
+
+ The origin in the running work was written by the server from the stored trace (see
+ trace_origin); a run without it, or an older origin without the verdict's content digest,
+ makes no link, and the hypothesis simply has none.
+ """
+ work = research_work.get()
+ found = None if work is None else work.context.get("origin")
+ if not isinstance(found, dict):
+ return None
+ origin = cast(dict[str, object], found)
+ if not origin.get("verdict_digest"):
+ return None
+ try:
+ return VerdictLink.model_validate({key: origin.get(key) for key in LINK_KEYS})
+ except ValidationError:
+ return None
diff --git a/src/thoth/application/services/ingestion_service.py b/src/thoth/application/services/ingestion_service.py
index 00d8ef1..f27f430 100644
--- a/src/thoth/application/services/ingestion_service.py
+++ b/src/thoth/application/services/ingestion_service.py
@@ -8,6 +8,7 @@
from thoth.application.services.resource_scope_context import resource_stage_scope
from thoth.domain.artifact import ArtifactEnvelope, ParserSelection, StructuralDocument
+from thoth.domain.canonical import same_stored_instant
from thoth.domain.enums import (
AuthorityState,
CutoffState,
@@ -168,7 +169,7 @@ def _prepare(
raise ValueError("project revision changed before source ingestion")
if (
command.captured_cutoff_at is not None
- and project.cutoff_at != command.captured_cutoff_at
+ and not same_stored_instant(project.cutoff_at, command.captured_cutoff_at)
):
raise ValueError("project cutoff changed before source ingestion")
pending_cutoff = (
@@ -222,7 +223,7 @@ def _finish(
raise ValueError("project revision changed before source ingestion")
if (
command.captured_cutoff_at is not None
- and project.cutoff_at != command.captured_cutoff_at
+ and not same_stored_instant(project.cutoff_at, command.captured_cutoff_at)
):
raise ValueError("project cutoff changed before source ingestion")
if command.classify_source_time:
diff --git a/src/thoth/application/services/lesson_ledger.py b/src/thoth/application/services/lesson_ledger.py
new file mode 100644
index 0000000..ef959b5
--- /dev/null
+++ b/src/thoth/application/services/lesson_ledger.py
@@ -0,0 +1,440 @@
+"""The lesson references of a project, written when a rule or a person records something.
+
+A lesson is added in the same commit as the record it rests on (or, for a confirmed effect, right
+after the import that brought the new results) and is never removed. Reading recalls only the
+lessons of the row's exact context and says which of them went stale or were refuted. No model is
+called and no lesson is a sentence: what is shown is read back from the records they point at.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Mapping, Sequence
+from dataclasses import dataclass
+from typing import cast
+
+from thoth.application.services.hypothesis_link_view import HypothesisLinkReader
+from thoth.application.services.hypothesis_same import read_same
+from thoth.application.services.request_records import RequestRecords
+from thoth.application.services.verification_trace import REVISION_PREFIX
+from thoth.domain.discrimination import (
+ DiscriminationLedgerRecord,
+ DiscriminationResult,
+ elimination,
+ latest_results,
+)
+from thoth.domain.enums import EntityType
+from thoth.domain.hypothesis_full import HypothesisRecord
+from thoth.domain.hypothesis_same import groups as same_groups
+from thoth.domain.lesson_ref import (
+ LessonContext,
+ LessonKind,
+ LessonRef,
+ LessonReferenceRecord,
+ LessonSource,
+ fingerprint_of,
+ lesson_id_of,
+ recall_lessons,
+)
+from thoth.domain.trace_closure import ClosureEvent, TraceClosureRecord
+from thoth.domain.verdict_link import CurrentVerdict
+from thoth.domain.verification_trace import (
+ SubjectKind,
+ VerdictRevision,
+ VerificationVerdictRecord,
+)
+from thoth.ports.ledger import LedgerPort
+
+KEY = "lesson-references"
+DISCRIMINATION_KEY = "hypothesis-discrimination"
+CLOSURE_KEY = "trace-closures"
+RULE_ACTOR = "system:lesson-rule"
+HUMAN_CLOSURES = frozenset({"HUMAN_CLOSED", "WAIVER_RECORDED", "CONDITION_CHANGED"})
+
+
+def _dump(item: DiscriminationResult | ClosureEvent) -> dict[str, object]:
+ """What the record says, without when it was stamped (the stamp is not its content)."""
+ content = cast(dict[str, object], item.model_dump(mode="json"))
+ content.pop("created_at", None)
+ return content
+
+
+@dataclass(frozen=True)
+class _ResultIndex:
+ """The test results grouped once: by id, and by hypothesis (each in the order recorded)."""
+
+ by_id: Mapping[str, DiscriminationResult]
+ by_hypothesis: Mapping[str, tuple[DiscriminationResult, ...]]
+
+ @staticmethod
+ def of(results: tuple[DiscriminationResult, ...]) -> _ResultIndex:
+ grouped: dict[str, list[DiscriminationResult]] = {}
+ for item in results:
+ grouped.setdefault(item.hypothesis_id, []).append(item)
+ return _ResultIndex(
+ {item.event_id: item for item in results},
+ {key: tuple(value) for key, value in grouped.items()},
+ )
+
+ def of_hypothesis(self, hypothesis_id: str) -> tuple[DiscriminationResult, ...]:
+ return self.by_hypothesis.get(hypothesis_id, ())
+
+
+class LessonLedger:
+ def __init__(self, records: RequestRecords) -> None:
+ self._records = records
+ self._reader = HypothesisLinkReader(records.ledger)
+
+ @property
+ def _ledger(self) -> LedgerPort:
+ return self._records.ledger
+
+ def read(self, project_id: str) -> LessonReferenceRecord:
+ content = self._reader.content(project_id, EntityType.THREAD, KEY)
+ if content is None:
+ return LessonReferenceRecord(project_id=project_id)
+ return LessonReferenceRecord.model_validate(content)
+
+ def _results(self, project_id: str) -> tuple[DiscriminationResult, ...]:
+ content = self._reader.content(project_id, EntityType.THREAD, DISCRIMINATION_KEY)
+ if content is None:
+ return ()
+ return DiscriminationLedgerRecord.model_validate(content).results
+
+ def _closure_events(self, project_id: str) -> tuple[ClosureEvent, ...]:
+ content = self._reader.content(project_id, EntityType.THREAD, CLOSURE_KEY)
+ return () if content is None else TraceClosureRecord.model_validate(content).events
+
+ def _revision(
+ self, project_id: str, digest: str, heads: Mapping[str, str] | None = None
+ ) -> VerdictRevision | None:
+ content = self._reader.content(
+ project_id, EntityType.THREAD, REVISION_PREFIX + digest, heads
+ )
+ return (
+ None if content is None else VerificationVerdictRecord.model_validate(content).revision
+ )
+
+ @staticmethod
+ def _context(subject_kind: str, subject_id: str, revision: VerdictRevision) -> LessonContext:
+ applied = revision.conditions
+ return LessonContext(
+ subject_kind=subject_kind, # type: ignore[arg-type]
+ subject_id=subject_id,
+ unit=applied.unit,
+ condition=applied.condition,
+ rule_id=applied.rule_id,
+ rule_revision=applied.rule_revision,
+ )
+
+ def row_now(
+ self,
+ project_id: str,
+ kind: str,
+ subject_id: str,
+ shown: Mapping[tuple[SubjectKind, str], CurrentVerdict] | None = None,
+ heads: Mapping[str, str] | None = None,
+ ) -> tuple[LessonContext, str] | None:
+ """The context a trace row's verdict stands in now, and that verdict's content digest.
+
+ `shown` is the current verdicts when the caller already read them (a list reads them once).
+ """
+ if shown is None:
+ shown = self._reader.current_verdicts(project_id, self._reader.trace(project_id)[1])
+ current = shown.get((SubjectKind(kind), subject_id))
+ revision = (
+ None if current is None else self._revision(project_id, current.verdict_revision, heads)
+ )
+ if current is None or revision is None:
+ return None
+ return self._context(kind, subject_id, revision), current.verdict_digest
+
+ def _add(self, project_id: str, refs: Sequence[LessonRef], actor_id: str) -> None:
+ record = self.read(project_id)
+ known = {item.lesson_id for item in record.refs}
+ fresh = [item for item in refs if item.lesson_id not in known]
+ if fresh:
+ updated = record.model_copy(update={"refs": (*record.refs, *fresh)})
+ self._records.save(project_id, EntityType.THREAD, KEY, updated, actor_id)
+
+ def _ref(
+ self,
+ kind: LessonKind,
+ outcome: str,
+ sources: Sequence[LessonSource],
+ fingerprint: str,
+ context: LessonContext,
+ actor_id: str,
+ hypothesis_id: str | None = None,
+ test_id: str | None = None,
+ ) -> LessonRef:
+ return LessonRef(
+ lesson_id=lesson_id_of(kind, outcome, sources),
+ kind=kind,
+ outcome=outcome,
+ sources=tuple(sources),
+ fingerprint=fingerprint,
+ context=context,
+ hypothesis_id=hypothesis_id,
+ test_id=test_id,
+ actor_id=actor_id,
+ created_at=self._records.clock.now(),
+ )
+
+ def after_result(
+ self,
+ project_id: str,
+ hypothesis: HypothesisRecord,
+ earlier: tuple[DiscriminationResult, ...],
+ event: DiscriminationResult,
+ actor_id: str,
+ ) -> None:
+ """A recorded test result is a lesson, and so is the elimination it newly amounts to."""
+ link = hypothesis.verdict_link
+ found = (
+ None if link is None else self.row_now(project_id, link.subject_kind, link.subject_id)
+ )
+ if found is None:
+ return # a result with no trace row behind it has no context to be recalled in
+ context, _ = found
+ source = LessonSource(record_kind="DISCRIMINATION_RESULT", record_id=event.event_id)
+ refs = [
+ self._ref(
+ "TEST_RESULT",
+ event.matched,
+ [source],
+ fingerprint_of({"event": _dump(event)}),
+ context,
+ actor_id,
+ event.hypothesis_id,
+ event.test_id,
+ )
+ ]
+ after = elimination((*earlier, event), event.hypothesis_id)
+ if after is not None and after != elimination(earlier, event.hypothesis_id):
+ fitting = [
+ item
+ for item in latest_results((*earlier, event), event.hypothesis_id).values()
+ if item.matched == "ALTERNATIVE"
+ ]
+ refs.append(
+ self._ref(
+ "ELIMINATION",
+ after,
+ [
+ LessonSource(record_kind="DISCRIMINATION_RESULT", record_id=i.event_id)
+ for i in fitting
+ ],
+ fingerprint_of({"events": [_dump(item) for item in fitting]}),
+ context,
+ actor_id,
+ event.hypothesis_id,
+ )
+ )
+ self._add(project_id, refs, actor_id)
+
+ def after_closure(self, project_id: str, event: ClosureEvent, actor_id: str) -> None:
+ """A closure a person recorded is a lesson; a fix is not, until its effect is confirmed."""
+ if event.kind not in HUMAN_CLOSURES:
+ return
+ found = self.row_now(project_id, event.subject_kind, event.subject_id)
+ if found is None:
+ return
+ context, digest = found
+ ref = self._ref(
+ "HUMAN_CLOSURE",
+ event.kind,
+ [LessonSource(record_kind="TRACE_CLOSURE", record_id=event.event_id)],
+ fingerprint_of({"event": _dump(event), "verdict_digest": digest}),
+ context,
+ actor_id,
+ )
+ self._add(project_id, [ref], actor_id)
+
+ def sync_effects(self, project_id: str, rows: Sequence[Mapping[str, object]]) -> None:
+ """A fix whose effect the rules confirmed is a lesson, in the confirming verdict context."""
+ with self._ledger.transaction():
+ self._sync_effects(project_id, rows)
+
+ def _sync_effects(self, project_id: str, rows: Sequence[Mapping[str, object]]) -> None:
+ events = {item.event_id: item for item in self._closure_events(project_id)}
+ refs: list[LessonRef] = []
+ for row in rows:
+ effect = row.get("effect_verdict_revision")
+ fixes = [
+ item
+ for item in cast(list[dict[str, object]], row["events"])
+ if item["kind"] == "FIX_APPLIED"
+ ]
+ if row.get("effect_confirmed") is not True or not isinstance(effect, str) or not fixes:
+ continue
+ fix = events.get(str(fixes[-1]["event_id"]))
+ kind, subject_id = str(row["subject_kind"]), str(row["subject_id"])
+ revision = self._revision(project_id, effect)
+ now = self.row_now(project_id, kind, subject_id)
+ if fix is None or revision is None or now is None:
+ continue
+ refs.append(
+ self._ref(
+ "EFFECT_CONFIRMED",
+ "CONFIRMED",
+ [LessonSource(record_kind="TRACE_CLOSURE", record_id=fix.event_id)],
+ fingerprint_of(
+ {"event": _dump(fix), "effect": effect, "verdict_digest": now[1]}
+ ),
+ self._context(kind, subject_id, revision),
+ RULE_ACTOR,
+ )
+ )
+ self._add(project_id, refs, RULE_ACTOR)
+
+ def _fingerprint_now(
+ self,
+ project_id: str,
+ ref: LessonRef,
+ results: tuple[DiscriminationResult, ...],
+ events: Mapping[str, ClosureEvent],
+ rows: Mapping[tuple[str, str], Mapping[str, object]],
+ row_now: Callable[[str, str], tuple[LessonContext, str] | None] | None = None,
+ index: _ResultIndex | None = None,
+ ) -> str | None:
+ """The fingerprint of what the lesson rests on now; None when it cannot be found.
+
+ `index` is the results grouped once by id and by hypothesis (a list builds it once); without
+ it the results are searched for each lesson.
+ """
+ index = index or _ResultIndex.of(results)
+ first = ref.sources[0].record_id
+ if ref.kind == "TEST_RESULT":
+ own = index.by_id.get(first)
+ if own is None:
+ return None
+ newest = latest_results(index.of_hypothesis(own.hypothesis_id), own.hypothesis_id).get(
+ own.test_id
+ )
+ return None if newest is None else fingerprint_of({"event": _dump(newest)})
+ if ref.kind == "ELIMINATION":
+ hypothesis_id = ref.hypothesis_id or ""
+ own_results = index.of_hypothesis(hypothesis_id)
+ if elimination(own_results, hypothesis_id) != ref.outcome:
+ return None
+ fitting = [
+ i
+ for i in latest_results(own_results, hypothesis_id).values()
+ if i.matched == "ALTERNATIVE"
+ ]
+ return fingerprint_of({"events": [_dump(item) for item in fitting]})
+ event = events.get(first)
+ row_now = row_now or (lambda kind, subject: self.row_now(project_id, kind, subject))
+ now = row_now(ref.context.subject_kind, ref.context.subject_id)
+ if event is None or now is None:
+ return None
+ if ref.kind == "HUMAN_CLOSURE":
+ return fingerprint_of({"event": _dump(event), "verdict_digest": now[1]})
+ row = rows.get((ref.context.subject_kind, ref.context.subject_id))
+ effect = None if row is None else row.get("effect_verdict_revision")
+ if row is None or row.get("effect_confirmed") is not True or not isinstance(effect, str):
+ return None
+ return fingerprint_of({"event": _dump(event), "effect": effect, "verdict_digest": now[1]})
+
+ def recall(
+ self, project_id: str, rows: Sequence[Mapping[str, object]]
+ ) -> list[dict[str, object]]:
+ """Each row's lessons of the same context, oldest first, with their state and sources."""
+ refs = self.read(project_id).refs
+ if not refs:
+ return []
+ results = self._results(project_id)
+ events = {item.event_id: item for item in self._closure_events(project_id)}
+ by_row = {(str(item["subject_kind"]), str(item["subject_id"])): item for item in rows}
+ # the trace is read once, and each row's context once, however many lessons rest on it
+ shown = self._reader.current_verdicts(
+ project_id,
+ self._reader.trace(project_id)[1],
+ {(SubjectKind(r.context.subject_kind), r.context.subject_id) for r in refs},
+ )
+ heads = self._ledger.read_heads(project_id)
+ known: dict[tuple[str, str], tuple[LessonContext, str] | None] = {}
+
+ def row_now(kind: str, subject: str) -> tuple[LessonContext, str] | None:
+ if (kind, subject) not in known:
+ known[(kind, subject)] = self.row_now(project_id, kind, subject, shown, heads)
+ return known[(kind, subject)]
+
+ index = _ResultIndex.of(results)
+ prints = {
+ item.lesson_id: self._fingerprint_now(
+ project_id, item, results, events, by_row, row_now, index
+ )
+ for item in refs
+ }
+ # hypotheses a person marked as the same: each member's whole group
+ siblings = {
+ member: group
+ for group in same_groups(read_same(self._reader, project_id))
+ for member in group
+ }
+ found: list[dict[str, object]] = []
+ by_subject: dict[tuple[str, str], list[LessonRef]] = {}
+ for ref in refs: # a lesson is recalled only for its own row, so each row sees only its own
+ by_subject.setdefault((ref.context.subject_kind, ref.context.subject_id), []).append(
+ ref
+ )
+ for (kind, subject_id), own in by_subject.items():
+ now = row_now(kind, subject_id)
+ if now is None:
+ continue
+ for ref, state in recall_lessons(own, now[0], prints, siblings):
+ if (ref.context.subject_kind, ref.context.subject_id) == (kind, subject_id):
+ item = self._item(ref, state, index.by_id, events)
+ others = siblings.get(ref.hypothesis_id or "", frozenset()) - {
+ ref.hypothesis_id
+ }
+ found.append(
+ {**item, "same_hypothesis_ids": sorted(others)} if others else item
+ )
+ return found
+
+ @staticmethod
+ def _item(
+ ref: LessonRef,
+ state: str,
+ results: Mapping[str, DiscriminationResult],
+ events: Mapping[str, ClosureEvent],
+ ) -> dict[str, object]:
+ detail: dict[str, object] = {}
+ first = ref.sources[0].record_id
+ if ref.kind in ("TEST_RESULT", "ELIMINATION"):
+ seen = [results[item.record_id] for item in ref.sources if item.record_id in results]
+ detail = {
+ "observations": [
+ {
+ "test_id": i.test_id,
+ "matched": i.matched,
+ "observation": i.observation,
+ "evidence_refs": list(i.evidence_refs),
+ }
+ for i in seen
+ ]
+ }
+ elif first in events:
+ closed = events[first]
+ detail = {
+ "closure_kind": closed.kind,
+ "basis_ref": closed.basis_ref,
+ "note": closed.note,
+ "scope": closed.scope,
+ "original_state": closed.verdict_state,
+ }
+ return {
+ "lesson_id": ref.lesson_id,
+ "kind": ref.kind,
+ "outcome": ref.outcome,
+ "state": state,
+ "subject_kind": ref.context.subject_kind,
+ "subject_id": ref.context.subject_id,
+ "hypothesis_id": ref.hypothesis_id,
+ "test_id": ref.test_id,
+ "actor_id": ref.actor_id,
+ "created_at": ref.created_at.isoformat(),
+ "detail": detail,
+ }
diff --git a/src/thoth/application/services/memory_admission.py b/src/thoth/application/services/memory_admission.py
index f909179..cb1d2cb 100644
--- a/src/thoth/application/services/memory_admission.py
+++ b/src/thoth/application/services/memory_admission.py
@@ -6,7 +6,7 @@
from thoth.domain.actor import ActorRef
from thoth.domain.auth import authenticated_data_scope_allows, current_authenticated_actor
-from thoth.domain.canonical import canonical_payload, domain_digest
+from thoth.domain.canonical import canonical_payload, domain_digest, same_stored_instant
from thoth.domain.enums import ProjectLifecycle
from thoth.domain.memory_preparation import MemoryAuthorityBasis
from thoth.ports.auth import AuthSessionStorePort
@@ -44,7 +44,7 @@ def capture(
or policy is None
or policy.project_id != project_id
or project.policy_binding_ref != policy.policy_id
- or project.cutoff_at != cutoff_at
+ or not same_stored_instant(project.cutoff_at, cutoff_at)
or project.lifecycle
in {
ProjectLifecycle.CLOSING,
diff --git a/src/thoth/application/services/memory_query_expansion.py b/src/thoth/application/services/memory_query_expansion.py
new file mode 100644
index 0000000..ec146d2
--- /dev/null
+++ b/src/thoth/application/services/memory_query_expansion.py
@@ -0,0 +1,154 @@
+"""The one model call an investigation makes to widen its question for memory recall.
+
+The caller awaits this before build_context and hands the outcome over. It is asked at most once
+per investigation: a second caller in the same attempt gets the first answer. A failure, a time-out
+or a reply in the wrong shape never fails the investigation; recall then runs on the question's
+own words and the selection record says why.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from datetime import datetime
+
+from pydantic import ValidationError
+
+from thoth.application.services.full_project_memory import FullProjectMemoryService
+from thoth.domain.enums import ModelRole
+from thoth.domain.memory_expansion import (
+ MEMORY_QUERY_EXPANSION_PURPOSE,
+ MemoryExpansionOutcome,
+ MemoryExpansionRecord,
+ QueryExpansion,
+)
+from thoth.domain.memory_terms import added_words, words
+from thoth.domain.model import ContextPack, ModelRequest
+from thoth.domain.research_execution import ResearchFence, ResearchWork, model_purpose
+from thoth.domain.research_lease import ResearchLeaseLost, ResearchPaused
+from thoth.ports.memory import MemoryQueryExpanderPort
+from thoth.ports.model import ModelOutputContractHold, ModelPort
+
+PROMPT_VERSION = "memory_query_expansion.v1"
+EXPANSION_MAX_OUTPUT_TOKENS = 300
+EXPANSION_TIMEOUT_SECONDS = 20.0
+CACHE_KEY = "memory_query_expansion"
+_TASK = (
+ "A user asks a question about a project. Stored project memories are short notes, written in "
+ "their own words, that may be about the same thing in other words. Return search words only: "
+ "synonyms (other words for what the question says), keywords (the topic in another language, "
+ "such as English for a Korean question and the reverse), related (closely related terms) and "
+ "note_line (one sentence written the way a stored memory about this question might read). "
+ "At most eight short phrases in each list. Do not answer the question. Your words only help "
+ "search; they decide nothing."
+)
+
+
+class ModelMemoryQueryExpander:
+ """Asks the same ModelPort the investigation already uses; opens no separate model route."""
+
+ def __init__(
+ self,
+ model: ModelPort,
+ *,
+ project_id: str,
+ cutoff_at: datetime,
+ model_policy_ref: str,
+ head_set_digest: str,
+ max_output_tokens: int = EXPANSION_MAX_OUTPUT_TOKENS,
+ ) -> None:
+ self._model = model
+ self._project_id, self._cutoff_at = project_id, cutoff_at
+ self._policy_ref, self._heads = model_policy_ref, head_set_digest
+ self._max_output_tokens = max_output_tokens
+ self.dispatch_ids: tuple[str, ...] = ()
+
+ async def expand(self, query: str) -> QueryExpansion:
+ context = ContextPack(
+ case_id=f"memory-expansion:{self._project_id}",
+ project_id=self._project_id,
+ object_id="memory-expansion",
+ problem=query,
+ evidence=(),
+ criteria=(),
+ sufficiency=None,
+ input_head_set_digest=self._heads,
+ research_context={"task": _TASK},
+ )
+ token = model_purpose.set(MEMORY_QUERY_EXPANSION_PURPOSE)
+ try:
+ result = await self._model.structured(
+ ModelRequest(
+ role=ModelRole.MEMORY_QUERY_EXPANDER,
+ project_id=self._project_id,
+ cutoff_at=self._cutoff_at,
+ context_pack=context,
+ output_model=QueryExpansion,
+ prompt_version=PROMPT_VERSION,
+ model_policy_ref=self._policy_ref,
+ max_output_tokens=self._max_output_tokens,
+ )
+ )
+ finally:
+ model_purpose.reset(token)
+ self.dispatch_ids = result.dispatch_ids
+ return result.output
+
+
+async def widen_query(
+ *,
+ memory: FullProjectMemoryService,
+ expander: MemoryQueryExpanderPort,
+ work: ResearchWork | None,
+ project_id: str,
+ query: str,
+ timeout_seconds: float = EXPANSION_TIMEOUT_SECONDS,
+) -> MemoryExpansionOutcome:
+ """The widened question for this investigation: asked once, remembered for the next caller."""
+
+ cache = None if work is None else work.preprocessing_cache
+ cached = None if cache is None else cache.get(CACHE_KEY)
+ if isinstance(cached, MemoryExpansionOutcome):
+ return cached
+ outcome = await _ask(memory, expander, project_id, query, timeout_seconds)
+ if cache is not None:
+ cache[CACHE_KEY] = outcome
+ return outcome
+
+
+async def _ask(
+ memory: FullProjectMemoryService,
+ expander: MemoryQueryExpanderPort,
+ project_id: str,
+ query: str,
+ timeout_seconds: float,
+) -> MemoryExpansionOutcome:
+ skipped = memory.expansion_skip_reason(project_id, query)
+ if skipped is not None:
+ return MemoryExpansionOutcome(record=MemoryExpansionRecord(reason=skipped))
+
+ def failed(status: str, reason: str) -> MemoryExpansionOutcome:
+ record = MemoryExpansionRecord.model_validate(
+ {"status": status, "reason": reason, "dispatch_ids": dispatch_ids()}
+ )
+ return MemoryExpansionOutcome(record=record)
+
+ def dispatch_ids() -> tuple[str, ...]:
+ return expander.dispatch_ids if isinstance(expander, ModelMemoryQueryExpander) else ()
+
+ try:
+ expansion = await asyncio.wait_for(expander.expand(query), timeout_seconds)
+ except (ResearchFence, ResearchLeaseLost, ResearchPaused):
+ raise
+ except (ModelOutputContractHold, ValidationError) as exc:
+ return failed("EXPANSION_INVALID", type(exc).__name__)
+ except TimeoutError:
+ return failed("EXPANSION_FAILED", "TIMEOUT")
+ except Exception as exc:
+ return failed("EXPANSION_FAILED", type(exc).__name__)
+ added = added_words(expansion.text(), words(query))
+ record = MemoryExpansionRecord(
+ status="USED",
+ added_words=tuple(word.run for word in added),
+ dispatch_ids=dispatch_ids(),
+ )
+ return MemoryExpansionOutcome(record=record, expansion=expansion)
diff --git a/src/thoth/application/services/memory_recall.py b/src/thoth/application/services/memory_recall.py
index 2bffede..f8e8af9 100644
--- a/src/thoth/application/services/memory_recall.py
+++ b/src/thoth/application/services/memory_recall.py
@@ -7,7 +7,18 @@
An automatic memory (a hypothesis, action or outcome saved after an investigation) is let in more
narrowly than a user's correction: the question must name it, quote it, be a follow-up question
("앞에서 세운 가설"), or share two words that are not words every memory of the project shares.
-Counting words is all this does; no score is made.
+Counting words is all this does; no score is made. Words are counted per question word: a question
+word is matched once when it, or a pair of its letters, is in the memory (see memory_terms.py).
+The memory's words are made from its text when it is recalled, not read from the words stored with
+it, so memories saved under an older splitting rule are found by the current one.
+
+An automatic memory also needs at least one question word found as written (not only through a
+letter pair of it) besides the two; a word met only through a pair is marked as a partial match.
+
+Words a model added to the question (memory_expansion.py) take part the same way, in a second
+group: a memory the question's own words reach comes first, one reached only with the added words
+follows, and the count an automatic memory needs is made from both. An added word that the
+question already has (itself, or one letter pair of it) is not counted again.
"""
from __future__ import annotations
@@ -18,7 +29,8 @@
from dataclasses import dataclass, field
from thoth.domain.enums import MemoryKind, MemoryPayloadMode
-from thoth.domain.memory import FullMemoryRevision
+from thoth.domain.memory import FullMemoryRevision, MemoryMatch
+from thoth.domain.memory_terms import Word, matched_words, terms, word_runs
OMITTED_BY_BUDGET = "OMITTED_BY_BUDGET"
AUTO_MEMORY_WEAK_MATCH = "AUTO_MEMORY_WEAK_MATCH"
@@ -126,16 +138,38 @@ def follow_up_markers(query: str) -> tuple[str, ...]:
return tuple(found)
-def common_terms(eligible: Sequence[FullMemoryRevision]) -> frozenset[str]:
- """Words that appear in at least half of the memories allowed at all; counting only."""
+_LABEL = re.compile(r"^[A-Za-z_][\w ]{0,40}:\s+")
+
+
+def recall_text(item: FullMemoryRevision) -> str:
+ """The words a memory is found by: a correction's text, or a new memory's values without the
+ reference line and the field names that every memory of a kind shares."""
+
+ if item.assertion is not None:
+ return item.assertion
+ excerpt = item.content_excerpt
+ if item.source_ref and excerpt.startswith(item.source_ref + "\n"):
+ excerpt = excerpt[len(item.source_ref) + 1 :]
+ first, *rest = excerpt.split(" | ")
+ return " ".join([first, *(_LABEL.sub("", part) for part in rest)])
+
+
+def item_terms(item: FullMemoryRevision) -> frozenset[str]:
+ return terms(recall_text(item))
+
+
+def common_terms(
+ eligible: Sequence[FullMemoryRevision], query_words: Sequence[Word]
+) -> frozenset[str]:
+ """Question words that appear in at least half of the memories allowed at all; counting only."""
if len(eligible) < COMMON_TERM_MIN_POOL:
return frozenset()
counts: dict[str, int] = defaultdict(int)
for item in eligible:
- for term in set(item.query_terms):
- counts[term] += 1
- return frozenset(term for term, count in counts.items() if count * 2 >= len(eligible))
+ for run in word_runs(matched_words(query_words, item_terms(item))):
+ counts[run] += 1
+ return frozenset(run for run, count in counts.items() if count * 2 >= len(eligible))
@dataclass
@@ -144,39 +178,82 @@ class Narrowing:
excluded: dict[str, list[str]] = field(default_factory=lambda: {})
common_terms: frozenset[str] = frozenset()
follow_up_markers: tuple[str, ...] = ()
+ # Memories that only the added words let in, and how each memory met the question.
+ expansion_only: frozenset[str] = frozenset()
+ matches: dict[str, MemoryMatch] = field(default_factory=lambda: {})
def narrow_recall(
- eligible: Sequence[FullMemoryRevision], query: str, query_terms: frozenset[str]
+ eligible: Sequence[FullMemoryRevision],
+ query: str,
+ query_words: Sequence[Word],
+ added_words: Sequence[Word] = (),
) -> Narrowing:
"""Which of the memories allowed at all meet this question, and why the others do not."""
markers = follow_up_markers(query)
- common = common_terms(eligible)
+ own_common = common_terms(eligible, query_words)
+ common = common_terms(eligible, (*query_words, *added_words)) if added_words else own_common
narrowing = Narrowing(common_terms=common, follow_up_markers=markers)
text = _norm(query)
+ later: set[str] = set()
def leave_out(item: FullMemoryRevision, reason: str) -> None:
narrowing.excluded.setdefault(reason, []).append(item.memory_revision_id)
+ def let_in(
+ item: FullMemoryRevision,
+ own: frozenset[str],
+ own_whole: frozenset[str],
+ added: frozenset[str],
+ added_whole: frozenset[str],
+ ) -> None:
+ narrowing.relevant.append(item)
+ by_expansion = item.memory_revision_id in later
+ added = added if by_expansion else frozenset()
+ partial, partial_added = own - own_whole, added - added_whole
+ narrowing.matches[item.memory_revision_id] = MemoryMatch(
+ matched_by="EXPANSION" if by_expansion else "QUERY",
+ words=tuple(sorted(own)),
+ added_words=tuple(sorted(added)),
+ match_strength="PARTIAL" if partial or partial_added else "WHOLE",
+ partial_words=tuple(sorted(partial)),
+ partial_added_words=tuple(sorted(partial_added)),
+ )
+
for item in eligible:
- shared = query_terms & set(item.query_terms)
- if markers or not query_terms:
- narrowing.relevant.append(item)
+ item_words = item_terms(item)
+ met = matched_words(query_words, item_words)
+ shared = word_runs(met)
+ whole = frozenset(word.run for word in met if word.run in item_words)
+ met_added = matched_words(added_words, item_words)
+ added = word_runs(met_added)
+ added_whole = frozenset(word.run for word in met_added if word.run in item_words)
+ if markers or not query_words:
+ let_in(item, shared, whole, frozenset(), frozenset())
elif not is_auto_memory(item):
- # A user's correction needs one shared word, as before.
+ # A user's correction needs one matched word, as before.
if shared:
- narrowing.relevant.append(item)
+ let_in(item, shared, whole, frozenset(), frozenset())
+ elif added:
+ later.add(item.memory_revision_id)
+ let_in(item, shared, whole, added, added_whole)
else:
leave_out(item, QUERY_IRRELEVANT)
- elif _names_it(item, text) or _quotes_it(item, text):
- narrowing.relevant.append(item)
- elif not shared:
+ elif (
+ _names_it(item, text)
+ or _quotes_it(item, text)
+ or (len(shared - own_common) >= 2 and whole - own_common)
+ ):
+ let_in(item, shared, whole, frozenset(), frozenset())
+ elif len((shared | added) - common) >= 2 and added_whole - common:
+ later.add(item.memory_revision_id)
+ let_in(item, shared, whole, added, added_whole)
+ elif not shared and not added:
leave_out(item, QUERY_IRRELEVANT)
- elif len(shared - common) >= 2:
- narrowing.relevant.append(item)
else:
leave_out(item, AUTO_MEMORY_WEAK_MATCH)
+ narrowing.expansion_only = frozenset(later)
return narrowing
@@ -195,7 +272,7 @@ def _names_it(item: FullMemoryRevision, query: str) -> bool:
def order_recall(
items: Sequence[FullMemoryRevision],
query: str,
- query_terms: frozenset[str],
+ query_words: Sequence[Word],
follow_up: bool = False,
) -> list[FullMemoryRevision]:
text = _norm(query)
@@ -203,7 +280,9 @@ def order_recall(
for item in items:
# A follow-up has no words to count: a user's correction goes first, the rest by recency.
third = (
- int(not is_auto_memory(item)) if follow_up else len(query_terms & set(item.query_terms))
+ int(not is_auto_memory(item))
+ if follow_up
+ else len(matched_words(query_words, item_terms(item)))
)
groups[(_names_it(item, text), _quotes_it(item, text), third)].append(item)
ordered: list[FullMemoryRevision] = []
@@ -233,14 +312,26 @@ class RecallPlan:
def plan_recall(
candidates: Sequence[FullMemoryRevision],
query: str,
- query_terms: frozenset[str],
+ query_words: Sequence[Word],
limits: RecallLimits,
follow_up: bool = False,
+ added_words: Sequence[Word] = (),
+ expansion_only: frozenset[str] = frozenset(),
) -> RecallPlan:
"""Narrow the memories that may be recalled to the ones the question is given."""
plan = RecallPlan()
- ordered = order_recall(candidates, query, query_terms, follow_up)
+ ordered = order_recall(
+ [i for i in candidates if i.memory_revision_id not in expansion_only],
+ query,
+ query_words,
+ follow_up,
+ ) + order_recall(
+ [i for i in candidates if i.memory_revision_id in expansion_only],
+ query,
+ (*query_words, *added_words),
+ follow_up,
+ )
plan.retrieved = ordered[: limits.candidates]
def leave_out(item: FullMemoryRevision, limit: str) -> None:
diff --git a/src/thoth/application/services/memory_settings.py b/src/thoth/application/services/memory_settings.py
index 3dbef3f..3ce24a9 100644
--- a/src/thoth/application/services/memory_settings.py
+++ b/src/thoth/application/services/memory_settings.py
@@ -1,4 +1,4 @@
-"""A project's memory switch, kept as a versioned ledger record like the model preference."""
+"""A project's memory switches, kept as a versioned ledger record like the model preference."""
from __future__ import annotations
@@ -14,7 +14,8 @@ class MemorySettingsConflict(ValueError):
class MemorySettingsService:
- """Whether a project gives recalled memory to an investigation; on until turned off.
+ """Whether a project gives recalled memory to an investigation, and whether an investigation
+ may ask a model for extra search words; each is on until turned off.
Turning it off changes nothing that is stored, listed or corrected. It only stops the
recalled memory from being placed in front of an investigation.
@@ -24,27 +25,43 @@ def __init__(self, records: RequestRecords) -> None:
self._records = records
def read(self, project_id: str) -> tuple[str | None, bool]:
+ digest, settings = self.read_settings(project_id)
+ return digest, settings.memory_injection
+
+ def read_settings(self, project_id: str) -> tuple[str | None, ProjectMemorySettings]:
record = self._records.read(project_id, EntityType.THREAD, KEY)
if record is None:
- return None, True
- return record[0].revision_digest, ProjectMemorySettings.model_validate(
- record[1]
- ).memory_injection
+ return None, ProjectMemorySettings(project_id=project_id)
+ return record[0].revision_digest, ProjectMemorySettings.model_validate(record[1])
def enabled(self, project_id: str) -> bool:
return self.read(project_id)[1]
+ def expansion_enabled(self, project_id: str) -> bool:
+ return self.read_settings(project_id)[1].query_expansion
+
def save(
- self, project_id: str, enabled: bool, expected_digest: str | None, actor_id: str
+ self,
+ project_id: str,
+ enabled: bool,
+ expected_digest: str | None,
+ actor_id: str,
+ query_expansion: bool | None = None,
) -> str:
with self._records.ledger.transaction():
- current, _ = self.read(project_id)
+ current, settings = self.read_settings(project_id)
if current != expected_digest:
raise MemorySettingsConflict("MEMORY_SETTINGS_REVISION_CONFLICT")
return self._records.save(
project_id,
EntityType.THREAD,
KEY,
- ProjectMemorySettings(project_id=project_id, memory_injection=enabled),
+ ProjectMemorySettings(
+ project_id=project_id,
+ memory_injection=enabled,
+ query_expansion=settings.query_expansion
+ if query_expansion is None
+ else query_expansion,
+ ),
actor_id,
).revision_digest
diff --git a/src/thoth/application/services/model_call_settings.py b/src/thoth/application/services/model_call_settings.py
index 6fe6db4..6093ca9 100644
--- a/src/thoth/application/services/model_call_settings.py
+++ b/src/thoth/application/services/model_call_settings.py
@@ -1,4 +1,4 @@
-"""A project's cut-off-call retry switch, a versioned ledger record like the memory switch."""
+"""A project's model-call switches (cut-off retry, hypothesis contract v3) as a ledger record."""
from __future__ import annotations
@@ -6,6 +6,7 @@
from thoth.domain.enums import EntityType
from thoth.domain.model_call_settings import (
AUTO_RETRY_INTERRUPTED_DEFAULT,
+ HYPOTHESIS_CONTRACT_V3_DEFAULT,
ProjectModelCallSettings,
)
@@ -22,21 +23,39 @@ class ModelCallSettingsService:
def __init__(self, records: RequestRecords) -> None:
self._records = records
- def read(self, project_id: str) -> tuple[str | None, bool]:
+ def snapshot(self, project_id: str) -> tuple[str | None, bool, bool]:
+ """The settings digest and both switches as they stand."""
record = self._records.read(project_id, EntityType.THREAD, KEY)
if record is None:
- return None, AUTO_RETRY_INTERRUPTED_DEFAULT
+ return None, AUTO_RETRY_INTERRUPTED_DEFAULT, HYPOTHESIS_CONTRACT_V3_DEFAULT
settings = ProjectModelCallSettings.model_validate(record[1])
- return record[0].revision_digest, settings.auto_retry_interrupted_model_call
+ return (
+ record[0].revision_digest,
+ settings.auto_retry_interrupted_model_call,
+ settings.hypothesis_contract_v3,
+ )
+
+ def read(self, project_id: str) -> tuple[str | None, bool]:
+ digest, retry, _ = self.snapshot(project_id)
+ return digest, retry
+
+ def hypothesis_contract_v3(self, project_id: str) -> bool:
+ return self.snapshot(project_id)[2]
def auto_retry_interrupted(self, project_id: str) -> bool:
return self.read(project_id)[1]
def save(
- self, project_id: str, enabled: bool, expected_digest: str | None, actor_id: str
+ self,
+ project_id: str,
+ enabled: bool | None,
+ expected_digest: str | None,
+ actor_id: str,
+ hypothesis_v3: bool | None = None,
) -> str:
+ """Save the switches; one left as None keeps its value."""
with self._records.ledger.transaction():
- current, _ = self.read(project_id)
+ current, retry, contract = self.snapshot(project_id)
if current != expected_digest:
raise ModelCallSettingsConflict("MODEL_CALL_SETTINGS_REVISION_CONFLICT")
return self._records.save(
@@ -44,7 +63,9 @@ def save(
EntityType.THREAD,
KEY,
ProjectModelCallSettings(
- project_id=project_id, auto_retry_interrupted_model_call=enabled
+ project_id=project_id,
+ auto_retry_interrupted_model_call=retry if enabled is None else enabled,
+ hypothesis_contract_v3=contract if hypothesis_v3 is None else hypothesis_v3,
),
actor_id,
).revision_digest
diff --git a/src/thoth/application/services/r2_closed_loop.py b/src/thoth/application/services/r2_closed_loop.py
index 2bce762..00a3879 100644
--- a/src/thoth/application/services/r2_closed_loop.py
+++ b/src/thoth/application/services/r2_closed_loop.py
@@ -14,6 +14,7 @@
SandboxTemplateRequired,
)
from thoth.application.services.r2_test_lifecycle import (
+ PendingResultHold,
PreparedResearchExecution,
R2TestLifecycle,
ResearchTestCompletion,
@@ -457,6 +458,11 @@ def _test_hold(
"sandbox_receipt": None
if bundle is None
else cast(JsonValue, bundle.receipt.model_dump(mode="json")),
+ **(
+ {"pending_result": cast(JsonValue, error.detail)}
+ if isinstance(error, PendingResultHold)
+ else {}
+ ),
}
)
diff --git a/src/thoth/application/services/r2_test_lifecycle.py b/src/thoth/application/services/r2_test_lifecycle.py
index 7f81111..8a34b22 100644
--- a/src/thoth/application/services/r2_test_lifecycle.py
+++ b/src/thoth/application/services/r2_test_lifecycle.py
@@ -7,6 +7,7 @@
from pydantic import JsonValue
+from thoth.application.services.execution_pending import PENDING_RESULT_CLEARED_EVENT, is_unsettled
from thoth.application.services.execution_service import ExecutionService
from thoth.application.services.hypothesis_service import HypothesisService
from thoth.application.services.hypothesis_test_lifecycle import HypothesisTestLifecycle
@@ -48,6 +49,17 @@ class ResearchTestCompletion:
projection: dict[str, JsonValue]
+RECONCILIATION_REQUIRED = "RESEARCH_TEST_RESULT_RECONCILIATION_REQUIRED"
+
+
+class PendingResultHold(ValueError):
+ """An earlier run of the same work has no settled result; says what it is and the two exits."""
+
+ def __init__(self, detail: dict[str, JsonValue]) -> None:
+ super().__init__(RECONCILIATION_REQUIRED)
+ self.detail = detail
+
+
class R2TestLifecycle:
def __init__(
self,
@@ -270,9 +282,23 @@ def _reject_pending_result(
action: ActionCandidate,
spec: SandboxRunSpec,
) -> None:
+ """Hold when an earlier run of this same work has no settled result.
+
+ The same work is the project, the research object and the action. A different set of input
+ files does not make it different work: it is only recorded in the hold so the person can
+ see what changed. A result already settled, another action, or another object runs as usual.
+ """
inputs = sorted(item.content_sha256 for item in spec.input_snapshots)
+ now_binding = canonical_payload(
+ {
+ "runtime_profile": spec.runtime_profile.value,
+ "image_digest": spec.image_digest,
+ "argv": spec.argv,
+ "policy_digest": spec.policy_digest,
+ }
+ )
for execution in self._executions.list_executions(project_id):
- if execution.object_id != action.object_id or execution.plan_id != spec.action_plan_id:
+ if execution.object_id != action.object_id:
continue
revision = self._ledger.read_revision_by_digest(
project_id, execution.plan_revision_digest
@@ -283,12 +309,11 @@ def _reject_pending_result(
if snapshot is None:
raise ValueError("RESEARCH_TEST_PENDING_EXECUTION_UNRESOLVED")
plan = ActionPlanRecord.model_validate(snapshot.content)
+ cleared = self._cleared_attempts(project_id, execution.plan_execution_id)
for attempt in self._executions.list_attempts(project_id, execution.plan_execution_id):
- if (
- attempt.state
- not in {"DISPATCHED", "RUNNING", "CANCEL_REQUESTED", "UNKNOWN_COMPLETION"}
- and attempt.observation_completeness != "NOT_ADMITTED"
- ):
+ if attempt.attempt_id in cleared:
+ continue
+ if not is_unsettled(attempt):
continue
step = next(
(
@@ -299,22 +324,47 @@ def _reject_pending_result(
),
None,
)
- if step is None or sorted(attempt.input_digests) != inputs:
+ if step is None:
continue
binding = step.get("runtime_binding")
if not isinstance(binding, dict):
raise ValueError("RESEARCH_TEST_PENDING_EXECUTION_UNRESOLVED")
- if canonical_payload(cast(dict[str, object], binding)) != canonical_payload(
+ previous = sorted(attempt.input_digests)
+ # Citation, hypothesis or input-file changes do not authorize duplicate work.
+ raise PendingResultHold(
{
- "runtime_profile": spec.runtime_profile.value,
- "image_digest": spec.image_digest,
- "argv": spec.argv,
- "policy_digest": spec.policy_digest,
+ "plan_execution_id": execution.plan_execution_id,
+ "execution_revision": execution.revision,
+ "attempt_id": attempt.attempt_id,
+ "attempt_state": attempt.state,
+ "observation_completeness": attempt.observation_completeness,
+ "action_id": action.action_id,
+ "object_id": action.object_id,
+ "plan_id": execution.plan_id,
+ "input_difference": {
+ "same": previous == inputs,
+ "previous_count": len(previous),
+ "current_count": len(inputs),
+ "removed": [item for item in previous if item not in inputs],
+ "added": [item for item in inputs if item not in previous],
+ },
+ "runtime_binding_same": canonical_payload(cast(dict[str, object], binding))
+ == now_binding,
+ # The one way out, whatever state the earlier run is in, is to clear it
+ # (with a reason) and run again.
+ "exits": ["CLEAR_AND_RERUN"],
}
- ):
- continue
- # Citation or hypothesis revisions do not authorize duplicate external work.
- raise ValueError("RESEARCH_TEST_RESULT_RECONCILIATION_REQUIRED")
+ )
+
+ def _cleared_attempts(self, project_id: str, execution_id: str) -> frozenset[str]:
+ cleared: set[str] = set()
+ for record in self._executions.list_audit(project_id, execution_id):
+ if record.event_type != PENDING_RESULT_CLEARED_EVENT:
+ continue
+ attempt_id = record.payload.get("attempt_id")
+ if isinstance(attempt_id, str):
+ cleared.add(attempt_id)
+ return frozenset(cleared)
def hold(
self, prepared: PreparedResearchExecution, bundle: SandboxExecutionBundle, reason: str
diff --git a/src/thoth/application/services/research_analysis.py b/src/thoth/application/services/research_analysis.py
index e5a08fc..3acdcbd 100644
--- a/src/thoth/application/services/research_analysis.py
+++ b/src/thoth/application/services/research_analysis.py
@@ -8,6 +8,7 @@
from thoth.application.services.connector_service import ConnectorService
from thoth.application.services.full_project_memory import FullProjectMemoryService
+from thoth.application.services.memory_query_expansion import ModelMemoryQueryExpander, widen_query
from thoth.application.services.request_records import RequestRecords
from thoth.application.services.requirement_compiler import compile_requirements
from thoth.application.services.research_context_assembler import assemble_context
@@ -33,6 +34,7 @@
ReviewAdjudication,
ReviewProposal,
)
+from thoth.domain.memory_expansion import MemoryExpansionOutcome
from thoth.domain.model import ContextPack, ModelRequest
from thoth.domain.project import Project, WorkThread
from thoth.domain.public_web_access import PROJECT_PUBLIC_WEB_CONNECTOR_ID
@@ -255,6 +257,27 @@ def require_current_sources(
if expected_digest is not None and self.source_digest(evidence) != expected_digest:
raise ResearchFence("SOURCE_CONTEXT_CHANGED")
+ async def widen_memory_question(
+ self, work: ResearchWork, project: Project, model: ModelPort
+ ) -> MemoryExpansionOutcome:
+ """One model call widens the question for memory recall; the legacy analysis step that
+ follows in this run takes the same answer."""
+ return await widen_query(
+ memory=self.memory,
+ expander=ModelMemoryQueryExpander(
+ model,
+ project_id=project.project_id,
+ cutoff_at=project.cutoff_at,
+ model_policy_ref=project.policy_binding_ref,
+ head_set_digest=head_set_digest(
+ self.records.ledger.read_heads(project.project_id)
+ ),
+ ),
+ work=work,
+ project_id=project.project_id,
+ query=work.effective_question,
+ )
+
async def run(
self,
work: ResearchWork,
@@ -263,6 +286,7 @@ async def run(
model: ModelPort,
progress: Progress,
) -> dict[str, object]:
+ expansion = await self.widen_memory_question(work, project, model)
try:
memory_context = self.memory.build_context(
project_id=project.project_id,
@@ -271,6 +295,7 @@ async def run(
target_use="WORKING_CONTEXT",
scope=thread.scope,
cutoff_at=project.cutoff_at,
+ expansion=expansion,
)
work.context["authorized_project_memory"] = memory_context.model_dump(mode="json")
except ValidationError:
@@ -309,7 +334,7 @@ async def run(
)
# Start with admissible connected source candidates; no fake evidence is needed.
all_evidence = self.evidence(project.project_id)
- shortlist = lexical_candidates(work.effective_question, (), all_evidence)
+ shortlist = lexical_candidates(work.effective_question, (), all_evidence, work.pinned_spans)
if shortlist and all(span.cutoff_state == CutoffState.UNKNOWN_TIME for span in shortlist):
work.context["source_time_limitation"] = "SOURCE_TIME_UNCONFIRMED"
assembly = self.assemble(work, tuple(s.span_id for s in shortlist), shortlist, all_evidence)
diff --git a/src/thoth/application/services/research_attempt_runner.py b/src/thoth/application/services/research_attempt_runner.py
index 8da774f..a875fb7 100644
--- a/src/thoth/application/services/research_attempt_runner.py
+++ b/src/thoth/application/services/research_attempt_runner.py
@@ -373,6 +373,9 @@ def _prepare_work(
work.resume_from_operation_id = resume_from
if request.model_settings is not None:
work.context["model_settings"] = request.model_settings.model_dump(mode="json")
+ from thoth.application.services.trace_origin import apply_trace_origin
+
+ apply_trace_origin(work, attempt.continuation, request.project_id)
if attempt.continuation.get("retry_policy") == "ONCE_TRANSIENT_429":
from thoth.domain.oauth_retry import OAuthRetryPolicy, allows_once_transient_429
@@ -393,6 +396,9 @@ def _prepare_work(
):
# The project's own switch (off unless turned on): one cut-off call may be sent once more.
work.auto_retry_interrupted_call = True
+ calls = host.call_settings
+ if calls is not None and calls.hypothesis_contract_v3(request.project_id):
+ work.hypothesis_contract_v3 = True # the project's own switch, off unless turned on
boundary.validate_sources = lambda: host.analysis.require_current_sources(
work.evidence, work.context.get("source_context_digest")
)
diff --git a/src/thoth/application/services/research_boundary.py b/src/thoth/application/services/research_boundary.py
index 1b9e33b..b0e6a72 100644
--- a/src/thoth/application/services/research_boundary.py
+++ b/src/thoth/application/services/research_boundary.py
@@ -14,7 +14,7 @@
ModelReceiveObservation,
)
from thoth.domain.operation import OperationRecord
-from thoth.domain.research_execution import ResearchFence
+from thoth.domain.research_execution import ResearchFence, model_purpose
from thoth.domain.research_lease import ResearchLeaseLost, ResearchPaused
from thoth.domain.research_request import ResearchBudget, ThreadRequestRevision
from thoth.ports.governance import GovernanceStorePort
@@ -170,6 +170,7 @@ def reserve_dispatch(
payload_digest=hashlib.sha256(payload).hexdigest(),
output_reserved=output_tokens,
model_settings=self.request.model_settings,
+ purpose=model_purpose.get(),
)
project = self.request.project_id
with self.records.ledger.transaction():
diff --git a/src/thoth/application/services/research_completion_view.py b/src/thoth/application/services/research_completion_view.py
index d10215b..3d4acc5 100644
--- a/src/thoth/application/services/research_completion_view.py
+++ b/src/thoth/application/services/research_completion_view.py
@@ -1,5 +1,7 @@
"""Compatible answer meaning; operation success remains a technical publication state."""
+from typing import cast
+
from thoth.application.services.request_records import RequestRecords
from thoth.application.services.research_stages import read_stage
from thoth.domain.evidence_requirements import CoverageAssessment
@@ -12,6 +14,28 @@
from thoth.ports.resource_scope import ResourceAccessPort
+def _execution_hold(closed_loop: dict[str, object]) -> dict[str, object]:
+ """What a held closed-loop test tells the reader (R2 replay guard), and the way out of it.
+
+ Only a hold that has an unsettled earlier run carries anything to clear; every other hold
+ has no exits."""
+ pending_value = closed_loop.get("pending_result")
+ pending: dict[str, object] = (
+ cast(dict[str, object], pending_value) if isinstance(pending_value, dict) else {}
+ )
+ exits = pending.get("exits")
+ return {
+ "schema_version": "1.0.0",
+ "reason_code": closed_loop.get("reason_code"),
+ "plan_execution_id": pending.get("plan_execution_id"),
+ "execution_revision": pending.get("execution_revision"),
+ "attempt_id": pending.get("attempt_id"),
+ "attempt_state": pending.get("attempt_state"),
+ "input_difference": pending.get("input_difference"),
+ "exits": list(cast(list[object], exits)) if isinstance(exits, list) else [],
+ }
+
+
def completion_view(
records: RequestRecords,
access: ResourceAccessPort,
@@ -51,6 +75,8 @@ def completion_view(
}
)
result = {} if manifest is None else manifest.result
+ closed_loop = result.get("r2_closed_loop")
+ held_test = isinstance(closed_loop, dict) and closed_loop.get("terminal_state") == "HOLD"
has_answer = isinstance(result.get("answer"), str) and bool(str(result["answer"]).strip())
kind = "NOT_PRODUCED"
if manifest is not None:
@@ -58,6 +84,7 @@ def completion_view(
manifest.phase == "HOLD"
or operation_state == "FAILED"
or result.get("answer_status") == "PARTIAL_HOLD"
+ or held_test
):
kind = "HOLD"
elif not fresh:
@@ -99,7 +126,7 @@ def completion_view(
elif manifest.source_context_version is None:
kind = "LEGACY_UNKNOWN"
terminal = operation_state in {"SUCCEEDED", "FAILED", "CANCELLED"}
- return {
+ view: dict[str, object] = {
"answer_outcome": {
"schema_version": "1.0.0",
"state": kind,
@@ -138,3 +165,6 @@ def completion_view(
),
},
}
+ if held_test and manifest is not None:
+ view["execution_hold"] = _execution_hold(cast(dict[str, object], closed_loop))
+ return view
diff --git a/src/thoth/application/services/research_freshness.py b/src/thoth/application/services/research_freshness.py
index e6805dc..34eb18f 100644
--- a/src/thoth/application/services/research_freshness.py
+++ b/src/thoth/application/services/research_freshness.py
@@ -1,6 +1,8 @@
"""One dependency eligibility calculation for history and live consumers."""
+from thoth.application.services.hypothesis_link_view import HypothesisLinkReader
from thoth.application.services.memory_supersession import superseded_memory_digests
+from thoth.domain.canonical import same_stored_instant
from thoth.domain.enums import ImpactStatus
from thoth.domain.research_basis import BasisCurrentness, ResearchResultBasis
from thoth.ports.artifact_ledger import ArtifactLedgerPort
@@ -82,7 +84,7 @@ def evaluate_result(
if (
project is None
or policy is None
- or project.cutoff_at != basis.cutoff_at
+ or not same_stored_instant(project.cutoff_at, basis.cutoff_at)
or policy.policy_digest != basis.policy_digest
):
issues.append(
@@ -147,6 +149,9 @@ def owner_eligibility(self, project_id: str, owner_digest: str) -> BasisCurrentn
def require_action_eligible(self, project_id: str, key: str, digest: str) -> None:
if self.evaluate_entity(project_id, key, digest).state != "CURRENT":
raise ValueError("DEPENDENCY_REVIEW_REQUIRED")
+ # An action or plan that rests on a hypothesis whose trace verdict has changed is not used
+ # for approval or execution until a person re-checks it (the hypothesis link).
+ HypothesisLinkReader(self.ledger).require_action_hypotheses_current(project_id, key)
def ineligible_owner_refs(self, project_id: str) -> frozenset[str]:
states = self.ledger.read_dependency_states(project_id)
diff --git a/src/thoth/application/services/research_retrieval.py b/src/thoth/application/services/research_retrieval.py
index 0339e39..e1ac195 100644
--- a/src/thoth/application/services/research_retrieval.py
+++ b/src/thoth/application/services/research_retrieval.py
@@ -85,7 +85,10 @@ def text_neighbors(
def lexical_candidates(
- question: str, queries: tuple[str, ...], evidence: tuple[EvidenceSpan, ...]
+ question: str,
+ queries: tuple[str, ...],
+ evidence: tuple[EvidenceSpan, ...],
+ pinned: tuple[str, ...] = (),
) -> tuple[EvidenceSpan, ...]:
policy, started = retrieval_policy(), perf_counter_ns()
terms = {token for token in tokens(" ".join((question, *queries))) if len(token) >= _MIN_TERM}
@@ -100,9 +103,10 @@ def lexical_candidates(
if span.span_id in exact or _term_hits(span.exact_text, terms)
]
# A bounded shortlist, not a claim that unselected source territory was searched.
- selected: list[EvidenceSpan] = []
- size = 0
- for span in _interleave_artifacts(ranked):
+ # A pinned span is kept first when it is retrievable at all; it never widens what is eligible.
+ selected = [span for span in eligible if span.span_id in pinned][: policy.max_spans]
+ size = sum(len(span.exact_text) for span in selected)
+ for span in _interleave_artifacts([span for span in ranked if span.span_id not in pinned]):
if (
len(selected) >= policy.max_spans
or size + len(span.exact_text) > policy.character_budget
@@ -117,7 +121,7 @@ def lexical_candidates(
result,
started,
)
- replay_retrieval_shadows(lambda: lexical_candidates(question, queries, evidence))
+ replay_retrieval_shadows(lambda: lexical_candidates(question, queries, evidence, pinned))
return result
diff --git a/src/thoth/application/services/research_usage.py b/src/thoth/application/services/research_usage.py
index eb6bdc3..be9cebf 100644
--- a/src/thoth/application/services/research_usage.py
+++ b/src/thoth/application/services/research_usage.py
@@ -6,6 +6,10 @@
from pydantic import JsonValue, TypeAdapter
+from thoth.domain.memory_expansion import (
+ MEMORY_QUERY_EXPANSION_LABEL,
+ MEMORY_QUERY_EXPANSION_PURPOSE,
+)
from thoth.domain.model_dispatch import ModelDispatchRecord
_JSON_OBJECT = TypeAdapter(dict[str, JsonValue])
@@ -117,7 +121,7 @@ def summarize_operation_usage(
inputs, outputs, cached, complete = _observed(dispatches.values())
unreported = len(dispatches) - complete
known = bool(inputs or outputs)
- return {
+ entry: dict[str, JsonValue] = {
"operation_id": operation_id,
"calls": len(dispatches),
"auto_retries": _interrupted_retries(dispatches),
@@ -129,6 +133,30 @@ def summarize_operation_usage(
"state": "UNKNOWN" if not known else "PARTIAL" if unreported else "OBSERVED",
"wall_ms": wall_ms,
}
+ expansion = _purpose_usage(dispatches.values(), MEMORY_QUERY_EXPANSION_PURPOSE)
+ if expansion is not None:
+ # Already part of the totals above; shown apart so its share is visible.
+ entry["by_purpose"] = {
+ MEMORY_QUERY_EXPANSION_PURPOSE: {"label": MEMORY_QUERY_EXPANSION_LABEL, **expansion}
+ }
+ return entry
+
+
+def _purpose_usage(
+ dispatches: Iterable[ModelDispatchRecord], purpose: str
+) -> dict[str, JsonValue] | None:
+ items = tuple(r for r in dispatches if r.purpose == purpose)
+ if not items:
+ return None
+ inputs, outputs, _cached, complete = _observed(items)
+ known = bool(inputs or outputs)
+ return {
+ "calls": len(items),
+ "input_tokens": sum(inputs) if inputs else None,
+ "output_tokens": sum(outputs) if outputs else None,
+ "total_tokens": sum(inputs) + sum(outputs) if known else None,
+ "unreported_calls": len(items) - complete,
+ }
def usage_label(usage: object) -> str:
diff --git a/src/thoth/application/services/restore_planner.py b/src/thoth/application/services/restore_planner.py
index 5b2404e..47327da 100644
--- a/src/thoth/application/services/restore_planner.py
+++ b/src/thoth/application/services/restore_planner.py
@@ -10,7 +10,7 @@
from thoth.application.services.restore_source_basis import content_origin
from thoth.application.services.revision_diff import semantic_diff
from thoth.domain.auth import current_authenticated_actor
-from thoth.domain.canonical import canonical_payload, domain_digest
+from thoth.domain.canonical import canonical_payload, domain_digest, same_stored_instant
from thoth.domain.research_history import HistoryCapability
from thoth.domain.resource_scope import ResourceScopeError
from thoth.domain.restore import RestoreBasis, RestoreError, RestorePreview, RestoreSelection
@@ -149,7 +149,7 @@ def build(self, selection: RestoreSelection, *, require_write: bool = False) ->
if str(project_record.lifecycle) in {"ARCHIVED", "ARCHIVED_READ_ONLY"}:
raise RestoreError("RESTORE_PROTECTED_STATE")
cutoff = record.model_dump(mode="python").get("cutoff_at")
- if cutoff is not None and cutoff != project_record.cutoff_at:
+ if cutoff is not None and not same_stored_instant(cutoff, project_record.cutoff_at):
raise RestoreError("RESTORE_SOURCE_DRIFT")
source_refs = profile.evidence_refs(record)
frozen_sources = self.sources.resolve(project, origin.revision_digest, source_refs)
diff --git a/src/thoth/application/services/restore_references.py b/src/thoth/application/services/restore_references.py
index c326ac7..19f9710 100644
--- a/src/thoth/application/services/restore_references.py
+++ b/src/thoth/application/services/restore_references.py
@@ -4,6 +4,7 @@
from thoth.domain.action_full import ActionPlanRecord
from thoth.domain.base import DomainModel
+from thoth.domain.canonical import same_stored_instant
from thoth.domain.hypothesis_full import (
HypothesisPortfolioRecord,
HypothesisRecord,
@@ -118,7 +119,7 @@ def hypothesis(self, target: SemanticRevision, value: DomainModel) -> RestoreRef
or prediction.hypothesis_semantic_digest != semantic
):
raise RestoreError("RESTORE_MEMBERS_REQUIRE_REVIEW")
- if prediction.knowledge_cutoff != project.cutoff_at or not set(
+ if not same_stored_instant(prediction.knowledge_cutoff, project.cutoff_at) or not set(
prediction.assumption_refs
).issubset(value.assumption_refs):
raise RestoreError("RESTORE_MEMBERS_REQUIRE_REVIEW")
@@ -163,7 +164,7 @@ def hypothesis(self, target: SemanticRevision, value: DomainModel) -> RestoreRef
or binding.prediction_fit != assessment.prediction_fit
):
raise RestoreError("RESTORE_TARGET_MISMATCH")
- if assessment.knowledge_cutoff != project.cutoff_at:
+ if not same_stored_instant(assessment.knowledge_cutoff, project.cutoff_at):
raise RestoreError("RESTORE_SOURCE_DRIFT")
plan = self.ledger.read_revision_by_digest(
target.project_id, assessment.plan_revision_digest
diff --git a/src/thoth/application/services/sandbox_service.py b/src/thoth/application/services/sandbox_service.py
index 05f0caf..df61697 100644
--- a/src/thoth/application/services/sandbox_service.py
+++ b/src/thoth/application/services/sandbox_service.py
@@ -11,7 +11,12 @@
from thoth.application.services.policy_gate import PolicyDenied, PolicyGate
from thoth.application.services.resource_scope_context import resource_stage_scope
from thoth.domain.artifact import ArtifactEnvelope
-from thoth.domain.canonical import canonical_payload, domain_digest, head_set_digest
+from thoth.domain.canonical import (
+ canonical_payload,
+ domain_digest,
+ head_set_digest,
+ same_stored_instant,
+)
from thoth.domain.enums import AuthorityState, CutoffState, SecurityClass
from thoth.domain.ingestion import IngestionResult
from thoth.domain.policy import (
@@ -297,13 +302,16 @@ def _validate_admission_basis(
if (
project is None
or project.revision != basis.project_revision
- or project.cutoff_at != basis.cutoff_at
+ or not same_stored_instant(project.cutoff_at, basis.cutoff_at)
or project.policy_binding_ref != basis.policy_id
or (
spec.project_revision is not None
and spec.project_revision != basis.project_revision
)
- or (spec.cutoff_at is not None and spec.cutoff_at != basis.cutoff_at)
+ or (
+ spec.cutoff_at is not None
+ and not same_stored_instant(spec.cutoff_at, basis.cutoff_at)
+ )
or (
spec.current_head_set_digest is not None
and spec.current_head_set_digest != basis.head_set_digest
@@ -421,7 +429,8 @@ def preflight(self, spec: SandboxRunSpec) -> None:
if project is None:
raise SandboxFailure(SandboxErrorCode.INPUT_INVALID, "sandbox project does not exist")
if (spec.project_revision is not None and spec.project_revision != project.revision) or (
- spec.cutoff_at is not None and spec.cutoff_at != project.cutoff_at
+ spec.cutoff_at is not None
+ and not same_stored_instant(spec.cutoff_at, project.cutoff_at)
):
raise SandboxFailure(SandboxErrorCode.INPUT_INVALID, "sandbox project/cutoff is stale")
if project.policy_binding_ref != policy.policy_id:
diff --git a/src/thoth/application/services/trace_closure.py b/src/thoth/application/services/trace_closure.py
new file mode 100644
index 0000000..bf1a36e
--- /dev/null
+++ b/src/thoth/application/services/trace_closure.py
@@ -0,0 +1,167 @@
+"""A person's closure of a trace row that was not met, and the one effect the rules confirm.
+
+A closure is a decision made outside the system that a person records with the document it rests
+on. It is an event added to one project record; the verdict of the row is never touched, so the
+rule's own word and its history stay. THOTH grants no waiver: it only writes down that somebody did.
+"""
+
+from __future__ import annotations
+
+from thoth.application.services.human_actor import require_human_actor
+from thoth.application.services.hypothesis_link_view import HypothesisLinkReader
+from thoth.application.services.lesson_ledger import LessonLedger
+from thoth.application.services.request_records import RequestRecords
+from thoth.application.services.verification_trace import REVISION_PREFIX
+from thoth.domain.enums import EntityType
+from thoth.domain.trace_closure import (
+ ClosureEvent,
+ ClosureKind,
+ TraceClosureRecord,
+ effect_confirmed_by,
+ is_met,
+)
+from thoth.domain.verification_trace import (
+ SubjectKind,
+ VerdictRevision,
+ VerificationTraceRecord,
+ VerificationVerdictRecord,
+ subject_key,
+)
+
+KEY = "trace-closures"
+CLOSURE_BASIS_REQUIRED = "CLOSURE_BASIS_REQUIRED"
+CLOSURE_SCOPE_REQUIRED = "CLOSURE_SCOPE_REQUIRED"
+CLOSURE_ROW_NOT_FOUND = "CLOSURE_ROW_NOT_FOUND"
+CLOSURE_ROW_ALREADY_MET = "CLOSURE_ROW_ALREADY_MET"
+CLOSURE_VERDICT_CHANGED_AGAIN = "CLOSURE_VERDICT_CHANGED_AGAIN"
+
+
+class ClosureRefused(ValueError):
+ """The closure is not recorded; the message is the reason code."""
+
+
+class TraceClosures:
+ def __init__(self, records: RequestRecords) -> None:
+ self._records = records
+ self._reader = HypothesisLinkReader(records.ledger)
+ self._lessons = LessonLedger(records)
+
+ def read(self, project_id: str) -> TraceClosureRecord:
+ content = self._reader.content(project_id, EntityType.THREAD, KEY)
+ if content is None:
+ return TraceClosureRecord(project_id=project_id)
+ return TraceClosureRecord.model_validate(content)
+
+ def record(
+ self,
+ *,
+ project_id: str,
+ subject_kind: SubjectKind,
+ subject_id: str,
+ kind: ClosureKind,
+ basis_ref: str,
+ note: str,
+ scope: str | None,
+ current_verdict_revision: str,
+ actor_id: str,
+ ) -> ClosureEvent:
+ require_human_actor(actor_id)
+ if not basis_ref.strip():
+ raise ClosureRefused(CLOSURE_BASIS_REQUIRED)
+ if kind == "CONDITION_CHANGED" and not (scope or "").strip():
+ raise ClosureRefused(CLOSURE_SCOPE_REQUIRED)
+ with self._records.ledger.transaction():
+ _, trace = self._reader.trace(project_id)
+ shown = self._reader.current_verdicts(project_id, trace)
+ current = shown.get((subject_kind, subject_id))
+ if current is None:
+ raise ClosureRefused(CLOSURE_ROW_NOT_FOUND)
+ if current.verdict_revision != current_verdict_revision:
+ raise ClosureRefused(CLOSURE_VERDICT_CHANGED_AGAIN)
+ if is_met(current.state):
+ raise ClosureRefused(CLOSURE_ROW_ALREADY_MET)
+ event = ClosureEvent(
+ event_id=self._records.ids.new("trace-closure"),
+ subject_kind=subject_kind.value, # type: ignore[arg-type]
+ subject_id=subject_id,
+ kind=kind,
+ basis_ref=basis_ref.strip(),
+ note=note.strip(),
+ scope=(scope or "").strip() or None if kind == "CONDITION_CHANGED" else None,
+ verdict_revision=current.verdict_revision,
+ verdict_digest=current.verdict_digest,
+ verdict_state=current.state,
+ actor_id=actor_id,
+ created_at=self._records.clock.now(),
+ )
+ earlier = self.read(project_id)
+ self._records.save(
+ project_id,
+ EntityType.THREAD,
+ KEY,
+ earlier.model_copy(update={"events": (*earlier.events, event)}),
+ actor_id,
+ )
+ self._lessons.after_closure(project_id, event, actor_id)
+ return event
+
+ def _revision(self, project_id: str, digest: str) -> VerdictRevision | None:
+ content = self._reader.content(project_id, EntityType.THREAD, REVISION_PREFIX + digest)
+ return (
+ None if content is None else VerificationVerdictRecord.model_validate(content).revision
+ )
+
+ def _confirmed(
+ self, project_id: str, trace: VerificationTraceRecord, fix: ClosureEvent
+ ) -> VerdictRevision | None:
+ """The later verdict that confirms the fix, from the row's own history after the fix."""
+ history = trace.history.get(subject_key(SubjectKind(fix.subject_kind), fix.subject_id), ())
+ if fix.verdict_revision not in history:
+ return None
+ at = history.index(fix.verdict_revision)
+ before = self._revision(project_id, history[at])
+ later = [self._revision(project_id, digest) for digest in history[at + 1 :]]
+ if before is None:
+ return None
+ return effect_confirmed_by(before, [item for item in later if item is not None])
+
+ def view(self, project_id: str, *, with_current: bool = True) -> list[dict[str, object]]:
+ """Each row with a closure: what was recorded, and whether the rules confirm the effect.
+
+ `with_current` reads where each row stands now (its state and whether it moved since the
+ record). A reader that needs only the records and the confirmed effect (the CSV note) can
+ leave it off and skip reading every row's verdict.
+ """
+ _, trace = self._reader.trace(project_id)
+ if trace is None:
+ return []
+ rows: dict[tuple[str, str], list[ClosureEvent]] = {}
+ for event in self.read(project_id).events:
+ rows.setdefault((event.subject_kind, event.subject_id), []).append(event)
+ shown = (
+ self._reader.current_verdicts(
+ project_id, trace, {(SubjectKind(kind), subject) for kind, subject in rows}
+ )
+ if with_current
+ else {}
+ )
+ found: list[dict[str, object]] = []
+ for (kind, subject_id), events in rows.items():
+ current = shown.get((SubjectKind(kind), subject_id))
+ fixes = [item for item in events if item.kind == "FIX_APPLIED"]
+ confirmed = self._confirmed(project_id, trace, fixes[-1]) if fixes else None
+ found.append(
+ {
+ "subject_kind": kind,
+ "subject_id": subject_id,
+ "current_state": None if current is None else current.state,
+ "effect_confirmed": confirmed is not None,
+ "effect_verdict_revision": None
+ if confirmed is None
+ else confirmed.revision_digest,
+ "verdict_changed_since": current is None
+ or current.verdict_digest != events[-1].verdict_digest,
+ "events": [item.model_dump(mode="json") for item in events],
+ }
+ )
+ return found
diff --git a/src/thoth/application/services/trace_closure_text.py b/src/thoth/application/services/trace_closure_text.py
new file mode 100644
index 0000000..e511a92
--- /dev/null
+++ b/src/thoth/application/services/trace_closure_text.py
@@ -0,0 +1,79 @@
+"""The closure_status cell of the trace CSV: one line of plain words for each closed row.
+
+The cell is a note for a person reading the file in a spreadsheet. It is built from the project's
+recorded closures and is never read back by an import. Only the latest record is written, and the
+number of earlier ones is counted; "effect confirmed" is said only when the rules confirmed it.
+The kind names are the ones the screen uses for the same records.
+
+The file carries the note in an optional column, closure_status, that is added only when some
+requirement or criterion row has a note. The reader knows the column and never takes a value from
+it, so editing it changes nothing.
+"""
+
+from __future__ import annotations
+
+import re
+from collections.abc import Iterable, Mapping
+from typing import cast
+
+_KIND_TEXT = {
+ "FIX_APPLIED": "수정 반영됨(효과 미확인)",
+ "HUMAN_CLOSED": "사람 확인 종결(시험 결과 없음)",
+ "WAIVER_RECORDED": "편차·면제 승인(원 기준 미충족 유지)",
+}
+_EFFECT_CONFIRMED = "효과 확인됨"
+_DAY = re.compile(r"^\d{4}-\d{2}-\d{2}")
+
+CLOSURE_COLUMN = "closure_status"
+ClosureNotes = Mapping[str, str] # item id -> the note of that row
+_CLOSABLE = ("REQUIREMENT", "CRITERION") # the item kinds a verdict, and so a closure, belongs to
+
+
+def _label(event: Mapping[str, object], confirmed: bool) -> str:
+ if confirmed:
+ return _EFFECT_CONFIRMED
+ kind = str(event.get("kind", ""))
+ if kind == "CONDITION_CHANGED":
+ return f"운용 조건 변경(조건 범위: {event.get('scope') or '미기재'})"
+ return _KIND_TEXT.get(kind, "처분 기록됨")
+
+
+def closure_status_text(row: Mapping[str, object]) -> str:
+ """The note for one row of TraceClosures.view: its latest record, and how many came before."""
+ events = [
+ cast("Mapping[str, object]", item)
+ for item in cast("list[object]", row.get("events") or [])
+ if isinstance(item, Mapping)
+ ]
+ latest = events[-1]
+ day = _DAY.match(str(latest.get("created_at", "")))
+ parts = [
+ _label(latest, row.get("effect_confirmed") is True),
+ f"근거 문서: {latest.get('basis_ref', '')}",
+ *([f"기록 날짜: {day.group(0)}"] if day else []),
+ *([f"이전 기록 {len(events) - 1}건(THOTH에서 확인)"] if len(events) > 1 else []),
+ ]
+ return " · ".join(parts)
+
+
+def with_closure_notes(
+ rows: list[dict[str, str]], closures: ClosureNotes | None
+) -> list[dict[str, str]]:
+ """The export rows with each note written on the requirement or criterion row it belongs to."""
+ if not closures:
+ return rows
+ return [
+ {**row, CLOSURE_COLUMN: closures[row["item_id"]]}
+ if row["row_type"] == "ITEM" and row["kind"] in _CLOSABLE and row["item_id"] in closures
+ else row
+ for row in rows
+ ]
+
+
+def closure_status_cells(rows: Iterable[Mapping[str, object]]) -> dict[str, str]:
+ """The notes by the row they belong to (a closure's subject id is that row's item id)."""
+ return {
+ str(row["subject_id"]): closure_status_text(row)
+ for row in rows
+ if row.get("events") and row.get("subject_id")
+ }
diff --git a/src/thoth/application/services/trace_csv.py b/src/thoth/application/services/trace_csv.py
new file mode 100644
index 0000000..f7527d8
--- /dev/null
+++ b/src/thoth/application/services/trace_csv.py
@@ -0,0 +1,599 @@
+"""thoth-trace-csv/1: the trace as one CSV file a spreadsheet can open, and the way back.
+
+One file holds rows of five kinds (ITEM, LINK, RULE, RESULT, DELETE). Every row has every column;
+the order of the columns and of the rows does not matter, blank lines are skipped, and a row is
+found by its id, never by its position. Cells that a spreadsheet would run as a formula (they begin
+with = + - @ tab or CR) are written with a leading apostrophe and read back by removing exactly
+that one apostrophe.
+
+One column is optional: synthetic_notice. A set whose items carry the notice field (invented demo
+data) is exported with that column filled on every row, so the mark is seen in a spreadsheet and
+comes back on import; a file without the column is still a complete file.
+
+This module only turns a trace set into text and text into checked rows. Deciding what an import
+changes is in verification_trace_import.
+"""
+
+from __future__ import annotations
+
+import csv
+import hashlib
+import io
+import json
+import re
+from collections.abc import Callable, Iterable, Mapping
+from dataclasses import dataclass, field
+from datetime import datetime
+from decimal import Decimal, InvalidOperation, localcontext
+from typing import Any, cast
+
+from pydantic import BaseModel, ValidationError
+
+from thoth.application.services.trace_closure_text import CLOSURE_COLUMN, with_closure_notes
+from thoth.domain.canonical import normalize_timestamp
+from thoth.domain.verification_trace import (
+ Comparator,
+ CriterionRule,
+ ResultRecord,
+ Rounding,
+ TraceItem,
+ TraceKind,
+ TraceLink,
+ TraceRelation,
+ TraceSet,
+)
+
+FORMAT = "thoth-trace-csv/1"
+BOM = "\ufeff"
+MAX_CSV_CHARS = 5_000_000
+MAX_ROWS = 20_000
+
+COLUMNS: tuple[str, ...] = (
+ "format",
+ "row_type",
+ "base_set_digest",
+ "item_id",
+ "link_id",
+ "rule_id",
+ "result_id",
+ "target_kind",
+ "target_id",
+ "item_key",
+ "kind",
+ "title",
+ "fields_json",
+ "source_span_refs",
+ "from_id",
+ "to_id",
+ "relation",
+ "criterion_id",
+ "rule_revision",
+ "measure",
+ "comparator",
+ "threshold",
+ "unit",
+ "condition",
+ "rounding_digits",
+ "rounding_mode",
+ "required",
+ "result_revision",
+ "value",
+ "raw_value",
+ "numerator",
+ "denominator",
+ "observed_at",
+)
+ROW_TYPES = ("ITEM", "LINK", "RULE", "RESULT", "DELETE")
+DELETE_KINDS = ("ITEM", "LINK", "RULE", "RESULT")
+NOTICE_COLUMN = "synthetic_notice"
+NOTICE_FIELD = "synthetic_notice" # the item field the column stands for
+
+# What the file cannot carry. Exported with every file so nobody assumes the CSV is the whole trace.
+LOSS_MANIFEST: tuple[str, ...] = (
+ "evidence_text: only the positions of evidence (source_span_refs) are kept, not the text",
+ "verdict_history: computed verdicts and their revisions are recomputed after an import",
+ "confirmations: who confirmed which verdict stays in THOTH and is not in the file",
+ "selection_policy: the policy that picks among results stays as set in THOTH",
+ "pending_changes: which verdicts are out of date is worked out again by THOTH",
+ "closures: closure_status is for reading only; editing it changes nothing. "
+ "Record closures in THOTH.",
+)
+
+_ESCAPE_NEEDED = re.compile(r"^'*[=+\-@\t\r]")
+_ESCAPED = re.compile(r"^'+[=+\-@\t\r]")
+
+
+@dataclass(frozen=True)
+class Issue:
+ code: str
+ row: int | None = None
+ ref: str | None = None
+ detail: str = ""
+
+ def as_json(self) -> dict[str, Any]:
+ return {"code": self.code, "row": self.row, "ref": self.ref, "detail": self.detail}
+
+
+@dataclass(frozen=True)
+class ParsedRow:
+ row: int # the line of the file, only for messages; it is never an id
+ row_type: str
+ key: str
+ model: BaseModel | None = None
+ target_kind: str | None = None
+ key_given: bool = True # an item row may leave its internal key blank
+
+
+@dataclass
+class ParsedCsv:
+ rows: list[ParsedRow] = field(default_factory=lambda: list[ParsedRow]())
+ issues: list[Issue] = field(default_factory=lambda: list[Issue]())
+ base_digests: tuple[str, ...] = ()
+ ignored_columns: tuple[str, ...] = ()
+ reference_ids: set[str] = field(default_factory=lambda: set[str]())
+
+
+def input_sha256(text: str) -> str:
+ """Hash of the exact text that was sent, byte order mark included."""
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
+
+
+def escape_cell(text: str) -> str:
+ return "'" + text if _ESCAPE_NEEDED.match(text) else text
+
+
+def unescape_cell(text: str) -> str:
+ return text[1:] if _ESCAPED.match(text) else text
+
+
+# --- export --------------------------------------------------------------------------------------
+
+
+def _bool(value: bool) -> str:
+ return "true" if value else "false"
+
+
+def _opt(value: object) -> str:
+ return "" if value is None else str(value)
+
+
+def _decimal(value: Decimal | None) -> str:
+ return "" if value is None else format(value, "f")
+
+
+def _json(value: object) -> str:
+ return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
+
+
+_KIND_ORDER = {kind: number for number, kind in enumerate(TraceKind)}
+
+
+def export_rows(trace_set: TraceSet) -> list[dict[str, str]]:
+ base = {"format": FORMAT, "base_set_digest": trace_set.set_digest}
+ notice = next(
+ (
+ item.fields[NOTICE_FIELD]
+ for item in sorted(trace_set.items, key=lambda i: i.item_id)
+ if item.fields.get(NOTICE_FIELD)
+ ),
+ "",
+ )
+ if notice:
+ base[NOTICE_COLUMN] = notice # every row of a synthetic set carries the mark
+ rows: list[dict[str, str]] = []
+ for item in sorted(trace_set.items, key=lambda i: (_KIND_ORDER[i.kind], i.item_id)):
+ shown = {name: text for name, text in item.fields.items() if name != NOTICE_FIELD}
+ rows.append(
+ {
+ **base,
+ "row_type": "ITEM",
+ "item_id": item.item_id,
+ "item_key": item.item_key,
+ "kind": item.kind.value,
+ "title": item.title,
+ "fields_json": _json(shown) if shown else "",
+ "source_span_refs": _json(list(item.source_span_refs))
+ if item.source_span_refs
+ else "",
+ **({NOTICE_COLUMN: item.fields.get(NOTICE_FIELD, "")} if notice else {}),
+ }
+ )
+ for link in sorted(trace_set.links, key=lambda i: i.link_id):
+ rows.append(
+ {
+ **base,
+ "row_type": "LINK",
+ "link_id": link.link_id,
+ "from_id": link.from_id,
+ "to_id": link.to_id,
+ "relation": link.relation.value,
+ }
+ )
+ for rule in sorted(trace_set.rules, key=lambda i: i.rule_id):
+ rows.append(
+ {
+ **base,
+ "row_type": "RULE",
+ "rule_id": rule.rule_id,
+ "rule_revision": str(rule.rule_revision),
+ "criterion_id": rule.criterion_id,
+ "measure": rule.measure,
+ "comparator": rule.comparator.value,
+ "threshold": _decimal(rule.threshold),
+ "unit": rule.unit,
+ "condition": rule.condition,
+ "rounding_digits": str(rule.rounding.digits),
+ "rounding_mode": rule.rounding.mode,
+ "required": _bool(rule.required),
+ }
+ )
+ for result in sorted(trace_set.results, key=lambda i: i.result_id):
+ rows.append(
+ {
+ **base,
+ "row_type": "RESULT",
+ "result_id": result.result_id,
+ "result_revision": str(result.result_revision),
+ "criterion_id": result.criterion_id,
+ "condition": result.condition,
+ "value": _decimal(result.value),
+ "raw_value": _opt(result.raw_value),
+ "unit": result.unit,
+ "numerator": _opt(result.numerator),
+ "denominator": _opt(result.denominator),
+ "observed_at": normalize_timestamp(result.observed_at),
+ "source_span_refs": _json(list(result.source_span_refs))
+ if result.source_span_refs
+ else "",
+ }
+ )
+ return rows
+
+
+def write_csv(rows: Iterable[dict[str, str]]) -> str:
+ """Rows as text with a byte order mark (so a spreadsheet reads the Korean text correctly)."""
+ rows = list(rows)
+ columns = (*COLUMNS, *(n for n in (NOTICE_COLUMN, CLOSURE_COLUMN) if any(n in r for r in rows)))
+ buffer = io.StringIO()
+ writer = csv.writer(buffer, lineterminator="\r\n")
+ writer.writerow(columns)
+ for row in rows:
+ writer.writerow([escape_cell(row.get(name, "")) for name in columns])
+ return BOM + buffer.getvalue()
+
+
+def export_csv(trace_set: TraceSet, closures: Mapping[str, str] | None = None) -> str:
+ return write_csv(with_closure_notes(export_rows(trace_set), closures))
+
+
+# --- parse ---------------------------------------------------------------------------------------
+
+
+def _guard(issues: list[Issue], row: int, ref: str, build: Callable[[], Any]) -> Any:
+ try:
+ return build()
+ except (ValueError, ValidationError, InvalidOperation, KeyError, TypeError) as exc:
+ message = str(exc).splitlines()
+ issues.append(Issue("ROW_INVALID", row, ref, (message[-1] if message else "")[:200]))
+ return None
+
+
+def _decimal_in(text: str) -> Decimal:
+ value = Decimal(text.strip())
+ if not value.is_finite():
+ raise ValueError("number must be finite")
+ return value
+
+
+def _int_or_none(text: str) -> int | None:
+ return None if not text.strip() else int(text.strip())
+
+
+def _span_refs(text: str) -> tuple[str, ...]:
+ if not text.strip():
+ return ()
+ loaded: object = json.loads(text)
+ if not isinstance(loaded, list):
+ raise ValueError("source_span_refs must be a JSON list of text")
+ parts = cast(list[object], loaded)
+ if not all(isinstance(part, str) for part in parts):
+ raise ValueError("source_span_refs must be a JSON list of text")
+ return tuple(cast(list[str], parts))
+
+
+def _fields(text: str) -> dict[str, str]:
+ if not text.strip():
+ return {}
+ loaded: object = json.loads(text)
+ if not isinstance(loaded, dict):
+ raise ValueError("fields_json must be a JSON object of text")
+ pairs = cast(dict[object, object], loaded)
+ if not all(isinstance(key, str) and isinstance(item, str) for key, item in pairs.items()):
+ raise ValueError("fields_json must be a JSON object of text")
+ return cast(dict[str, str], pairs)
+
+
+def _generated_key(item_id: str) -> str:
+ return "csv-" + hashlib.sha256(item_id.encode("utf-8")).hexdigest()[:16]
+
+
+def _item(cells: dict[str, str]) -> TraceItem:
+ fields = _fields(cells["fields_json"])
+ if cells.get(NOTICE_COLUMN, "").strip():
+ fields = {**fields, NOTICE_FIELD: cells[NOTICE_COLUMN]}
+ return TraceItem(
+ item_id=cells["item_id"],
+ item_key=cells["item_key"] or _generated_key(cells["item_id"]),
+ kind=TraceKind(cells["kind"].strip()),
+ title=cells["title"],
+ fields=fields,
+ source_span_refs=_span_refs(cells["source_span_refs"]),
+ )
+
+
+def _link(cells: dict[str, str]) -> TraceLink:
+ return TraceLink(
+ link_id=cells["link_id"],
+ from_id=cells["from_id"],
+ to_id=cells["to_id"],
+ relation=TraceRelation(cells["relation"].strip()),
+ )
+
+
+def _flag(text: str) -> bool:
+ lowered = text.strip().lower()
+ if lowered not in {"true", "false"}:
+ raise ValueError("required must be true or false")
+ return lowered == "true"
+
+
+def _rule(cells: dict[str, str]) -> CriterionRule:
+ return CriterionRule(
+ rule_id=cells["rule_id"],
+ rule_revision=int(cells["rule_revision"].strip() or "1"),
+ criterion_id=cells["criterion_id"],
+ measure=cells["measure"],
+ comparator=Comparator(cells["comparator"].strip()),
+ threshold=_decimal_in(cells["threshold"]),
+ unit=cells["unit"],
+ condition=cells["condition"],
+ rounding=Rounding(
+ digits=int(cells["rounding_digits"].strip() or "2"),
+ mode=cells["rounding_mode"].strip() or "HALF_UP", # type: ignore[arg-type]
+ ),
+ required=_flag(cells["required"]),
+ )
+
+
+def _result(cells: dict[str, str]) -> ResultRecord:
+ text = cells["value"]
+ value: Decimal | None = None
+ raw = cells["raw_value"] or None
+ if text.strip():
+ try:
+ value = _decimal_in(text)
+ except (InvalidOperation, ValueError):
+ raw = raw or text # not a number: keep what was written so the verdict can say so
+ return ResultRecord(
+ result_id=cells["result_id"],
+ result_revision=int(cells["result_revision"].strip() or "1"),
+ criterion_id=cells["criterion_id"],
+ condition=cells["condition"],
+ value=value,
+ raw_value=raw,
+ unit=cells["unit"],
+ numerator=_int_or_none(cells["numerator"]),
+ denominator=_int_or_none(cells["denominator"]),
+ observed_at=datetime.fromisoformat(cells["observed_at"].strip()),
+ source_span_refs=_span_refs(cells["source_span_refs"]),
+ )
+
+
+_BUILDERS: dict[str, tuple[str, Callable[[dict[str, str]], BaseModel]]] = {
+ "ITEM": ("item_id", _item),
+ "LINK": ("link_id", _link),
+ "RULE": ("rule_id", _rule),
+ "RESULT": ("result_id", _result),
+}
+
+
+def _reference_ids(row_type: str, model: BaseModel) -> Iterable[str]:
+ if isinstance(model, TraceLink):
+ return (model.from_id, model.to_id)
+ if isinstance(model, (CriterionRule, ResultRecord)):
+ return (model.criterion_id,)
+ return ()
+
+
+def parse_csv(text: str) -> ParsedCsv:
+ """Read the file into checked rows. Problems are collected, nothing is raised for bad rows."""
+ parsed = ParsedCsv()
+ if len(text) > MAX_CSV_CHARS:
+ parsed.issues.append(
+ Issue("FILE_TOO_LARGE", detail=f"more than {MAX_CSV_CHARS} characters")
+ )
+ return parsed
+ body = text[1:] if text.startswith(BOM) else text
+ try:
+ table = list(csv.reader(io.StringIO(body, newline="")))
+ except csv.Error as exc:
+ parsed.issues.append(Issue("CSV_UNREADABLE", detail=str(exc)[:200]))
+ return parsed
+ header_at = next((n for n, line in enumerate(table) if any(c.strip() for c in line)), None)
+ if header_at is None:
+ parsed.issues.append(Issue("CSV_EMPTY", detail="the file has no header row"))
+ return parsed
+ names = [name.strip() for name in table[header_at]]
+ if len(set(names)) != len(names):
+ parsed.issues.append(Issue("HEADER_DUPLICATE_COLUMN", row=header_at + 1))
+ return parsed
+ missing = [name for name in COLUMNS if name not in names]
+ if missing:
+ parsed.issues.append(
+ Issue("HEADER_MISSING_COLUMNS", row=header_at + 1, detail=", ".join(missing))
+ )
+ return parsed
+ parsed.ignored_columns = tuple(
+ name for name in names if name not in {*COLUMNS, NOTICE_COLUMN, CLOSURE_COLUMN}
+ )
+ digests: set[str] = set()
+ seen: dict[tuple[str, str], int] = {}
+ count = 0
+ for number, line in enumerate(table[header_at + 1 :], start=header_at + 2):
+ if not any(cell.strip() for cell in line):
+ continue
+ count += 1
+ if count > MAX_ROWS:
+ parsed.issues.append(
+ Issue("TOO_MANY_ROWS", row=number, detail=f"more than {MAX_ROWS} rows")
+ )
+ break
+ padded = [*line, *([""] * (len(names) - len(line)))]
+ cells = {name: unescape_cell(padded[at]) for at, name in enumerate(names)}
+ if cells["format"].strip() != FORMAT:
+ parsed.issues.append(
+ Issue("FORMAT_UNSUPPORTED", number, detail=cells["format"].strip()[:40])
+ )
+ continue
+ if cells["base_set_digest"].strip():
+ digests.add(cells["base_set_digest"].strip())
+ row_type = cells["row_type"].strip()
+ if row_type == "DELETE":
+ row = _delete_row(parsed, number, cells)
+ elif row_type in _BUILDERS:
+ row = _upsert_row(parsed, number, row_type, cells)
+ else:
+ parsed.issues.append(Issue("ROW_TYPE_UNKNOWN", number, detail=row_type[:40]))
+ continue
+ if row is None:
+ continue
+ marker = (
+ row.row_type if row.row_type != "DELETE" else "DELETE:" + str(row.target_kind),
+ row.key,
+ )
+ if marker in seen:
+ parsed.issues.append(
+ Issue("DUPLICATE_ID_IN_FILE", number, row.key, f"also on line {seen[marker]}")
+ )
+ continue
+ seen[marker] = number
+ parsed.rows.append(row)
+ parsed.base_digests = tuple(sorted(digests))
+ return parsed
+
+
+def _delete_row(parsed: ParsedCsv, number: int, cells: dict[str, str]) -> ParsedRow | None:
+ kind, target = cells["target_kind"].strip(), cells["target_id"]
+ if kind not in DELETE_KINDS or not target:
+ parsed.issues.append(
+ Issue(
+ "DELETE_ROW_INCOMPLETE",
+ number,
+ target or None,
+ "target_kind and target_id are required",
+ )
+ )
+ return None
+ return ParsedRow(row=number, row_type="DELETE", key=target, target_kind=kind)
+
+
+def _upsert_row(
+ parsed: ParsedCsv, number: int, row_type: str, cells: dict[str, str]
+) -> ParsedRow | None:
+ key_column, build = _BUILDERS[row_type]
+ key = cells[key_column]
+ if not key:
+ parsed.issues.append(Issue("ID_MISSING", number, None, f"{key_column} is empty"))
+ return None
+ model = _guard(parsed.issues, number, key, lambda: build(cells))
+ if model is None:
+ return None
+ parsed.reference_ids.update(_reference_ids(row_type, model))
+ return ParsedRow(
+ row=number,
+ row_type=row_type,
+ key=key,
+ model=model,
+ key_given=row_type != "ITEM" or bool(cells["item_key"]),
+ )
+
+
+# --- spreadsheet damage to ids -------------------------------------------------------------------
+
+_MONTHS = {
+ name: number
+ for number, name in enumerate(
+ ("jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"), 1
+ )
+}
+_DATE_LIKE = re.compile(r"^\d{1,4}([-/.])\d{1,4}(\1\d{1,4})?$")
+_EXCEL_DATE = re.compile(
+ r"^(\d{1,2}-[A-Za-z]{3}|[A-Za-z]{3}-\d{2}|\d{4}-\d{2}-\d{2}( 00:00:00)?"
+ r"|\d{1,2}/\d{1,2}(/\d{2,4})?)$"
+)
+_EXPONENT = re.compile(r"^\d(\.\d+)?[eE]\+?\d+$")
+
+
+def _date_tokens(text: str) -> frozenset[int]:
+ tokens: set[int] = set()
+ for part in re.split(r"[-/ .:]", text):
+ if part.isdigit():
+ number = int(part)
+ if number:
+ tokens.add(number % 100 if number >= 1000 else number)
+ elif part[:3].lower() in _MONTHS:
+ tokens.add(_MONTHS[part[:3].lower()])
+ return frozenset(tokens)
+
+
+def _exponent_of(original: str, incoming: str) -> bool:
+ mantissa = incoming.lower().split("e")[0].replace(".", "")
+ with localcontext() as context:
+ context.prec = max(len(mantissa), 1)
+ try:
+ return +Decimal(original) == Decimal(incoming)
+ except InvalidOperation:
+ return False
+
+
+def spreadsheet_damage(original: str, incoming: str) -> str | None:
+ """Why `incoming` looks like a spreadsheet's rewrite of `original`, or None."""
+ zeros_lost = (
+ original.isdigit()
+ and incoming.isdigit()
+ and original.startswith("0")
+ and original != incoming
+ and (original.lstrip("0") or "0") == incoming
+ )
+ if zeros_lost:
+ return "LEADING_ZEROS_LOST"
+ if (
+ original.isdigit()
+ and len(original) >= 12
+ and _EXPONENT.match(incoming)
+ and _exponent_of(original, incoming)
+ ):
+ return "EXPONENT_FORM"
+ if (
+ _DATE_LIKE.match(original)
+ and _EXCEL_DATE.match(incoming)
+ and _date_tokens(original) <= _date_tokens(incoming)
+ ):
+ return "DATE_FORM"
+ return None
+
+
+def spreadsheet_suspects(
+ existing_ids: Iterable[str], incoming_ids: Iterable[str]
+) -> list[tuple[str, str, str]]:
+ """Ids in the file that are new but look like a spreadsheet changed an id that is missing."""
+ existing, incoming = set(existing_ids), set(incoming_ids)
+ lost = sorted(existing - incoming)
+ found: list[tuple[str, str, str]] = []
+ for name in sorted(incoming - existing):
+ for original in lost:
+ reason = spreadsheet_damage(original, name)
+ if reason is not None:
+ found.append((name, original, reason))
+ break
+ return found
diff --git a/src/thoth/application/services/trace_origin.py b/src/thoth/application/services/trace_origin.py
new file mode 100644
index 0000000..d54141d
--- /dev/null
+++ b/src/thoth/application/services/trace_origin.py
@@ -0,0 +1,123 @@
+"""Check a trace origin against the stored trace, and what a research request does with it."""
+
+from __future__ import annotations
+
+from pydantic import ValidationError
+
+from thoth.application.services.verification_trace import (
+ VerificationTraceService,
+ mark_stale,
+)
+from thoth.domain.research_execution import ResearchWork
+from thoth.domain.trace_origin import MAX_ORIGIN_REFS, TraceOriginInput, TraceVerdictOrigin
+from thoth.domain.verification_trace import (
+ SubjectKind,
+ TraceKind,
+ VerdictRevision,
+ VerificationTraceRecord,
+ subject_key,
+)
+
+# Told to the model with the row: the verdict is the rule's, not a question for the model.
+ORIGIN_NOTE = (
+ "This investigation started from one row of the project's verification trace. The verdict was "
+ "computed by the trace rule from stored results; do not change, re-judge or soften it. Use the "
+ "row's reasons, rule and source positions as the starting context for finding candidate causes "
+ "and the tests that would tell them apart."
+)
+
+
+class TraceOriginRefused(ValueError):
+ """The origin does not match the stored trace; the message is the reason code."""
+
+
+def build_trace_origin(
+ service: VerificationTraceService, project_id: str, claimed: TraceOriginInput
+) -> TraceVerdictOrigin:
+ """The origin the server writes for a row the client named, or the reason it is refused."""
+ if claimed.project_id != project_id:
+ raise TraceOriginRefused("TRACE_ORIGIN_PROJECT_MISMATCH")
+ digest, record = service.read(project_id)
+ if record is None or digest is None:
+ raise TraceOriginRefused("TRACE_ORIGIN_NOT_FOUND")
+ kind = SubjectKind(claimed.subject_kind)
+ shown = mark_stale(record.trace_set, record.pending_changes)
+ history = record.history.get(subject_key(kind, claimed.subject_id), ())
+ if (kind, claimed.subject_id) not in shown or not history:
+ raise TraceOriginRefused("TRACE_ORIGIN_NOT_FOUND")
+ if claimed.verdict_revision != history[-1]:
+ # A revision that was once this row's verdict is "changed"; one that never was is unknown.
+ raise TraceOriginRefused(
+ "TRACE_ORIGIN_REVISION_CHANGED"
+ if claimed.verdict_revision in history
+ else "TRACE_ORIGIN_NOT_FOUND"
+ )
+ return _from_stored(project_id, record, service.revision(project_id, history[-1]))
+
+
+def _from_stored(
+ project_id: str, record: VerificationTraceRecord, verdict: VerdictRevision
+) -> TraceVerdictOrigin:
+ trace_set = record.trace_set
+ title = next(
+ (
+ item.title
+ for item in trace_set.items
+ if item.item_id == verdict.subject_id
+ and item.kind in (TraceKind.CRITERION, TraceKind.REQUIREMENT)
+ ),
+ "",
+ )
+ applied = verdict.conditions
+ rule = next((r for r in trace_set.rules if r.rule_id == applied.rule_id), None)
+ summary = None
+ if rule is not None and applied.comparator is not None and applied.threshold is not None:
+ summary = (
+ f"{rule.measure} {applied.comparator.value} {applied.threshold} {applied.unit or ''}"
+ )
+ chosen = (
+ () if verdict.selection is None else tuple(c.result_id for c in verdict.selection.chosen)
+ )
+ return TraceVerdictOrigin(
+ project_id=project_id,
+ trace_set_digest=trace_set.set_digest,
+ subject_kind=verdict.subject_kind.value,
+ subject_id=verdict.subject_id,
+ subject_title=title[:500],
+ verdict_revision=verdict.revision_digest,
+ verdict_digest=verdict.verdict_digest,
+ state=verdict.state.value,
+ reason_codes=tuple(code[:300] for code in verdict.reasons.computed[:MAX_ORIGIN_REFS]),
+ condition=applied.condition,
+ rule_summary=None if summary is None else summary.strip(),
+ chosen_result_ids=chosen[:MAX_ORIGIN_REFS],
+ source_span_refs=tuple(verdict.basis.source_span_refs[:MAX_ORIGIN_REFS]),
+ )
+
+
+def origin_context(origin: TraceVerdictOrigin) -> dict[str, object]:
+ """The origin as the model's context and the result carry it."""
+ return {**origin.model_dump(mode="json"), "note": ORIGIN_NOTE}
+
+
+def apply_trace_origin(
+ work: ResearchWork, continuation: dict[str, object], project_id: str
+) -> None:
+ """Give the request's origin to the model's context and pin its source positions.
+
+ Only an origin that has the server's own shape for this project is used; anything else is left
+ out and the result says so, instead of passing client text to the model.
+ """
+ raw = continuation.get("origin")
+ if raw is None:
+ return
+ try:
+ origin = TraceVerdictOrigin.model_validate(raw)
+ except ValidationError:
+ work.context["origin_rejected"] = "TRACE_ORIGIN_INVALID"
+ return
+ if origin.project_id != project_id:
+ work.context["origin_rejected"] = "TRACE_ORIGIN_PROJECT_MISMATCH"
+ return
+ work.context["origin"] = origin_context(origin)
+ work.pinned_spans = origin.source_span_refs
diff --git a/src/thoth/application/services/verification_trace.py b/src/thoth/application/services/verification_trace.py
new file mode 100644
index 0000000..b1c018f
--- /dev/null
+++ b/src/thoth/application/services/verification_trace.py
@@ -0,0 +1,815 @@
+"""Verdicts computed by rule from a trace set, and where a project keeps them.
+
+Nothing here calls a model. The same trace set, policy and previous verdicts always give the same
+verdicts: compute_verdicts is a pure function, and a verdict whose inputs did not change is kept
+as it was (same revision, same confirmations). A new revision is made only when its inputs
+changed, and it links to the revision before it. Recomputing is always an explicit user action.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Iterable
+from dataclasses import dataclass
+from datetime import datetime
+from decimal import Decimal
+from typing import Literal, cast
+
+from pydantic import BaseModel
+
+from thoth.application.services.request_records import RequestRecords
+from thoth.domain.canonical import canonical_payload, domain_digest
+from thoth.domain.enums import EntityType
+from thoth.domain.verification_trace import (
+ SYSTEM_ACTOR,
+ TRACE_SCHEMA_VERSION,
+ AppliedConditions,
+ CauseOfChange,
+ ChangedDependency,
+ Comparator,
+ Confirmation,
+ CriterionRule,
+ CriterionVerdictState,
+ Currentness,
+ CurrentnessState,
+ ExcludedResult,
+ RequirementVerdictState,
+ ResultCandidate,
+ ResultRecord,
+ ResultSelection,
+ SelectionPolicy,
+ SelectionPolicyName,
+ SubjectKind,
+ TraceKind,
+ TraceRelation,
+ TraceSet,
+ VerdictActor,
+ VerdictBasis,
+ VerdictDraft,
+ VerdictReasons,
+ VerdictRevision,
+ VerificationTraceRecord,
+ VerificationVerdictRecord,
+ seal_verdict,
+ subject_key,
+)
+
+KEY = "verification-trace:project"
+REVISION_PREFIX = "trace-verdict:"
+Subject = tuple[SubjectKind, str]
+_Built = tuple[VerificationTraceRecord, tuple[VerdictRevision, ...]]
+DependencyKind = Literal["RULE", "RESULT", "LINK", "POLICY"]
+
+
+class TraceError(ValueError):
+ pass
+
+
+class TraceConflict(TraceError):
+ pass
+
+
+@dataclass(frozen=True)
+class _Context:
+ """What is the same for every verdict in one computation."""
+
+ trigger: str
+ changes: tuple[ChangedDependency, ...]
+ actor: VerdictActor
+
+
+# --- reading the set ---
+
+
+def _ids_of(trace_set: TraceSet, kind: TraceKind) -> tuple[str, ...]:
+ return tuple(sorted(item.item_id for item in trace_set.items if item.kind is kind))
+
+
+def _children(trace_set: TraceSet, requirement_id: str) -> tuple[str, ...]:
+ return tuple(
+ sorted(
+ link.from_id
+ for link in trace_set.links
+ if link.relation is TraceRelation.REFINES and link.to_id == requirement_id
+ )
+ )
+
+
+def _requirements_of(trace_set: TraceSet, criterion_ids: Iterable[str]) -> tuple[str, ...]:
+ wanted = set(criterion_ids)
+ return tuple(
+ sorted(
+ {
+ link.to_id
+ for link in trace_set.links
+ if link.relation is TraceRelation.REFINES and link.from_id in wanted
+ }
+ )
+ )
+
+
+def _show(value: object) -> str:
+ if isinstance(value, Decimal):
+ return format(value, "f")
+ if isinstance(value, BaseModel):
+ return canonical_payload(value).decode("utf-8")
+ if isinstance(value, tuple):
+ return "[" + ", ".join(_show(item) for item in cast(tuple[object, ...], value)) + "]"
+ return str(value)
+
+
+def _digest(domain: str, payload: dict[str, object]) -> str:
+ return domain_digest(domain, TRACE_SCHEMA_VERSION, canonical_payload(payload))
+
+
+# --- one criterion ---
+
+
+def _select(
+ rule: CriterionRule, results: list[ResultRecord], policy: SelectionPolicy
+) -> ResultSelection:
+ def ref(result: ResultRecord) -> ResultCandidate:
+ return ResultCandidate(result_id=result.result_id, result_revision=result.result_revision)
+
+ same = sorted(
+ (item for item in results if item.condition == rule.condition),
+ key=lambda item: (item.observed_at, item.result_revision, item.result_id),
+ )
+ other = [item for item in results if item.condition != rule.condition]
+ if policy.name is SelectionPolicyName.LATEST_PER_CONDITION:
+ chosen, older = same[-1:], same[:-1]
+ else:
+ chosen, older = same, []
+ excluded = (
+ *(
+ ExcludedResult(
+ result_id=item.result_id,
+ result_revision=item.result_revision,
+ reason="SUPERSEDED_BY_LATER_RESULT",
+ )
+ for item in older
+ ),
+ *(
+ ExcludedResult(
+ result_id=item.result_id,
+ result_revision=item.result_revision,
+ reason="CONDITION_MISMATCH",
+ )
+ for item in other
+ ),
+ )
+ return ResultSelection(
+ policy=policy,
+ candidates=tuple(ref(item) for item in sorted(results, key=lambda item: item.result_id)),
+ chosen=tuple(ref(item) for item in chosen),
+ excluded=excluded,
+ )
+
+
+def _value_to_compare(rule: CriterionRule, result: ResultRecord) -> tuple[Decimal | None, str]:
+ """The rounded value to compare, or (None, why there is none)."""
+ name = result.result_id
+ if result.unit != rule.unit:
+ return None, f"UNIT_MISMATCH:{name}:{result.unit or 'none'}!={rule.unit}"
+ try:
+ counted: Decimal | None = None
+ if result.numerator is not None and result.denominator is not None:
+ if result.denominator <= 0:
+ return None, f"DENOMINATOR_NOT_POSITIVE:{name}"
+ counted = rule.rounding.apply(Decimal(result.numerator) / Decimal(result.denominator))
+ if result.value is None:
+ if result.raw_value is not None and result.raw_value.strip():
+ return None, f"VALUE_NOT_A_NUMBER:{name}"
+ if counted is None:
+ return None, f"VALUE_MISSING:{name}"
+ return counted, ""
+ rounded = rule.rounding.apply(result.value)
+ except ValueError:
+ return None, f"VALUE_OUT_OF_RANGE:{name}"
+ if counted is not None and counted != rounded:
+ return None, f"VALUE_DISAGREES_WITH_COUNTS:{name}"
+ return rounded, ""
+
+
+def _meets(rule: CriterionRule, value: Decimal) -> bool:
+ if rule.comparator is Comparator.AT_LEAST:
+ return value >= rule.threshold
+ return value <= rule.threshold
+
+
+def _judge(
+ rule: CriterionRule, chosen: list[ResultRecord]
+) -> tuple[CriterionVerdictState, tuple[str, ...]]:
+ if not chosen:
+ return CriterionVerdictState.HOLD_NO_RESULT, (f"NO_RESULT:{rule.condition}",)
+ failed: list[str] = []
+ invalid: list[str] = []
+ for result in chosen:
+ value, problem = _value_to_compare(rule, result)
+ if value is None:
+ invalid.append(problem)
+ elif not _meets(rule, value):
+ need = f"{rule.comparator.value} {format(rule.threshold, 'f')} {rule.unit}"
+ failed.append(
+ f"THRESHOLD_NOT_MET:{result.result_id}:value={format(value, 'f')} need {need}"
+ )
+ if failed:
+ return CriterionVerdictState.FAIL_COMPUTED, (*failed, *invalid)
+ if invalid:
+ return CriterionVerdictState.HOLD_INVALID_RESULT, tuple(invalid)
+ return CriterionVerdictState.PASS_COMPUTED, ()
+
+
+def _chain(draft: VerdictDraft, previous: VerdictRevision | None) -> VerdictRevision:
+ """Make the revision; if the verdict did not change keep the previous one as it was."""
+ candidate = seal_verdict(draft, None if previous is None else previous.revision_digest)
+ if previous is not None and candidate.verdict_digest == previous.verdict_digest:
+ return previous
+ return candidate
+
+
+def _criterion_revision(
+ trace_set: TraceSet,
+ criterion_id: str,
+ policy: SelectionPolicy,
+ context: _Context,
+ previous: VerdictRevision | None,
+) -> VerdictRevision:
+ rule = next((item for item in trace_set.rules if item.criterion_id == criterion_id), None)
+ results = sorted(
+ (item for item in trace_set.results if item.criterion_id == criterion_id),
+ key=lambda item: item.result_id,
+ )
+ cause_changes = tuple(item for item in context.changes if criterion_id in item.criterion_ids)
+ cause = CauseOfChange(trigger=context.trigger, changed=cause_changes)
+ inputs = _digest(
+ "TRACE_CRITERION_INPUT",
+ {"criterion_id": criterion_id, "rule": rule, "results": tuple(results), "policy": policy},
+ )
+ if rule is None:
+ return _chain(
+ VerdictDraft(
+ subject_kind=SubjectKind.CRITERION,
+ subject_id=criterion_id,
+ state=CriterionVerdictState.HOLD_NO_RULE,
+ cause=cause,
+ basis=VerdictBasis(input_digest=inputs),
+ conditions=AppliedConditions(),
+ actor=context.actor,
+ reasons=VerdictReasons(computed=("NO_RULE",)),
+ ),
+ previous,
+ )
+ selection = _select(rule, results, policy)
+ chosen_ids = {item.result_id for item in selection.chosen}
+ chosen = [item for item in results if item.result_id in chosen_ids]
+ state, reasons = _judge(rule, chosen)
+ refs = tuple(sorted({ref for item in chosen for ref in item.source_span_refs}))
+ return _chain(
+ VerdictDraft(
+ subject_kind=SubjectKind.CRITERION,
+ subject_id=criterion_id,
+ state=state,
+ cause=cause,
+ basis=VerdictBasis(input_digest=inputs, source_span_refs=refs),
+ selection=selection,
+ conditions=AppliedConditions(
+ required=rule.required,
+ rule_id=rule.rule_id,
+ rule_revision=rule.rule_revision,
+ condition=rule.condition,
+ unit=rule.unit,
+ comparator=rule.comparator,
+ threshold=rule.threshold,
+ rounding=rule.rounding,
+ ),
+ actor=context.actor,
+ reasons=VerdictReasons(computed=reasons),
+ ),
+ previous,
+ )
+
+
+# --- one requirement ---
+
+
+def _requirement_state(
+ states: list[CriterionVerdictState],
+) -> RequirementVerdictState:
+ """Never PASS without a required criterion; conditions are never averaged."""
+ if not states:
+ return RequirementVerdictState.HOLD_NO_CRITERIA
+ failed = any(item is CriterionVerdictState.FAIL_COMPUTED for item in states)
+ held = any(
+ item not in (CriterionVerdictState.PASS_COMPUTED, CriterionVerdictState.FAIL_COMPUTED)
+ for item in states
+ )
+ if failed:
+ return (
+ RequirementVerdictState.FAIL_WITH_INCOMPLETE_COVERAGE
+ if held
+ else RequirementVerdictState.FAIL
+ )
+ return RequirementVerdictState.HOLD_INCOMPLETE if held else RequirementVerdictState.PASS
+
+
+def _requirement_revision(
+ trace_set: TraceSet,
+ requirement_id: str,
+ criteria: dict[str, VerdictRevision],
+ context: _Context,
+ previous: VerdictRevision | None,
+) -> VerdictRevision:
+ children = _children(trace_set, requirement_id)
+ rules = {item.criterion_id: item for item in trace_set.rules}
+ required = tuple(child for child in children if child not in rules or rules[child].required)
+ states = [criteria[child].state for child in required]
+ typed = [item for item in states if isinstance(item, CriterionVerdictState)]
+ digests = tuple(criteria[child].verdict_digest for child in required)
+ refs = tuple(
+ sorted({ref for child in required for ref in criteria[child].basis.source_span_refs})
+ )
+ own = set(children)
+ cause_changes = tuple(
+ item
+ for item in context.changes
+ if requirement_id in item.requirement_ids or own & set(item.criterion_ids)
+ )
+ reasons = (
+ tuple(
+ f"{child}:{criteria[child].state.value}"
+ for child in required
+ if criteria[child].state is not CriterionVerdictState.PASS_COMPUTED
+ )
+ if required
+ else ("NO_REQUIRED_CRITERIA",)
+ )
+ return _chain(
+ VerdictDraft(
+ subject_kind=SubjectKind.REQUIREMENT,
+ subject_id=requirement_id,
+ state=_requirement_state(typed),
+ cause=CauseOfChange(trigger=context.trigger, changed=cause_changes),
+ basis=VerdictBasis(
+ input_digest=_digest(
+ "TRACE_REQUIREMENT_INPUT",
+ {"requirement_id": requirement_id, "required": required, "children": digests},
+ ),
+ source_span_refs=refs,
+ child_verdict_digests=digests,
+ ),
+ conditions=AppliedConditions(required_criteria=required),
+ actor=context.actor,
+ reasons=VerdictReasons(computed=reasons),
+ ),
+ previous,
+ )
+
+
+# --- public pure functions ---
+
+
+def compute_verdicts(
+ trace_set: TraceSet,
+ policy: SelectionPolicy,
+ *,
+ computed_at: datetime,
+ trigger: str = "RECOMPUTE",
+ changes: tuple[ChangedDependency, ...] = (),
+ previous: Iterable[VerdictRevision] = (),
+ actor_id: str = SYSTEM_ACTOR,
+) -> tuple[VerdictRevision, ...]:
+ """The current verdict of every criterion and requirement; model-free and repeatable.
+
+ A verdict whose inputs did not change comes back as the very same revision. A changed one is a
+ new revision whose parent is the previous revision of that subject.
+ """
+ last: dict[Subject, VerdictRevision] = {
+ (item.subject_kind, item.subject_id): item for item in previous
+ }
+ context = _Context(
+ trigger=trigger,
+ changes=changes,
+ actor=VerdictActor(actor_id=actor_id, computed_at=computed_at),
+ )
+ criteria = {
+ criterion_id: _criterion_revision(
+ trace_set,
+ criterion_id,
+ policy,
+ context,
+ last.get((SubjectKind.CRITERION, criterion_id)),
+ )
+ for criterion_id in _ids_of(trace_set, TraceKind.CRITERION)
+ }
+ requirements = tuple(
+ _requirement_revision(
+ trace_set,
+ requirement_id,
+ criteria,
+ context,
+ last.get((SubjectKind.REQUIREMENT, requirement_id)),
+ )
+ for requirement_id in _ids_of(trace_set, TraceKind.REQUIREMENT)
+ )
+ return (*criteria.values(), *requirements)
+
+
+def _rule_summary(rule: CriterionRule) -> str:
+ return f"{rule.comparator.value} {format(rule.threshold, 'f')} {rule.unit} @{rule.condition}"
+
+
+def _result_summary(result: ResultRecord) -> str:
+ value = "none" if result.value is None else format(result.value, "f")
+ return f"{value} {result.unit} @{result.condition}"
+
+
+def _field_changes(
+ kind: DependencyKind,
+ ref_id: str,
+ old: BaseModel,
+ new: BaseModel,
+ revisions: tuple[int, int],
+ scope: tuple[tuple[str, ...], tuple[str, ...]],
+) -> list[ChangedDependency]:
+ before, after = old.model_dump(mode="python"), new.model_dump(mode="python")
+ return [
+ ChangedDependency(
+ kind=kind,
+ ref_id=ref_id,
+ field=name,
+ before=_show(before[name]),
+ after=_show(after[name]),
+ before_revision=revisions[0],
+ after_revision=revisions[1],
+ criterion_ids=scope[0],
+ requirement_ids=scope[1],
+ )
+ for name in sorted(before)
+ if before[name] != after[name]
+ ]
+
+
+def dependency_changes(old: TraceSet | None, new: TraceSet) -> tuple[ChangedDependency, ...]:
+ """What a verdict depends on that differs between two sets.
+
+ Rules, results and refine-links count. A criterion's title or any other display field does
+ not, so editing it never makes a verdict stale.
+ """
+ before = old or TraceSet()
+ changes: list[ChangedDependency] = []
+
+ def scope(criterion_ids: tuple[str, ...]) -> tuple[tuple[str, ...], tuple[str, ...]]:
+ extra = (*_requirements_of(before, criterion_ids), *_requirements_of(new, criterion_ids))
+ return criterion_ids, tuple(sorted(set(extra)))
+
+ old_rules = {item.rule_id: item for item in before.rules}
+ new_rules = {item.rule_id: item for item in new.rules}
+ for rule_id in sorted(old_rules.keys() | new_rules.keys()):
+ was, now = old_rules.get(rule_id), new_rules.get(rule_id)
+ if was is not None and now is not None:
+ ids = tuple(sorted({was.criterion_id, now.criterion_id}))
+ changes += _field_changes(
+ "RULE", rule_id, was, now, (was.rule_revision, now.rule_revision), scope(ids)
+ )
+ continue
+ item = now or was
+ assert item is not None
+ changes.append(
+ ChangedDependency(
+ kind="RULE",
+ ref_id=rule_id,
+ field="*",
+ before=None if was is None else _rule_summary(was),
+ after=None if now is None else _rule_summary(now),
+ before_revision=None if was is None else was.rule_revision,
+ after_revision=None if now is None else now.rule_revision,
+ criterion_ids=scope((item.criterion_id,))[0],
+ requirement_ids=scope((item.criterion_id,))[1],
+ )
+ )
+ old_results = {item.result_id: item for item in before.results}
+ new_results = {item.result_id: item for item in new.results}
+ for result_id in sorted(old_results.keys() | new_results.keys()):
+ was_result, now_result = old_results.get(result_id), new_results.get(result_id)
+ if was_result is not None and now_result is not None:
+ ids = tuple(sorted({was_result.criterion_id, now_result.criterion_id}))
+ changes += _field_changes(
+ "RESULT",
+ result_id,
+ was_result,
+ now_result,
+ (was_result.result_revision, now_result.result_revision),
+ scope(ids),
+ )
+ continue
+ present = now_result or was_result
+ assert present is not None
+ changes.append(
+ ChangedDependency(
+ kind="RESULT",
+ ref_id=result_id,
+ field="*",
+ before=None if was_result is None else _result_summary(was_result),
+ after=None if now_result is None else _result_summary(now_result),
+ before_revision=None if was_result is None else was_result.result_revision,
+ after_revision=None if now_result is None else now_result.result_revision,
+ criterion_ids=scope((present.criterion_id,))[0],
+ requirement_ids=scope((present.criterion_id,))[1],
+ )
+ )
+ pairs_before = {
+ (link.from_id, link.to_id)
+ for link in before.links
+ if link.relation is TraceRelation.REFINES
+ }
+ pairs_after = {
+ (link.from_id, link.to_id) for link in new.links if link.relation is TraceRelation.REFINES
+ }
+ for source, target in sorted(pairs_before ^ pairs_after):
+ added = (source, target) in pairs_after
+ changes.append(
+ ChangedDependency(
+ kind="LINK",
+ ref_id=f"{source}->{target}",
+ field="*",
+ before=None if added else "linked",
+ after="linked" if added else None,
+ criterion_ids=(source,),
+ requirement_ids=(target,),
+ )
+ )
+ return tuple(changes)
+
+
+def mark_stale(
+ trace_set: TraceSet, changes: Iterable[ChangedDependency]
+) -> dict[Subject, Currentness]:
+ """Currentness of every criterion and requirement; only those the changes reach are stale."""
+ pending = tuple(changes)
+
+ def state(hits: tuple[ChangedDependency, ...]) -> Currentness:
+ if not hits:
+ return Currentness()
+ return Currentness(state=CurrentnessState.STALE_BASIS, changed_dependencies=hits)
+
+ result: dict[Subject, Currentness] = {}
+ for criterion_id in _ids_of(trace_set, TraceKind.CRITERION):
+ hits = tuple(item for item in pending if criterion_id in item.criterion_ids)
+ result[(SubjectKind.CRITERION, criterion_id)] = state(hits)
+ for requirement_id in _ids_of(trace_set, TraceKind.REQUIREMENT):
+ own = set(_children(trace_set, requirement_id))
+ hits = tuple(
+ item
+ for item in pending
+ if requirement_id in item.requirement_ids or own & set(item.criterion_ids)
+ )
+ result[(SubjectKind.REQUIREMENT, requirement_id)] = state(hits)
+ return result
+
+
+def _replaced(
+ project_id: str,
+ current: VerificationTraceRecord | None,
+ trace_set: TraceSet,
+ policy: SelectionPolicy | None,
+) -> VerificationTraceRecord:
+ """The record with a new set in it; what the new set changed is remembered as pending."""
+ if current is None:
+ return VerificationTraceRecord(
+ project_id=project_id, trace_set=trace_set, policy=policy or SelectionPolicy()
+ )
+ wanted = policy or current.policy
+ found = list(dependency_changes(current.trace_set, trace_set))
+ if wanted != current.policy:
+ found.append(
+ ChangedDependency(
+ kind="POLICY",
+ ref_id=wanted.name.value,
+ field="policy",
+ before=_show(current.policy),
+ after=_show(wanted),
+ before_revision=current.policy.version,
+ after_revision=wanted.version,
+ criterion_ids=_ids_of(trace_set, TraceKind.CRITERION),
+ requirement_ids=_ids_of(trace_set, TraceKind.REQUIREMENT),
+ )
+ )
+ merged = tuple(dict.fromkeys((*current.pending_changes, *found)))
+ return current.model_copy(
+ update={"trace_set": trace_set, "policy": wanted, "pending_changes": merged}
+ )
+
+
+# --- storage ---
+
+
+@dataclass(frozen=True)
+class HistoryPage:
+ """Revisions of one verdict, newest first, older than the cursor that was asked for."""
+
+ revisions: tuple[VerdictRevision, ...]
+ total: int
+ next_before: str | None # pass it back to read the revisions before this page
+
+
+class VerificationTraceService:
+ """One trace record per project, changed only with the digest the caller read.
+
+ Each verdict revision is its own record, written once in the same ledger commit as the trace
+ record that lists it, so a save writes the trace and the new revisions and nothing else.
+ """
+
+ def __init__(self, records: RequestRecords) -> None:
+ self._records = records
+
+ def read(self, project_id: str) -> tuple[str | None, VerificationTraceRecord | None]:
+ record = self._records.read(project_id, EntityType.THREAD, KEY)
+ if record is None:
+ return None, None
+ return record[0].revision_digest, VerificationTraceRecord.model_validate(record[1])
+
+ def revision(self, project_id: str, digest: str) -> VerdictRevision:
+ stored = self._records.read(project_id, EntityType.THREAD, REVISION_PREFIX + digest)
+ if stored is None:
+ raise TraceError("TRACE_VERDICT_REVISION_MISSING")
+ return VerificationVerdictRecord.model_validate(stored[1]).revision
+
+ def current_verdicts(
+ self, project_id: str, record: VerificationTraceRecord
+ ) -> dict[Subject, VerdictRevision]:
+ return {
+ key: self.revision(project_id, digest)
+ for key, digest in record.current_digests().items()
+ }
+
+ def history(
+ self,
+ project_id: str,
+ record: VerificationTraceRecord,
+ kind: SubjectKind,
+ subject_id: str,
+ *,
+ before: str | None = None,
+ limit: int = 50,
+ ) -> HistoryPage:
+ digests = record.history.get(subject_key(kind, subject_id), ())
+ older = digests
+ if before is not None:
+ if before not in digests:
+ raise TraceError("TRACE_HISTORY_CURSOR_UNKNOWN")
+ older = digests[: digests.index(before)]
+ page = older[max(len(older) - limit, 0) :]
+ return HistoryPage(
+ revisions=tuple(self.revision(project_id, digest) for digest in reversed(page)),
+ total=len(digests),
+ next_before=page[0] if page and len(older) > len(page) else None,
+ )
+
+ def currentness(self, project_id: str) -> dict[Subject, Currentness]:
+ _, record = self.read(project_id)
+ if record is None:
+ return {}
+ return mark_stale(record.trace_set, record.pending_changes)
+
+ def mark_stale(
+ self,
+ project_id: str,
+ changes: tuple[ChangedDependency, ...],
+ expected_digest: str | None,
+ actor_id: str,
+ ) -> str:
+ """Remember changes that reach existing verdicts; they stay as they are until recomputed."""
+
+ def build(current: VerificationTraceRecord | None) -> _Built:
+ if current is None:
+ raise TraceError("TRACE_NOT_FOUND")
+ merged = tuple(dict.fromkeys((*current.pending_changes, *changes)))
+ return current.model_copy(update={"pending_changes": merged}), ()
+
+ return self._write(project_id, expected_digest, build, actor_id)
+
+ def replace_trace_set(
+ self,
+ project_id: str,
+ trace_set: TraceSet,
+ expected_digest: str | None,
+ actor_id: str,
+ policy: SelectionPolicy | None = None,
+ ) -> str:
+ """Store a new set. What it changed is remembered as stale; no verdict is recomputed."""
+ return self._write(
+ project_id,
+ expected_digest,
+ lambda current: (_replaced(project_id, current, trace_set, policy), ()),
+ actor_id,
+ )
+
+ def recompute(
+ self,
+ project_id: str,
+ expected_digest: str | None,
+ actor_id: str,
+ trigger: str = "RECOMPUTE",
+ ) -> str:
+ """The user asked for new verdicts. Unchanged ones keep their revision."""
+
+ def build(current: VerificationTraceRecord | None) -> _Built:
+ if current is None:
+ raise TraceError("TRACE_NOT_FOUND")
+ return self._recomputed(project_id, current, trigger)
+
+ return self._write(project_id, expected_digest, build, actor_id)
+
+ def apply_import(
+ self,
+ project_id: str,
+ trace_set: TraceSet,
+ expected_digest: str | None,
+ actor_id: str,
+ trigger: str = "CSV_IMPORT",
+ ) -> str:
+ """Store the imported set and compute the verdicts it changes in one write."""
+ return self._write(
+ project_id,
+ expected_digest,
+ lambda current: self._recomputed(
+ project_id, _replaced(project_id, current, trace_set, None), trigger
+ ),
+ actor_id,
+ )
+
+ def _recomputed(
+ self, project_id: str, current: VerificationTraceRecord, trigger: str
+ ) -> _Built:
+ previous = tuple(self.current_verdicts(project_id, current).values())
+ verdicts = compute_verdicts(
+ current.trace_set,
+ current.policy,
+ computed_at=self._records.clock.now(),
+ trigger="INITIAL_COMPUTE" if not previous and trigger == "RECOMPUTE" else trigger,
+ changes=current.pending_changes,
+ previous=previous,
+ )
+ known = {item.revision_digest for item in previous}
+ added = tuple(item for item in verdicts if item.revision_digest not in known)
+ history = dict(current.history)
+ for item in added:
+ key = subject_key(item.subject_kind, item.subject_id)
+ history[key] = (*history.get(key, ()), item.revision_digest)
+ return current.model_copy(update={"history": history, "pending_changes": ()}), added
+
+ def confirm(
+ self,
+ project_id: str,
+ verdict_revision_digest: str,
+ actor_id: str,
+ rationale: str,
+ expected_digest: str | None,
+ ) -> str:
+ """Record that a person looked at this revision. The verdict itself is not touched."""
+
+ def build(current: VerificationTraceRecord | None) -> _Built:
+ if current is None:
+ raise TraceError("TRACE_NOT_FOUND")
+ if verdict_revision_digest not in current.current_digests().values():
+ raise TraceError("TRACE_CONFIRM_TARGET_NOT_CURRENT")
+ confirmation = Confirmation(
+ verdict_revision_digest=verdict_revision_digest,
+ actor_id=actor_id,
+ confirmed_at=self._records.clock.now(),
+ rationale=rationale,
+ )
+ return current.model_copy(
+ update={"confirmations": (*current.confirmations, confirmation)}
+ ), ()
+
+ return self._write(project_id, expected_digest, build, actor_id)
+
+ def _write(
+ self,
+ project_id: str,
+ expected_digest: str | None,
+ build: Callable[[VerificationTraceRecord | None], _Built],
+ actor_id: str,
+ ) -> str:
+ """The trace record and every new revision are committed together or not at all."""
+ with self._records.ledger.transaction():
+ digest, current = self.read(project_id)
+ if digest != expected_digest:
+ raise TraceConflict("TRACE_REVISION_CONFLICT")
+ updated, revisions = build(current)
+ main_ref, main = self._records.stage(
+ project_id, EntityType.THREAD, KEY, updated, actor_id
+ )
+ staged = [main]
+ for revision in revisions:
+ _, extra = self._records.stage(
+ project_id,
+ EntityType.THREAD,
+ REVISION_PREFIX + revision.revision_digest,
+ VerificationVerdictRecord(project_id=project_id, revision=revision),
+ actor_id,
+ )
+ staged.append(extra)
+ self._records.commit_staged(tuple(staged))
+ return main_ref.revision_digest
diff --git a/src/thoth/application/services/verification_trace_import.py b/src/thoth/application/services/verification_trace_import.py
new file mode 100644
index 0000000..a4a58fe
--- /dev/null
+++ b/src/thoth/application/services/verification_trace_import.py
@@ -0,0 +1,386 @@
+"""What a CSV import would change, worked out before anything is saved, and the apply step.
+
+Preview and apply run the same planning on the same input, so what the user saw is what is applied.
+The preview id is bound to the exact text that was sent and to the set the trace has right now:
+if either differs at apply time, nothing is applied. An apply is one write that stores the new set
+and the verdicts it changes together.
+
+Rows in the file add or change things (matched by id). Rows that are missing from the file change
+nothing. Only a DELETE row removes something, and an item that is still linked is never removed
+along with its links.
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass, field
+from typing import Any, Literal, cast
+
+from pydantic import BaseModel
+
+from thoth.application.services.trace_csv import (
+ LOSS_MANIFEST,
+ NOTICE_FIELD,
+ Issue,
+ ParsedRow,
+ input_sha256,
+ parse_csv,
+ spreadsheet_suspects,
+)
+from thoth.application.services.verification_trace import (
+ TraceError,
+ VerificationTraceService,
+)
+from thoth.domain.canonical import canonical_payload, domain_digest
+from thoth.domain.verification_trace import (
+ CriterionRule,
+ ResultRecord,
+ TraceItem,
+ TraceLink,
+ TraceSet,
+ VerificationTraceRecord,
+)
+
+ImportMode = Literal["CREATE", "UPDATE"]
+_STOPPING = {"STALE_BASE", "BASE_DIGEST_MISSING", "BASE_DIGEST_INCONSISTENT"}
+
+
+class ImportRejected(TraceError):
+ pass
+
+
+@dataclass(frozen=True)
+class PlannedChange:
+ kind: str
+ id: str
+ row: int
+ fields: tuple[str, ...] = ()
+
+ def as_json(self) -> dict[str, Any]:
+ return {"kind": self.kind, "id": self.id, "row": self.row, "fields": list(self.fields)}
+
+
+@dataclass
+class ImportPlan:
+ mode: str
+ input_sha256: str
+ preview_id: str
+ current_set_digest: str | None
+ added: list[PlannedChange] = field(default_factory=lambda: list[PlannedChange]())
+ updated: list[PlannedChange] = field(default_factory=lambda: list[PlannedChange]())
+ deleted: list[PlannedChange] = field(default_factory=lambda: list[PlannedChange]())
+ unchanged: int = 0
+ conflicts: list[Issue] = field(default_factory=lambda: list[Issue]())
+ losses: list[str] = field(default_factory=lambda: list(LOSS_MANIFEST))
+ new_set: TraceSet | None = None
+
+ @property
+ def applicable(self) -> bool:
+ return not self.conflicts
+
+ @property
+ def has_changes(self) -> bool:
+ return bool(self.added or self.updated or self.deleted)
+
+ def as_json(self) -> dict[str, Any]:
+ return {
+ "preview_id": self.preview_id,
+ "input_sha256": self.input_sha256,
+ "mode": self.mode,
+ "current_set_digest": self.current_set_digest,
+ "applicable": self.applicable,
+ "changes": {
+ "added": [item.as_json() for item in self.added],
+ "updated": [item.as_json() for item in self.updated],
+ "deleted": [item.as_json() for item in self.deleted],
+ "unchanged": self.unchanged,
+ },
+ "conflicts": [item.as_json() for item in self.conflicts],
+ "losses": self.losses,
+ "spreadsheet_suspects": [
+ item.as_json() for item in self.conflicts if item.code == "EXCEL_ID_SUSPECTED"
+ ],
+ }
+
+
+def _preview_id(project_id: str, mode: str, sha: str, set_digest: str | None) -> str:
+ payload: dict[str, object] = {
+ "project_id": project_id,
+ "mode": mode,
+ "input_sha256": sha,
+ "current_set_digest": set_digest,
+ }
+ return domain_digest("TRACE_IMPORT_PREVIEW", "1.0.0", canonical_payload(payload))
+
+
+def _changed_fields(old: BaseModel, new: BaseModel) -> tuple[str, ...]:
+ before, after = old.model_dump(mode="python"), new.model_dump(mode="python")
+ return tuple(sorted(name for name in before if before[name] != after[name]))
+
+
+def _empty(trace_set: TraceSet) -> bool:
+ return not (trace_set.items or trace_set.links or trace_set.rules or trace_set.results)
+
+
+def plan_import(
+ project_id: str, current: VerificationTraceRecord | None, mode: ImportMode, text: str
+) -> ImportPlan:
+ parsed = parse_csv(text)
+ sha = input_sha256(text)
+ now = None if current is None else current.trace_set.set_digest
+ plan = ImportPlan(
+ mode=mode,
+ input_sha256=sha,
+ preview_id=_preview_id(project_id, mode, sha, now),
+ current_set_digest=now,
+ conflicts=list(parsed.issues),
+ losses=[*LOSS_MANIFEST, *(f"column ignored: {name}" for name in parsed.ignored_columns)],
+ )
+ if parsed.issues and not parsed.rows and not parsed.base_digests:
+ return plan # the file could not be read as this format at all
+ existing = TraceSet() if current is None else current.trace_set
+ if mode == "CREATE":
+ if not _empty(existing):
+ plan.conflicts.append(
+ Issue("MODE_CREATE_NEEDS_EMPTY_PROJECT", detail="this project already has a trace")
+ )
+ plan.conflicts.extend(
+ Issue("DELETE_NOT_ALLOWED_IN_CREATE", row.row, row.key)
+ for row in parsed.rows
+ if row.row_type == "DELETE"
+ )
+ if not parsed.rows and not parsed.issues:
+ plan.conflicts.append(Issue("CSV_HAS_NO_ROWS"))
+ else:
+ if current is None:
+ plan.conflicts.append(
+ Issue("UPDATE_NEEDS_EXISTING_TRACE", detail="use CREATE for an empty project")
+ )
+ return plan
+ stop = _base_conflict(parsed.base_digests, existing.set_digest)
+ if stop is not None:
+ plan.conflicts.append(stop)
+ return plan
+ _apply_rows(plan, existing, parsed.rows, parsed.reference_ids, mode)
+ return plan
+
+
+def _base_conflict(digests: tuple[str, ...], now: str) -> Issue | None:
+ if not digests:
+ return Issue("BASE_DIGEST_MISSING", detail="base_set_digest is empty in every row")
+ if len(digests) > 1:
+ return Issue(
+ "BASE_DIGEST_INCONSISTENT", detail="rows name different base_set_digest values"
+ )
+ named = next(iter(digests))
+ if named != now:
+ return Issue(
+ "STALE_BASE",
+ detail=f"the file was made from set {named[:12]}, the trace is now {now[:12]}",
+ )
+ return None
+
+
+_STORES = {"ITEM": "items", "LINK": "links", "RULE": "rules", "RESULT": "results"}
+_KEYS = {"ITEM": "item_id", "LINK": "link_id", "RULE": "rule_id", "RESULT": "result_id"}
+
+
+def _apply_rows(
+ plan: ImportPlan,
+ existing: TraceSet,
+ rows: list[ParsedRow],
+ reference_ids: set[str],
+ mode: str,
+) -> None:
+ final: dict[str, dict[str, BaseModel]] = {
+ kind: {getattr(model, _KEYS[kind]): model for model in getattr(existing, name)}
+ for kind, name in _STORES.items()
+ }
+ upserted: set[tuple[str, str]] = set()
+ for row in rows:
+ if row.row_type == "DELETE" or row.model is None:
+ continue
+ upserted.add((row.row_type, row.key))
+ _upsert(plan, final[row.row_type], row)
+ deleted_items: set[str] = set()
+ for row in rows:
+ if row.row_type != "DELETE" or mode == "CREATE":
+ continue
+ kind = str(row.target_kind)
+ if (kind, row.key) in upserted:
+ plan.conflicts.append(Issue("DELETE_AND_UPSERT_SAME_TARGET", row.row, row.key))
+ elif row.key not in final[kind]:
+ plan.conflicts.append(Issue("DELETE_TARGET_NOT_FOUND", row.row, row.key, kind))
+ else:
+ del final[kind][row.key]
+ plan.deleted.append(PlannedChange(kind, row.key, row.row))
+ if kind == "ITEM":
+ deleted_items.add(row.key)
+ _keep_links(plan, final, deleted_items)
+ _spreadsheet_check(plan, existing, rows, reference_ids, mode)
+ if plan.conflicts:
+ return
+ try:
+ plan.new_set = TraceSet(
+ items=tuple(cast(dict[str, TraceItem], final["ITEM"]).values()),
+ links=tuple(cast(dict[str, TraceLink], final["LINK"]).values()),
+ rules=tuple(cast(dict[str, CriterionRule], final["RULE"]).values()),
+ results=tuple(cast(dict[str, ResultRecord], final["RESULT"]).values()),
+ )
+ except ValueError as exc:
+ found = re.findall(r"TRACE_[A-Z_]+(?::[^\s\]]+)?", str(exc))
+ plan.conflicts.append(
+ Issue("GRAPH_INVALID", detail=(found[0] if found else str(exc))[:200])
+ )
+
+
+def _upsert(plan: ImportPlan, store: dict[str, BaseModel], row: ParsedRow) -> None:
+ model = row.model
+ assert model is not None
+ old = store.get(row.key)
+ if isinstance(model, TraceItem) and isinstance(old, TraceItem):
+ if not row.key_given:
+ model = model.model_copy(update={"item_key": old.item_key})
+ elif model.item_key != old.item_key:
+ plan.conflicts.append(
+ Issue("ITEM_KEY_CHANGED", row.row, row.key, "the internal key cannot be changed")
+ )
+ return
+ if NOTICE_FIELD in old.fields and NOTICE_FIELD not in model.fields:
+ # A row that says nothing about the synthetic notice cannot take the mark away.
+ model = model.model_copy(
+ update={"fields": {**model.fields, NOTICE_FIELD: old.fields[NOTICE_FIELD]}}
+ )
+ if old is None:
+ plan.added.append(PlannedChange(row.row_type, row.key, row.row))
+ elif old == model:
+ plan.unchanged += 1
+ return
+ else:
+ plan.updated.append(
+ PlannedChange(row.row_type, row.key, row.row, _changed_fields(old, model))
+ )
+ store[row.key] = model
+
+
+def _keep_links(
+ plan: ImportPlan, final: dict[str, dict[str, BaseModel]], deleted_items: set[str]
+) -> None:
+ """An item that is deleted must have nothing left that points at it."""
+ for link in cast(dict[str, TraceLink], final["LINK"]).values():
+ for end in (link.from_id, link.to_id):
+ if end in deleted_items:
+ plan.conflicts.append(
+ Issue("DELETE_BLOCKED_BY_LINK", None, end, f"still linked by {link.link_id}")
+ )
+ for rule in cast(dict[str, CriterionRule], final["RULE"]).values():
+ if rule.criterion_id in deleted_items:
+ plan.conflicts.append(
+ Issue(
+ "DELETE_BLOCKED_BY_RULE",
+ None,
+ rule.criterion_id,
+ f"still has rule {rule.rule_id}",
+ )
+ )
+ for result in cast(dict[str, ResultRecord], final["RESULT"]).values():
+ for end in (result.criterion_id, result.result_id):
+ if end in deleted_items:
+ plan.conflicts.append(
+ Issue(
+ "DELETE_BLOCKED_BY_RESULT",
+ None,
+ end,
+ f"still has result {result.result_id}",
+ )
+ )
+
+
+def _spreadsheet_check(
+ plan: ImportPlan,
+ existing: TraceSet,
+ rows: list[ParsedRow],
+ reference_ids: set[str],
+ mode: str,
+) -> None:
+ if mode != "UPDATE":
+ return
+ known = {
+ *(item.item_id for item in existing.items),
+ *(link.link_id for link in existing.links),
+ *(rule.rule_id for rule in existing.rules),
+ *(result.result_id for result in existing.results),
+ }
+ incoming = {row.key for row in rows} | reference_ids
+ for name, original, reason in spreadsheet_suspects(known, incoming):
+ at = next((row.row for row in rows if row.key == name), None)
+ plan.conflicts.append(
+ Issue(
+ "EXCEL_ID_SUSPECTED",
+ at,
+ name,
+ f"looks like {original} changed by a spreadsheet ({reason})",
+ )
+ )
+
+
+class TraceImportService:
+ """Preview and apply for the trace CSV, on top of the trace service."""
+
+ def __init__(self, trace: VerificationTraceService) -> None:
+ self._trace = trace
+
+ def preview(self, project_id: str, mode: ImportMode, text: str) -> ImportPlan:
+ _, current = self._trace.read(project_id)
+ return plan_import(project_id, current, mode, text)
+
+ def apply(
+ self,
+ project_id: str,
+ mode: ImportMode,
+ text: str,
+ preview_id: str,
+ sha256: str,
+ actor_id: str,
+ ) -> dict[str, Any]:
+ if input_sha256(text) != sha256:
+ raise ImportRejected("TRACE_IMPORT_INPUT_CHANGED")
+ digest, current = self._trace.read(project_id)
+ plan = plan_import(project_id, current, mode, text)
+ if plan.preview_id != preview_id:
+ raise ImportRejected("TRACE_IMPORT_PREVIEW_STALE")
+ if plan.conflicts:
+ codes = ",".join(sorted({item.code for item in plan.conflicts}))
+ raise ImportRejected(f"TRACE_IMPORT_NOT_APPLICABLE:{codes}")
+ before = {} if current is None else self._trace.current_verdicts(project_id, current)
+ if not plan.has_changes or plan.new_set is None:
+ return {
+ "applied": False,
+ "reason": "NO_CHANGES",
+ "record_digest": digest,
+ "verdict_changes": [],
+ }
+ written = self._trace.apply_import(project_id, plan.new_set, digest, actor_id)
+ _, after_record = self._trace.read(project_id)
+ assert after_record is not None
+ changes = [
+ {
+ "subject_kind": key[0].value,
+ "subject_id": key[1],
+ "before": None if key not in before else before[key].state.value,
+ "after": revision.state.value,
+ }
+ for key, revision in self._trace.current_verdicts(project_id, after_record).items()
+ if key not in before or before[key].revision_digest != revision.revision_digest
+ ]
+ return {
+ "applied": True,
+ "record_digest": written,
+ "set_digest": after_record.trace_set.set_digest,
+ "counts": {
+ "added": len(plan.added),
+ "updated": len(plan.updated),
+ "deleted": len(plan.deleted),
+ },
+ "verdict_changes": changes,
+ }
diff --git a/src/thoth/application/services/verification_trace_view.py b/src/thoth/application/services/verification_trace_view.py
new file mode 100644
index 0000000..7a571e3
--- /dev/null
+++ b/src/thoth/application/services/verification_trace_view.py
@@ -0,0 +1,68 @@
+"""The trace as one JSON value for a screen or a client: set, current verdicts, recent history."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from thoth.application.services.verification_trace import VerificationTraceService, mark_stale
+from thoth.domain.verification_trace import (
+ SubjectKind,
+ TraceSet,
+ VerdictRevision,
+ VerificationTraceRecord,
+ subject_key,
+)
+
+RECENT_HISTORY = 3 # revisions of each verdict that come with trace/read; the rest is trace/history
+
+
+def revision_json(revision: VerdictRevision) -> dict[str, Any]:
+ return revision.model_dump(mode="json")
+
+
+def trace_view(
+ service: VerificationTraceService,
+ project_id: str,
+ record_digest: str | None,
+ record: VerificationTraceRecord | None,
+) -> dict[str, Any]:
+ trace_set = TraceSet() if record is None else record.trace_set
+ view: dict[str, Any] = {
+ "record_digest": record_digest,
+ "set_digest": trace_set.set_digest,
+ "items": [item.model_dump(mode="json") for item in trace_set.items],
+ "links": [item.model_dump(mode="json") for item in trace_set.links],
+ "rules": [item.model_dump(mode="json") for item in trace_set.rules],
+ "results": [item.model_dump(mode="json") for item in trace_set.results],
+ "policy": None if record is None else record.policy.model_dump(mode="json"),
+ "verdicts": [],
+ "confirmations": [],
+ "pending_changes": [],
+ }
+ if record is None:
+ return view
+ stale = mark_stale(record.trace_set, record.pending_changes)
+ for (kind, subject_id), digest in sorted(
+ record.current_digests().items(), key=lambda pair: (pair[0][0].value, pair[0][1])
+ ):
+ if (kind, subject_id) not in stale:
+ continue # a subject that is no longer in the set keeps its history, but is not shown
+ page = service.history(
+ project_id, record, SubjectKind(kind), subject_id, limit=RECENT_HISTORY
+ )
+ current = page.revisions[0]
+ assert current.revision_digest == digest
+ view["verdicts"].append(
+ {
+ **revision_json(current),
+ "currentness": stale[(kind, subject_id)].model_dump(mode="json"),
+ "confirmations": [
+ item.model_dump(mode="json") for item in record.confirmations_of(digest)
+ ],
+ "recent_history": [revision_json(item) for item in page.revisions],
+ "history_total": len(record.history[subject_key(kind, subject_id)]),
+ }
+ )
+ view["confirmations"] = [item.model_dump(mode="json") for item in record.confirmations]
+ view["pending_changes"] = [item.model_dump(mode="json") for item in record.pending_changes]
+ return view
diff --git a/src/thoth/application/workflows/thread_cycle.py b/src/thoth/application/workflows/thread_cycle.py
index 4431603..32c8ccd 100644
--- a/src/thoth/application/workflows/thread_cycle.py
+++ b/src/thoth/application/workflows/thread_cycle.py
@@ -23,6 +23,7 @@
FullMemoryPromotionResult,
FullProjectMemoryService,
)
+from thoth.application.services.hypothesis_contract_v3 import generator_contract, settle_contract
from thoth.application.services.memory_relation_resolver import MemoryRelationBudget
from thoth.application.services.research_commit_scope import ReadScope, read_scope
from thoth.application.services.research_identity_service import (
@@ -207,8 +208,7 @@ async def execute(self, command: ThreadCycleCommand) -> ThreadCycleResult:
project_id=command.project_id,
cutoff_at=command.cutoff_at,
context_pack=context,
- output_model=HypothesisPortfolio,
- prompt_version="hypothesis_portfolio.v2",
+ **generator_contract(work is not None and work.hypothesis_contract_v3),
model_policy_ref=command.model_policy_ref,
max_output_tokens=4_000,
)
@@ -222,7 +222,7 @@ async def execute(self, command: ThreadCycleCommand) -> ThreadCycleResult:
for status in assessment.derived_status
)
portfolio = validate_hypothesis_portfolio(
- hypothesis_result.output,
+ settle_contract(hypothesis_result.output, command.evidence),
evidence=command.evidence,
require_unknown_alternative=require_unknown,
)
diff --git a/src/thoth/apps/action_draft_composition.py b/src/thoth/apps/action_draft_composition.py
new file mode 100644
index 0000000..ad1cda4
--- /dev/null
+++ b/src/thoth/apps/action_draft_composition.py
@@ -0,0 +1,16 @@
+"""Compose the action draft made from a discriminating test."""
+
+from __future__ import annotations
+
+from thoth.application.commands.action_from_test import ActionFromTestHandlers
+from thoth.application.services.hypothesis_link_view import HypothesisLinkReader
+from thoth.application.services.request_records import RequestRecords
+from thoth.protocol.registry import MethodRegistry
+
+
+def install_action_drafts(registry: MethodRegistry, records: RequestRecords) -> None:
+ """Call after the hypothesis links are installed, so the draft goes through the same guard."""
+ handlers = ActionFromTestHandlers(
+ reader=HypothesisLinkReader(records.ledger), create=registry.resolve("action/create")
+ )
+ registry.register("action/draft/fromTest", handlers.draft)
diff --git a/src/thoth/apps/hypothesis_link_composition.py b/src/thoth/apps/hypothesis_link_composition.py
new file mode 100644
index 0000000..aba8c8f
--- /dev/null
+++ b/src/thoth/apps/hypothesis_link_composition.py
@@ -0,0 +1,73 @@
+"""Compose the hypothesis link methods and the two places where a stale link is refused."""
+
+from __future__ import annotations
+
+import functools
+
+from pydantic import JsonValue
+
+from thoth.application.commands.hypothesis_link import HypothesisLinkHandlers
+from thoth.application.services.hypothesis_link_recheck import HypothesisLinkRechecks
+from thoth.application.services.hypothesis_link_view import (
+ HYPOTHESIS_BASIS_CHANGED,
+ HypothesisLinkReader,
+)
+from thoth.application.services.request_records import RequestRecords
+from thoth.ports.project import ProjectStorePort
+from thoth.protocol.jsonrpc import RpcApplicationError, RpcErrorCode
+from thoth.protocol.registry import CommandHandler, MethodRegistry
+
+
+def install_hypothesis_links(
+ registry: MethodRegistry, records: RequestRecords, projects: ProjectStorePort
+) -> None:
+ reader = HypothesisLinkReader(records.ledger)
+ handlers = HypothesisLinkHandlers(
+ reader=reader, rechecks=HypothesisLinkRechecks(records), projects=projects
+ )
+ registry.register("hypothesis/link/list", handlers.list)
+ registry.register("hypothesis/link/recheck", handlers.recheck)
+ registry.decorate(
+ "action/authorization/prepare", lambda inner: _refuse_stale_plan(inner, reader)
+ )
+ for method in ("action/create", "action/generate"):
+ registry.decorate(method, lambda inner: _refuse_stale_references(inner, reader))
+
+
+def _refuse_stale_plan(inner: CommandHandler, reader: HypothesisLinkReader) -> CommandHandler:
+ """Preparing an approval for a plan whose actions rest on a changed verdict is refused."""
+
+ @functools.wraps(inner)
+ async def guarded(value: dict[str, JsonValue]):
+ project_id, digest = value.get("project_id"), value.get("plan_revision_digest")
+ if isinstance(project_id, str) and isinstance(digest, str):
+ try:
+ reader.require_plan_revision_hypotheses_current(project_id, digest)
+ except ValueError as exc:
+ raise RpcApplicationError(RpcErrorCode.DOMAIN_REJECTED, str(exc)) from exc
+ return await inner(value)
+
+ owner = getattr(inner, "__self__", None)
+ if owner is not None:
+ guarded.__self__ = owner # type: ignore[attr-defined]
+ return guarded
+
+
+def _refuse_stale_references(inner: CommandHandler, reader: HypothesisLinkReader) -> CommandHandler:
+ """A new action is not made on a hypothesis whose trace verdict has changed."""
+
+ @functools.wraps(inner)
+ async def guarded(value: dict[str, JsonValue]):
+ project_id, references = value.get("project_id"), value.get("hypothesis_refs")
+ named = tuple(str(item) for item in references) if isinstance(references, list) else ()
+ if isinstance(project_id, str) and reader.stale_hypothesis_ids(project_id, named):
+ raise RpcApplicationError(RpcErrorCode.DOMAIN_REJECTED, HYPOTHESIS_BASIS_CHANGED)
+ return await inner(value)
+
+ owner = getattr(inner, "__self__", None)
+ if owner is not None:
+ guarded.__self__ = owner # type: ignore[attr-defined]
+ return guarded
+
+
+__all__ = ["HYPOTHESIS_BASIS_CHANGED", "install_hypothesis_links"]
diff --git a/src/thoth/apps/judgment_records_composition.py b/src/thoth/apps/judgment_records_composition.py
new file mode 100644
index 0000000..ab418bc
--- /dev/null
+++ b/src/thoth/apps/judgment_records_composition.py
@@ -0,0 +1,62 @@
+"""Compose the judgment records: test results, refutation conditions and trace closures."""
+
+from __future__ import annotations
+
+import functools
+
+from pydantic import JsonValue
+
+from thoth.application.commands.discrimination import DiscriminationHandlers
+from thoth.application.commands.hypothesis_same import SameHandlers
+from thoth.application.commands.lesson import LessonHandlers
+from thoth.application.commands.trace_closure import ClosureHandlers
+from thoth.application.services.discrimination_ledger import DiscriminationLedger
+from thoth.application.services.hypothesis_same import HypothesisSame
+from thoth.application.services.lesson_ledger import LessonLedger
+from thoth.application.services.request_records import RequestRecords
+from thoth.application.services.trace_closure import TraceClosures
+from thoth.ports.project import ProjectStorePort
+from thoth.protocol.registry import CommandHandler, MethodRegistry
+
+
+def install_judgment_records(
+ registry: MethodRegistry, records: RequestRecords, projects: ProjectStorePort
+) -> None:
+ discrimination = DiscriminationHandlers(ledger=DiscriminationLedger(records), projects=projects)
+ registry.register("hypothesis/test/result/list", discrimination.list)
+ registry.register("hypothesis/test/result/record", discrimination.record_result)
+ registry.register("hypothesis/refutation/record", discrimination.record_conditions)
+ trace_closures = TraceClosures(records)
+ closures = ClosureHandlers(closures=trace_closures, projects=projects)
+ registry.register("trace/closure/list", closures.list)
+ registry.register("trace/closure/record", closures.record)
+ lessons = LessonLedger(records)
+ same = SameHandlers(same=HypothesisSame(records), projects=projects)
+ registry.register("hypothesis/same/list", same.list)
+ registry.register("hypothesis/same/record", same.record)
+ registry.register(
+ "trace/lesson/list",
+ LessonHandlers(lessons=lessons, closures=trace_closures, projects=projects).list,
+ )
+ registry.decorate(
+ "trace/importApply", lambda inner: _lessons_after_import(inner, lessons, trace_closures)
+ )
+
+
+def _lessons_after_import(
+ inner: CommandHandler, lessons: LessonLedger, closures: TraceClosures
+) -> CommandHandler:
+ """New results may confirm the effect of a recorded fix: a lesson, written right after."""
+
+ @functools.wraps(inner)
+ async def applied(value: dict[str, JsonValue]):
+ outcome = await inner(value)
+ project_id = value.get("project_id")
+ if isinstance(project_id, str) and isinstance(outcome, dict):
+ lessons.sync_effects(project_id, closures.view(project_id))
+ return outcome
+
+ owner = getattr(inner, "__self__", None)
+ if owner is not None:
+ applied.__self__ = owner # type: ignore[attr-defined]
+ return applied
diff --git a/src/thoth/apps/memory_composition.py b/src/thoth/apps/memory_composition.py
index 22d70c8..712f7db 100644
--- a/src/thoth/apps/memory_composition.py
+++ b/src/thoth/apps/memory_composition.py
@@ -121,6 +121,7 @@ def create_memory_components(
) -> tuple[FullMemoryStorePort, FullProjectMemoryService]:
store = stores.full_memory(fault_injector)
access = None if resource_access is None else MemoryResourceAccess(store, resource_access)
+ settings = create_memory_settings(stores, clock, ids)
service = FullProjectMemoryService(
store=store,
candidates=candidates,
@@ -138,7 +139,8 @@ def create_memory_components(
clock=clock,
),
resource_access=access,
- injection=create_memory_settings(stores, clock, ids),
+ injection=settings,
+ expansion_switch=settings,
)
return (store if access is None else ScopedFullMemoryStore(store, access)), service
diff --git a/src/thoth/apps/research_entry_composition.py b/src/thoth/apps/research_entry_composition.py
index fd628a8..d594760 100644
--- a/src/thoth/apps/research_entry_composition.py
+++ b/src/thoth/apps/research_entry_composition.py
@@ -61,7 +61,11 @@
from thoth.application.services.research_analysis import ResearchAnalysis
from thoth.application.services.research_leases import ResearchLeases
from thoth.application.services.scoped_artifacts import ScopedArtifactLedger
+from thoth.apps.action_draft_composition import install_action_drafts
+from thoth.apps.hypothesis_link_composition import install_hypothesis_links
+from thoth.apps.judgment_records_composition import install_judgment_records
from thoth.apps.test_runtime import TestComponents
+from thoth.apps.trace_composition import install_trace_origin, register_trace_methods
from thoth.apps.workspace_paths import workspace_id
from thoth.domain.deployment_mode import DeploymentMode, parse_deployment_mode
from thoth.ports.criterion_contract import CriterionContractStorePort
@@ -221,6 +225,7 @@ def create_research_entry(
)
registry.register("model/callSettings/read", call_settings_handlers.read)
registry.register("model/callSettings/update", call_settings_handlers.update)
+ register_trace_methods(registry, records, stores.projects)
ready_projection = None
if mode is DeploymentMode.HOSTED_REVIEW:
from thoth.apps.hosted_review_composition import hosted_ready_projection
@@ -282,6 +287,10 @@ def create_research_entry(
registry.decorate("thread/stop", lambda _: entry.stop)
for method in ("thread/start", "thread/input", "thread/steer"):
registry.register_reentry_authorizer(method, entry.authorize_reentry)
+ install_trace_origin(registry, records, stores.operations)
+ install_hypothesis_links(registry, records, stores.projects)
+ install_action_drafts(registry, records)
+ install_judgment_records(registry, records, stores.projects)
if mode is DeploymentMode.HOSTED_REVIEW:
from thoth.apps.hosted_review_composition import install_hosted_review_rpc_guards
from thoth.apps.hosted_review_quota import HostedReviewLimits
diff --git a/src/thoth/apps/runtime.py b/src/thoth/apps/runtime.py
index 21f4533..ff770b0 100644
--- a/src/thoth/apps/runtime.py
+++ b/src/thoth/apps/runtime.py
@@ -443,8 +443,8 @@ def workspace_setup():
execution_handlers = ExecutionHandlers(
store=execution_store,
actions=action_store,
- service=execution_service,
- sandbox=sandbox_service,
+ service=execution_service, sandbox=sandbox_service,
+ ledger=ledger,
)
outcome_handlers = OutcomeHandlers(store=outcome_store, service=outcome_service)
export_handlers = create_export_handlers(
diff --git a/src/thoth/apps/trace_composition.py b/src/thoth/apps/trace_composition.py
new file mode 100644
index 0000000..82436c2
--- /dev/null
+++ b/src/thoth/apps/trace_composition.py
@@ -0,0 +1,43 @@
+"""Compose the trace methods: the requirement-to-result table, its verdicts and its CSV."""
+
+from __future__ import annotations
+
+from thoth.application.commands.trace_origin_admission import with_thread_origins
+from thoth.application.commands.verification_trace import TraceHandlers
+from thoth.application.services.request_records import RequestRecords
+from thoth.application.services.trace_closure import TraceClosures
+from thoth.application.services.trace_closure_text import closure_status_cells
+from thoth.application.services.verification_trace import VerificationTraceService
+from thoth.application.services.verification_trace_import import TraceImportService
+from thoth.ports.operation import OperationStorePort
+from thoth.ports.project import ProjectStorePort
+from thoth.protocol.registry import MethodRegistry
+
+
+def register_trace_methods(
+ registry: MethodRegistry, records: RequestRecords, projects: ProjectStorePort
+) -> None:
+ service = VerificationTraceService(records)
+ closures = TraceClosures(records)
+ handlers = TraceHandlers(
+ service=service,
+ importer=TraceImportService(service),
+ projects=projects,
+ closure_notes=lambda project_id: closure_status_cells(
+ closures.view(project_id, with_current=False)
+ ),
+ )
+ registry.register("trace/read", handlers.read)
+ registry.register("trace/history", handlers.history)
+ registry.register("trace/export", handlers.export)
+ registry.register("trace/importPreview", handlers.import_preview)
+ registry.register("trace/importApply", handlers.import_apply)
+ registry.register("trace/confirm", handlers.confirm)
+
+
+def install_trace_origin(
+ registry: MethodRegistry, records: RequestRecords, operations: OperationStorePort
+) -> None:
+ """thread/list tells which trace row each thread started from. The origin of a research
+ request itself is admitted inside the research handlers (trace_origin_admission)."""
+ registry.decorate("thread/list", lambda inner: with_thread_origins(inner, records, operations))
diff --git a/src/thoth/domain/action.py b/src/thoth/domain/action.py
index f609938..23951c2 100644
--- a/src/thoth/domain/action.py
+++ b/src/thoth/domain/action.py
@@ -78,6 +78,15 @@ def band_needs_basis(self) -> OrdinalEstimate:
return self
+# Effects no one may delegate (R4). Every classifier of action effects reads this one list.
+PROHIBITED_EFFECT_KEYS = (
+ "changes_official_kpi",
+ "grants_waiver",
+ "changes_safety_threshold",
+ "finalizes_model_weights",
+)
+
+
class ActionRiskFacts(DomainModel):
changes_local_draft: bool = False
runs_untrusted_code: bool = False
diff --git a/src/thoth/domain/action_full.py b/src/thoth/domain/action_full.py
index 521f702..779f9f4 100644
--- a/src/thoth/domain/action_full.py
+++ b/src/thoth/domain/action_full.py
@@ -1,12 +1,21 @@
from __future__ import annotations
-from pydantic import AwareDatetime, model_validator
+from typing import cast
+
+from pydantic import AwareDatetime, Field, model_validator
from thoth.domain.base import DomainModel
from thoth.domain.ids import ProjectId, Sha256
from thoth.domain.research_projection import ActionGenerationDetails, ActionPlanGenerationDetails
+class ActionTestRef(DomainModel):
+ """The discriminating test (of one hypothesis) an action request was drafted from."""
+
+ hypothesis_id: str = Field(min_length=1, max_length=200)
+ test_id: str = Field(min_length=1, max_length=200)
+
+
class ActionRecord(DomainModel):
action_revision_id: str
action_id: str
@@ -14,6 +23,7 @@ class ActionRecord(DomainModel):
object_id: str
portfolio_id: str
hypothesis_refs: tuple[str, ...] = ()
+ test_refs: tuple[ActionTestRef, ...] = ()
primary_purpose: str | None
secondary_purposes: tuple[str, ...] = ()
specification: dict[str, object]
@@ -36,6 +46,28 @@ class ActionRecord(DomainModel):
generation_details: ActionGenerationDetails | None = None
schema_version: str = "1.0.0"
+ @model_validator(mode="before")
+ @classmethod
+ def read_test_refs_from_the_specification(cls, data: object) -> object:
+ """An older record names its test in its specification; the record carries it as a field.
+
+ A new request sends the refs as a field of its own (covered by the revision digest), and
+ the specification does not carry them. A record stored before that kept them inside its
+ specification (also covered by the digest); they are read from there if the field is empty.
+ """
+ if not isinstance(data, dict):
+ return data
+ fields = cast("dict[str, object]", data)
+ if fields.get("test_refs"):
+ return fields
+ specification = fields.get("specification")
+ found = (
+ cast("dict[str, object]", specification).get("test_refs")
+ if isinstance(specification, dict)
+ else None
+ )
+ return {**fields, "test_refs": found} if found else fields
+
@model_validator(mode="after")
def enforce_policy_boundary(self) -> ActionRecord:
if self.primary_purpose is None and self.proposal_state != "DRAFT":
diff --git a/src/thoth/domain/canonical.py b/src/thoth/domain/canonical.py
index 99dbff2..a06ebcd 100644
--- a/src/thoth/domain/canonical.py
+++ b/src/thoth/domain/canonical.py
@@ -28,6 +28,25 @@ def normalize_timestamp(value: datetime) -> str:
return utc_value.isoformat(timespec="milliseconds").replace("+00:00", "Z")
+def same_stored_instant(left: object, right: object) -> bool:
+ """Whether two timestamps are the same moment at the precision timestamps are stored in.
+
+ A project keeps its cutoff exactly as typed (it can have microseconds), while a stored record
+ or result keeps milliseconds (normalize_timestamp). Comparing the two as plain datetimes
+ reports a change that never happened, so cutoffs are compared the way they are stored. A
+ difference of a millisecond or more is still a change, and the time zone does not matter.
+ Anything that is not a timezone-aware datetime is compared as it is.
+ """
+ if (
+ isinstance(left, datetime)
+ and isinstance(right, datetime)
+ and left.utcoffset() is not None
+ and right.utcoffset() is not None
+ ):
+ return normalize_timestamp(left) == normalize_timestamp(right)
+ return left == right
+
+
def _canonical_key(value: object) -> bytes:
return json.dumps(
value,
diff --git a/src/thoth/domain/discrimination.py b/src/thoth/domain/discrimination.py
new file mode 100644
index 0000000..3af20c6
--- /dev/null
+++ b/src/thoth/domain/discrimination.py
@@ -0,0 +1,107 @@
+"""Test results and refutation conditions a person records for a hypothesis.
+
+Both are kept apart from the hypothesis, as appended events, so what the model produced is never
+edited and nothing a person wrote is overwritten: the latest result of a test is the one that
+counts, and the earlier ones stay in the ledger. An observation that matches the alternative
+explanation eliminates the hypothesis within what was observed (never a deletion): once for one
+result, repeated for results of two different tests.
+"""
+
+from __future__ import annotations
+
+from typing import Annotated, Literal
+
+from pydantic import AwareDatetime, Field
+
+from thoth.domain.base import DomainModel
+
+MatchKind = Literal["THIS_HYPOTHESIS", "ALTERNATIVE", "NEITHER", "UNDETERMINED"]
+EliminationState = Literal["SINGLE", "REPEATED"]
+RecordedStanding = Literal["NONE", "FITS", "AGAINST_ONCE", "AGAINST_REPEATED", "MIXED"]
+MAX_CONDITIONS = 10
+
+
+class DiscriminationResult(DomainModel):
+ """What was observed when one discriminating test was done, and which explanation it fit."""
+
+ event_id: str = Field(min_length=1)
+ hypothesis_id: str = Field(min_length=1, max_length=200)
+ hypothesis_revision_digest: str = Field(min_length=64, max_length=64)
+ test_id: str = Field(min_length=1, max_length=200)
+ observation: str = Field(min_length=1, max_length=2_000)
+ matched: MatchKind
+ evidence_refs: tuple[str, ...] = Field(default=(), max_length=20)
+ actor_id: str = Field(min_length=1, max_length=160)
+ created_at: AwareDatetime
+
+
+class RefutationConditions(DomainModel):
+ """What would show the hypothesis wrong, as the person wrote it (empty clears the list)."""
+
+ event_id: str = Field(min_length=1)
+ hypothesis_id: str = Field(min_length=1, max_length=200)
+ conditions: tuple[Annotated[str, Field(min_length=1, max_length=500)], ...] = Field(
+ default=(), max_length=MAX_CONDITIONS
+ )
+ actor_id: str = Field(min_length=1, max_length=160)
+ created_at: AwareDatetime
+
+
+class DiscriminationLedgerRecord(DomainModel):
+ """Every result and condition list of the project, oldest first; a write adds, never removes."""
+
+ record_kind: Literal["DiscriminationLedgerRecord"] = "DiscriminationLedgerRecord"
+ schema_version: Literal["2.0.0"] = "2.0.0"
+ project_id: str
+ results: tuple[DiscriminationResult, ...] = ()
+ conditions: tuple[RefutationConditions, ...] = ()
+
+
+def latest_results(
+ events: tuple[DiscriminationResult, ...], hypothesis_id: str
+) -> dict[str, DiscriminationResult]:
+ """The result that counts for each test of this hypothesis: the last one recorded."""
+ found: dict[str, DiscriminationResult] = {}
+ for item in events:
+ if item.hypothesis_id == hypothesis_id:
+ found[item.test_id] = item
+ return found
+
+
+def elimination(
+ events: tuple[DiscriminationResult, ...], hypothesis_id: str
+) -> EliminationState | None:
+ """SINGLE for one test whose result fits the alternative, REPEATED for two different tests."""
+ fitting = sum(
+ 1
+ for item in latest_results(events, hypothesis_id).values()
+ if item.matched == "ALTERNATIVE"
+ )
+ if fitting == 0:
+ return None
+ return "REPEATED" if fitting >= 2 else "SINGLE"
+
+
+def recorded_standing(
+ events: tuple[DiscriminationResult, ...], hypothesis_id: str
+) -> RecordedStanding:
+ """Where the recorded test results leave this hypothesis, read when asked and never stored.
+
+ Only the latest result of each test counts, and only a result that fits this hypothesis or the
+ alternative says anything: neither and undetermined are not counted. This is a reading of what
+ a person recorded, kept apart from the sealed-test appraisal of the hypothesis (that one is
+ not touched), and it changes nothing.
+ """
+ kinds = {item.matched for item in latest_results(events, hypothesis_id).values()}
+ fits, against = "THIS_HYPOTHESIS" in kinds, "ALTERNATIVE" in kinds
+ if fits and against:
+ return "MIXED"
+ if fits:
+ return "FITS"
+ if against:
+ return (
+ "AGAINST_REPEATED"
+ if elimination(events, hypothesis_id) == "REPEATED"
+ else "AGAINST_ONCE"
+ )
+ return "NONE"
diff --git a/src/thoth/domain/enums.py b/src/thoth/domain/enums.py
index 4c36ba3..cffb01b 100644
--- a/src/thoth/domain/enums.py
+++ b/src/thoth/domain/enums.py
@@ -90,6 +90,7 @@ class ModelRole(StrEnum):
USER_EXPLAINER = "USER_EXPLAINER"
REFERENCE_MAPPER = "REFERENCE_MAPPER"
MEMORY_RELATION_JUDGE = "MEMORY_RELATION_JUDGE"
+ MEMORY_QUERY_EXPANDER = "MEMORY_QUERY_EXPANDER"
class ImpactStatus(StrEnum):
diff --git a/src/thoth/domain/hypothesis.py b/src/thoth/domain/hypothesis.py
index 36c3201..24e4244 100644
--- a/src/thoth/domain/hypothesis.py
+++ b/src/thoth/domain/hypothesis.py
@@ -1,9 +1,9 @@
from __future__ import annotations
from decimal import Decimal
-from typing import Literal
+from typing import Annotated, Any, Literal
-from pydantic import Field, model_validator
+from pydantic import Field, PrivateAttr, SerializeAsAny, model_validator
from thoth.domain.base import DomainModel
from thoth.domain.counterevidence import HypothesisCriticalReview
@@ -46,7 +46,8 @@ class Hypothesis(DomainModel):
assumptions: tuple[str, ...]
uncertainty: str
predicted_observations: tuple[str, ...]
- discriminating_tests: tuple[DiscriminatingTest, ...]
+ # Dumped as what they are, so a v3 test keeps its extra fields wherever the portfolio is dumped.
+ discriminating_tests: tuple[SerializeAsAny[DiscriminatingTest], ...]
status: HypothesisStatus
primary_intent: (
Literal[
@@ -85,7 +86,7 @@ def require_testable_content(self) -> Hypothesis:
class HypothesisPortfolio(DomainModel):
portfolio_id: str
object_id: DecisionObjectId
- hypotheses: tuple[Hypothesis, ...]
+ hypotheses: tuple[SerializeAsAny[Hypothesis], ...]
status: PortfolioStatus
generated_from_head_set: Sha256
alternatives_considered: tuple[str, ...] = ()
@@ -106,3 +107,51 @@ def require_material_alternatives(self) -> HypothesisPortfolio:
if len({h.hypothesis_id for h in self.hypotheses}) != len(self.hypotheses):
raise ValueError("duplicate hypothesis ID")
return self
+
+
+class ExpectedResult(DomainModel):
+ """What one hypothesis expects from a test, as a short label: the same label is the same result.
+
+ "모름" says the generator does not know; no probability or score exists here.
+ """
+
+ hypothesis_id: HypothesisId
+ expected: str = Field(min_length=1, max_length=80)
+ basis: tuple[EvidenceSpanId, ...] = Field(default=(), max_length=5)
+
+
+class DiscriminatingTestV3(DiscriminatingTest):
+ expected_by_hypothesis: tuple[ExpectedResult, ...] = Field(default=(), max_length=10)
+
+
+class HypothesisV3(Hypothesis):
+ discriminating_tests: tuple[SerializeAsAny[DiscriminatingTestV3], ...] # pyright: ignore[reportIncompatibleVariableOverride]
+ refutation_conditions: tuple[Annotated[str, Field(min_length=1, max_length=300)], ...] = Field(
+ default=(), max_length=5
+ )
+ # What settling the generator's output dropped (see hypothesis_contract_v3); never part of the
+ # form the model fills in.
+ _dropped: tuple[Any, ...] = PrivateAttr(default=())
+
+ @property
+ def dropped(self) -> tuple[Any, ...]:
+ return self._dropped
+
+ def note_dropped(self, items: tuple[Any, ...]) -> None:
+ self._dropped = items
+
+
+class HypothesisPortfolioV3(HypothesisPortfolio):
+ """The v3 output form: a v2 portfolio whose hypotheses may add expected results."""
+
+ hypotheses: tuple[SerializeAsAny[HypothesisV3], ...] # pyright: ignore[reportIncompatibleVariableOverride]
+
+
+def is_v3(portfolio: HypothesisPortfolio) -> bool:
+ """Whether this portfolio is the v3 form (the one a v3 generator call produced)."""
+ return isinstance(portfolio, HypothesisPortfolioV3)
+
+
+def portfolio_from_json(value: dict[str, Any], *, v3: bool) -> HypothesisPortfolio:
+ """A portfolio from its JSON, in the form the caller says it has (v2 or v3)."""
+ return (HypothesisPortfolioV3 if v3 else HypothesisPortfolio).model_validate(value)
diff --git a/src/thoth/domain/hypothesis_full.py b/src/thoth/domain/hypothesis_full.py
index bde2633..b999ea8 100644
--- a/src/thoth/domain/hypothesis_full.py
+++ b/src/thoth/domain/hypothesis_full.py
@@ -6,6 +6,7 @@
from thoth.domain.ids import ProjectId, Sha256
from thoth.domain.research_projection import HypothesisGenerationDetails, PortfolioGenerationDetails
from thoth.domain.test_validity import Prespecification, ResearchMeasurementContract
+from thoth.domain.verdict_link import VerdictLink
class HypothesisRecord(DomainModel):
@@ -40,6 +41,8 @@ class HypothesisRecord(DomainModel):
receipt_ref: str | None = None
created_at: AwareDatetime
generation_details: HypothesisGenerationDetails | None = None
+ # The trace verdict this hypothesis was investigated from; absent on any other hypothesis.
+ verdict_link: VerdictLink | None = None
schema_version: str = "1.0.0"
@model_validator(mode="after")
diff --git a/src/thoth/domain/hypothesis_same.py b/src/thoth/domain/hypothesis_same.py
new file mode 100644
index 0000000..2756ded
--- /dev/null
+++ b/src/thoth/domain/hypothesis_same.py
@@ -0,0 +1,77 @@
+"""Hypotheses a person marked as the same one across investigations.
+
+Asking the same question of the same trace row again makes new hypothesis ids, so what was found
+about the earlier ones does not meet the new ones. Only a person says two of them are the same
+hypothesis, and may take that back; nothing is paired by how the sentences read. A mark is an
+appended event (LINK or UNLINK). A current link is a pair that was linked and not unlinked since,
+and a group is everything joined by current links. The groups are a reference: they are read to
+show earlier results and to widen a lesson's refutation, and they never merge counts, rankings or
+the elimination of a hypothesis.
+"""
+
+from __future__ import annotations
+
+from typing import Annotated, Literal, Self
+
+from pydantic import AwareDatetime, Field, model_validator
+
+from thoth.domain.base import DomainModel
+
+SameAction = Literal["LINK", "UNLINK"]
+HypothesisKey = Annotated[str, Field(min_length=1, max_length=200)]
+
+
+class SameHypothesisEvent(DomainModel):
+ event_id: str = Field(min_length=1)
+ hypothesis_ids: tuple[HypothesisKey, HypothesisKey]
+ action: SameAction
+ note: str = Field(default="", max_length=500)
+ actor_id: str = Field(min_length=1, max_length=160)
+ created_at: AwareDatetime
+
+ @model_validator(mode="after")
+ def _two_different_stored_in_order(self) -> Self:
+ first, second = self.hypothesis_ids
+ if first == second:
+ raise ValueError("SAME_IDENTICAL")
+ if first > second:
+ object.__setattr__(self, "hypothesis_ids", (second, first))
+ return self
+
+
+class SameHypothesisRecord(DomainModel):
+ """Every mark of the project, oldest first; a write adds one and removes none."""
+
+ record_kind: Literal["SameHypothesisRecord"] = "SameHypothesisRecord"
+ schema_version: Literal["2.0.0"] = "2.0.0" # the research record codec reads this version
+ project_id: str
+ events: tuple[SameHypothesisEvent, ...] = ()
+
+
+def current_pairs(record: SameHypothesisRecord) -> set[tuple[str, str]]:
+ """The pairs that are linked now: the latest mark of a pair decides."""
+ pairs: set[tuple[str, str]] = set()
+ for item in record.events:
+ if item.action == "LINK":
+ pairs.add(item.hypothesis_ids)
+ else:
+ pairs.discard(item.hypothesis_ids)
+ return pairs
+
+
+def groups(record: SameHypothesisRecord) -> list[frozenset[str]]:
+ """The sets of hypotheses joined by current links, each with at least two, in a fixed order."""
+ joined: list[set[str]] = []
+ for first, second in sorted(current_pairs(record)):
+ touching = [group for group in joined if first in group or second in group]
+ merged = {first, second}.union(*touching)
+ joined = [group for group in joined if group not in touching]
+ joined.append(merged)
+ return sorted((frozenset(group) for group in joined), key=sorted)
+
+
+def group_of(record: SameHypothesisRecord, hypothesis_id: str) -> frozenset[str]:
+ """The hypotheses marked the same as this one, itself included (just itself when none)."""
+ return next(
+ (group for group in groups(record) if hypothesis_id in group), frozenset({hypothesis_id})
+ )
diff --git a/src/thoth/domain/lesson_ref.py b/src/thoth/domain/lesson_ref.py
new file mode 100644
index 0000000..588edfd
--- /dev/null
+++ b/src/thoth/domain/lesson_ref.py
@@ -0,0 +1,145 @@
+"""Lessons: references to what a rule computed or a person recorded, with the condition they held.
+
+A lesson is never a sentence. It names a kind, a coded outcome, the records it rests on and the
+context those records were made in (the criterion, the unit, the condition and the rule revision).
+Only four kinds exist, and each takes only the sources and outcomes of its own kind, so what a
+model wrote (a hypothesis sentence, a reflection) and a fix whose effect was never confirmed
+cannot be stored as one. A lesson is recalled for a row only when every part of the context
+matches; it leaves the recall when the record it rests on changed (stale) or when a later record in
+the same context says the opposite about the same hypothesis (refuted). A person may mark
+hypotheses of different investigations as the same one; a lesson is then refuted by a later
+opposite result of any hypothesis in that group, and only that: counts and rankings stay per
+hypothesis. Nothing is deleted.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from typing import Literal, Self
+
+from pydantic import AwareDatetime, Field, model_validator
+
+from thoth.domain.base import DomainModel
+from thoth.domain.canonical import canonical_payload, domain_digest
+
+LessonKind = Literal["TEST_RESULT", "ELIMINATION", "EFFECT_CONFIRMED", "HUMAN_CLOSURE"]
+SourceKind = Literal["DISCRIMINATION_RESULT", "TRACE_CLOSURE"]
+LessonState = Literal["SAME_CONDITION", "STALE", "REFUTED"]
+
+# What each kind may say, and what it may rest on. There is no room for a sentence.
+_OUTCOMES: dict[str, frozenset[str]] = {
+ "TEST_RESULT": frozenset({"THIS_HYPOTHESIS", "ALTERNATIVE", "NEITHER", "UNDETERMINED"}),
+ "ELIMINATION": frozenset({"SINGLE", "REPEATED"}),
+ "EFFECT_CONFIRMED": frozenset({"CONFIRMED"}),
+ "HUMAN_CLOSURE": frozenset({"HUMAN_CLOSED", "WAIVER_RECORDED", "CONDITION_CHANGED"}),
+}
+_SOURCES: dict[str, str] = {
+ "TEST_RESULT": "DISCRIMINATION_RESULT",
+ "ELIMINATION": "DISCRIMINATION_RESULT",
+ "EFFECT_CONFIRMED": "TRACE_CLOSURE",
+ "HUMAN_CLOSURE": "TRACE_CLOSURE",
+}
+_OPPOSITE = {"ALTERNATIVE": "THIS_HYPOTHESIS", "THIS_HYPOTHESIS": "ALTERNATIVE"}
+
+
+class LessonContext(DomainModel):
+ """What the row's verdict stood on when the record was made: all of it must match to recall."""
+
+ subject_kind: Literal["CRITERION", "REQUIREMENT"]
+ subject_id: str = Field(min_length=1, max_length=500)
+ unit: str | None = Field(default=None, max_length=100)
+ condition: str | None = Field(default=None, max_length=500)
+ rule_id: str | None = Field(default=None, max_length=500)
+ rule_revision: int | None = None
+
+
+class LessonSource(DomainModel):
+ record_kind: SourceKind
+ record_id: str = Field(min_length=1, max_length=200)
+
+
+class LessonRef(DomainModel):
+ lesson_id: str = Field(min_length=1, max_length=200)
+ kind: LessonKind
+ outcome: str = Field(min_length=1, max_length=40)
+ sources: tuple[LessonSource, ...] = Field(min_length=1, max_length=20)
+ fingerprint: str = Field(min_length=1, max_length=200) # the sources as they stood
+ context: LessonContext
+ hypothesis_id: str | None = Field(default=None, max_length=200)
+ test_id: str | None = Field(default=None, max_length=200)
+ actor_id: str = Field(min_length=1, max_length=160)
+ created_at: AwareDatetime
+
+ @model_validator(mode="after")
+ def _only_what_a_rule_or_a_person_recorded(self) -> Self:
+ if self.outcome not in _OUTCOMES[self.kind]:
+ raise ValueError("LESSON_OUTCOME_NOT_OF_KIND")
+ if any(item.record_kind != _SOURCES[self.kind] for item in self.sources):
+ raise ValueError("LESSON_SOURCE_NOT_OF_KIND")
+ return self
+
+
+class LessonReferenceRecord(DomainModel):
+ """Every lesson of the project, oldest first; a write adds lessons and removes none."""
+
+ record_kind: Literal["LessonReferenceRecord"] = "LessonReferenceRecord"
+ schema_version: Literal["2.0.0"] = "2.0.0"
+ project_id: str
+ refs: tuple[LessonRef, ...] = ()
+
+
+def lesson_id_of(kind: str, outcome: str, sources: Sequence[LessonSource]) -> str:
+ """The same lesson from the same records always has the same id, so it is added once."""
+ payload: dict[str, object] = {
+ "kind": kind,
+ "outcome": outcome,
+ "sources": sorted(f"{item.record_kind}:{item.record_id}" for item in sources),
+ }
+ return domain_digest("LESSON", "1.0.0", canonical_payload(payload))
+
+
+def fingerprint_of(parts: Mapping[str, object]) -> str:
+ return domain_digest("LESSON_FINGERPRINT", "1.0.0", canonical_payload(parts))
+
+
+def _claim(ref: LessonRef) -> str | None:
+ """What the lesson says about its hypothesis, when it says anything that can be opposed."""
+ if ref.hypothesis_id is None:
+ return None
+ if ref.kind == "ELIMINATION":
+ return "ALTERNATIVE"
+ return ref.outcome if ref.kind == "TEST_RESULT" and ref.outcome in _OPPOSITE else None
+
+
+def recall_lessons(
+ refs: Sequence[LessonRef],
+ current: LessonContext,
+ fingerprints: Mapping[str, str | None],
+ siblings: Mapping[str, frozenset[str]] | None = None,
+) -> list[tuple[LessonRef, LessonState]]:
+ """The lessons of this exact context with their state; other contexts are not here at all.
+
+ `fingerprints` holds the fingerprint of each lesson's sources as they stand now (None when
+ they cannot be found). `siblings` names, for a hypothesis a person marked as the same as others,
+ the whole group. Oldest first. Nothing is read from how often a result happened.
+ """
+ same = [(at, ref) for at, ref in enumerate(refs) if ref.context == current]
+ stale = {ref.lesson_id for _, ref in same if fingerprints.get(ref.lesson_id) != ref.fingerprint}
+ found: list[tuple[LessonRef, LessonState]] = []
+ groups = siblings or {}
+ for at, ref in same:
+ if ref.lesson_id in stale:
+ found.append((ref, "STALE"))
+ continue
+ claim = _claim(ref)
+ mine = groups.get(ref.hypothesis_id or "", frozenset({ref.hypothesis_id or ""}))
+ refuted = claim is not None and any(
+ later.hypothesis_id in mine
+ and later.kind == "TEST_RESULT"
+ and later.outcome == _OPPOSITE[claim]
+ and later.lesson_id not in stale
+ for index, later in same
+ if index > at
+ )
+ found.append((ref, "REFUTED" if refuted else "SAME_CONDITION"))
+ return found
diff --git a/src/thoth/domain/memory.py b/src/thoth/domain/memory.py
index 6690987..51d0b99 100644
--- a/src/thoth/domain/memory.py
+++ b/src/thoth/domain/memory.py
@@ -13,6 +13,7 @@
RecallEligibility,
)
from thoth.domain.ids import ProjectId, Sha256
+from thoth.domain.memory_expansion import MemoryExpansionRecord
class MemoryRecord(DomainModel):
@@ -157,6 +158,19 @@ class MemoryTransitionReceipt(DomainModel):
recorded_at: AwareDatetime
+class MemoryMatch(DomainModel):
+ """Why one memory met the question: its own words, or words a model added to them."""
+
+ matched_by: Literal["QUERY", "EXPANSION"]
+ words: tuple[str, ...] = ()
+ added_words: tuple[str, ...] = ()
+ # WHOLE when every matched word was found as written; PARTIAL when some word was met only
+ # through a letter pair of it (자료를 met by 자료), which is the weaker kind of match.
+ match_strength: Literal["WHOLE", "PARTIAL"] = "WHOLE"
+ partial_words: tuple[str, ...] = ()
+ partial_added_words: tuple[str, ...] = ()
+
+
class MemorySelectionRecord(DomainModel):
"""Which stored memory reached each stage of one recall, and why the rest did not.
@@ -182,6 +196,10 @@ class MemorySelectionRecord(DomainModel):
# A question that continues the previous investigation skips the word gate; the markers found.
follow_up: bool = False
follow_up_markers: tuple[str, ...] = ()
+ # How each memory that reached the recall stage met the question, by memory revision id.
+ matches: dict[str, MemoryMatch] = Field(default_factory=dict)
+ # The question-widening step (None when the record was made before it existed).
+ expansion: MemoryExpansionRecord | None = None
class FullMemoryContextPack(DomainModel):
diff --git a/src/thoth/domain/memory_expansion.py b/src/thoth/domain/memory_expansion.py
new file mode 100644
index 0000000..b99d693
--- /dev/null
+++ b/src/thoth/domain/memory_expansion.py
@@ -0,0 +1,85 @@
+"""Words a model adds to a question so a memory written in other words can still be found.
+
+The model is asked once per investigation. What it returns is only search words: it never decides
+which memory is recalled, and no score is made. A memory that the question's own words reach comes
+first; one reached only through added words follows as a separate group and is marked as such.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from typing import Literal, cast
+
+from pydantic import Field, field_validator
+
+from thoth.domain.base import DomainModel
+
+MAX_EXPANSION_ITEMS = 8
+MAX_EXPANSION_ITEM_CHARS = 40
+MAX_NOTE_LINE_CHARS = 240
+# The purpose recorded on the model call, so its usage is shown apart from the investigation's.
+MEMORY_QUERY_EXPANSION_PURPOSE = "MEMORY_QUERY_EXPANSION"
+MEMORY_QUERY_EXPANSION_LABEL = "기억 검색어 넓히기"
+
+ExpansionStatus = Literal["USED", "EXPANSION_FAILED", "EXPANSION_INVALID", "NOT_ASKED"]
+
+
+def _clean_items(value: object) -> object:
+ """Keep at most eight short, non-empty phrases; a value that is not a list is left to fail."""
+
+ if not isinstance(value, (list, tuple)):
+ return value
+ items = cast(Sequence[object], value)
+ kept: list[str] = []
+ for item in items:
+ if not isinstance(item, str):
+ return items
+ text = " ".join(item.split())
+ if text and len(text) <= MAX_EXPANSION_ITEM_CHARS and text not in kept:
+ kept.append(text)
+ return tuple(kept[:MAX_EXPANSION_ITEMS])
+
+
+class QueryExpansion(DomainModel):
+ """The model's answer: other words for the question, its topic in another language, related
+ terms, and one sentence written the way a stored memory about it might read."""
+
+ synonyms: tuple[str, ...] = ()
+ keywords: tuple[str, ...] = ()
+ related: tuple[str, ...] = ()
+ note_line: str = Field(default="", max_length=MAX_NOTE_LINE_CHARS)
+
+ @field_validator("synonyms", "keywords", "related", mode="before")
+ @classmethod
+ def _limit_items(cls, value: object) -> object:
+ return _clean_items(value)
+
+ @field_validator("note_line", mode="before")
+ @classmethod
+ def _limit_note(cls, value: object) -> object:
+ if isinstance(value, str):
+ return " ".join(value.split())[:MAX_NOTE_LINE_CHARS]
+ return value
+
+ def text(self) -> str:
+ return " ".join((*self.synonyms, *self.keywords, *self.related, self.note_line)).strip()
+
+
+class MemoryExpansionRecord(DomainModel):
+ """What happened to the question-widening step of one investigation (selection record)."""
+
+ status: ExpansionStatus = "NOT_ASKED"
+ # Why no call was made (SETTING_OFF, MEMORY_OFF, NO_CANDIDATES, FOLLOW_UP) or what went wrong.
+ reason: str | None = None
+ # Words added to the question's own words that took part in matching.
+ added_words: tuple[str, ...] = ()
+ dispatch_ids: tuple[str, ...] = ()
+
+
+class MemoryExpansionOutcome(DomainModel):
+ record: MemoryExpansionRecord = Field(default_factory=MemoryExpansionRecord)
+ expansion: QueryExpansion | None = None
+
+ @property
+ def used(self) -> bool:
+ return self.record.status == "USED" and self.expansion is not None
diff --git a/src/thoth/domain/memory_settings.py b/src/thoth/domain/memory_settings.py
index 8750fd3..bd8165e 100644
--- a/src/thoth/domain/memory_settings.py
+++ b/src/thoth/domain/memory_settings.py
@@ -6,9 +6,14 @@
from thoth.domain.base import DomainModel
+# Whether an investigation asks the model once for extra search words (see memory_expansion.py).
+# One place to change the default if a measured comparison shows widening does not help.
+QUERY_EXPANSION_DEFAULT_ON = True
+
class ProjectMemorySettings(DomainModel):
record_kind: Literal["ProjectMemorySettings"] = "ProjectMemorySettings"
schema_version: Literal["2.0.0"] = "2.0.0"
project_id: str
memory_injection: bool = True
+ query_expansion: bool = QUERY_EXPANSION_DEFAULT_ON
diff --git a/src/thoth/domain/memory_terms.py b/src/thoth/domain/memory_terms.py
new file mode 100644
index 0000000..446b639
--- /dev/null
+++ b/src/thoth/domain/memory_terms.py
@@ -0,0 +1,150 @@
+"""The words of a text, for deciding whether a question and a memory are about the same thing.
+
+Korean is written with endings and particles stuck to the word ("자료를", "필요해"), so cutting a
+text at spaces cannot tell that "자료를" and "자료" are one word. A run of Hangul, Han or kana
+letters therefore keeps two things: the run as written, and, when it has three or more letters,
+every overlapping pair of letters in it (a "bigram": 자료를 -> 자료, 료를). A two-letter run is
+only itself. Letters of other scripts and digits stay whole, from two characters up ("AI", "5G").
+
+A question word counts as matched when the run or any pair of it is among the memory's terms.
+Counting is by question word: one word is matched once, however many of its pairs are found, so
+the pairs never make a match look bigger. No score or weight is made.
+
+The idea of overlapping letter pairs for Korean text comes from public work on keyword search
+over agent memory; this code is written here from that idea alone.
+"""
+
+from __future__ import annotations
+
+import re
+import unicodedata
+from collections.abc import Iterable, Sequence
+from dataclasses import dataclass
+from functools import cached_property, lru_cache
+
+_LETTERS = "\u1100-\u11ff\u3040-\u30ff\u3130-\u318f\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7a3"
+_RUN = re.compile(rf"[{_LETTERS}]+|(?:(?![{_LETTERS}])\w)+")
+_PAIR_LETTERS = re.compile(rf"[{_LETTERS}]+")
+# A run longer than this makes pairs only from its start; nothing is rejected.
+MAX_PAIR_CHARS = 2_000
+# Pairs that are only a grammatical ending or helper: they would match unrelated sentences.
+_ENDING_PAIRS = frozenset(
+ {
+ "하다",
+ "한다",
+ "했다",
+ "되다",
+ "된다",
+ "있다",
+ "없다",
+ "이다",
+ "니다",
+ "습니",
+ "합니",
+ "에서",
+ "에게",
+ "으로",
+ "까지",
+ "부터",
+ "해서",
+ "하고",
+ "하는",
+ }
+)
+# Two-letter English words that say nothing about a topic ("AI" and "5G" do).
+_STOP_WORDS = frozenset(
+ {
+ "a",
+ "an",
+ "as",
+ "at",
+ "be",
+ "by",
+ "do",
+ "go",
+ "he",
+ "if",
+ "in",
+ "is",
+ "it",
+ "me",
+ "my",
+ "no",
+ "of",
+ "on",
+ "or",
+ "so",
+ "to",
+ "up",
+ "us",
+ "we",
+ }
+)
+
+
+@dataclass(frozen=True)
+class Word:
+ run: str
+ pairs: tuple[str, ...] = ()
+
+ @cached_property
+ def forms(self) -> frozenset[str]:
+ return frozenset((self.run, *self.pairs))
+
+
+def _normal(text: str) -> str:
+ return unicodedata.normalize("NFKC", text).casefold()
+
+
+def _pairs(run: str) -> tuple[str, ...]:
+ head = run[:MAX_PAIR_CHARS]
+ found = (head[at : at + 2] for at in range(len(head) - 1))
+ return tuple(dict.fromkeys(pair for pair in found if pair not in _ENDING_PAIRS))
+
+
+@lru_cache(maxsize=2_048)
+def words(text: str) -> tuple[Word, ...]:
+ """The words of a text in order of first appearance, each once."""
+ found: dict[str, Word] = {}
+ for match in _RUN.finditer(_normal(text)):
+ run = match.group(0)
+ if len(run) < 2 or run in found:
+ continue
+ if _PAIR_LETTERS.fullmatch(run):
+ found[run] = Word(run, _pairs(run) if len(run) >= 3 else ())
+ elif run not in _STOP_WORDS:
+ found[run] = Word(run)
+ return tuple(found.values())
+
+
+@lru_cache(maxsize=2_048)
+def terms(text: str) -> frozenset[str]:
+ """Every run and every pair of the text."""
+ return frozenset(form for word in words(text) for form in word.forms)
+
+
+def matched_words(query: Sequence[Word], memory_terms: frozenset[str]) -> tuple[Word, ...]:
+ """The question words that appear in the memory, each counted once."""
+ return tuple(word for word in query if not word.forms.isdisjoint(memory_terms))
+
+
+def word_runs(items: Iterable[Word]) -> frozenset[str]:
+ return frozenset(item.run for item in items)
+
+
+def _same_word(left: Word, right: Word) -> bool:
+ """One word is the other, or a letter pair of it: 자료 and 자료를 are one word."""
+ return left.run in right.forms or right.run in left.forms
+
+
+def added_words(text: str, existing: Sequence[Word]) -> tuple[Word, ...]:
+ """The words of an added text that the question does not already have.
+
+ A word that is, or is a letter pair of, a word already kept (the question's own or an earlier
+ added one) is dropped, so it can never be counted a second time.
+ """
+ kept: list[Word] = []
+ for word in words(text):
+ if not any(_same_word(word, other) for other in (*existing, *kept)):
+ kept.append(word)
+ return tuple(kept)
diff --git a/src/thoth/domain/model_call_settings.py b/src/thoth/domain/model_call_settings.py
index 4a72217..2a20b13 100644
--- a/src/thoth/domain/model_call_settings.py
+++ b/src/thoth/domain/model_call_settings.py
@@ -11,6 +11,9 @@
# (no idempotency) or whether the cut-off one stopped, so a send-again can repeat the same work
# or a runaway; a project turns it on knowingly. When on: at most twice, 2 s then 4 s.
AUTO_RETRY_INTERRUPTED_DEFAULT = False
+# Hypothesis generation contract v3 (what each hypothesis expects from a test, and what would refute
+# it): off, so a project keeps the v2 contract exactly until it turns this on.
+HYPOTHESIS_CONTRACT_V3_DEFAULT = False
class ProjectModelCallSettings(DomainModel):
@@ -18,3 +21,4 @@ class ProjectModelCallSettings(DomainModel):
schema_version: Literal["2.0.0"] = "2.0.0"
project_id: str
auto_retry_interrupted_model_call: bool = AUTO_RETRY_INTERRUPTED_DEFAULT
+ hypothesis_contract_v3: bool = HYPOTHESIS_CONTRACT_V3_DEFAULT
diff --git a/src/thoth/domain/model_dispatch.py b/src/thoth/domain/model_dispatch.py
index 96f6e53..5c63d0c 100644
--- a/src/thoth/domain/model_dispatch.py
+++ b/src/thoth/domain/model_dispatch.py
@@ -43,6 +43,9 @@ class ModelDispatchRecord(DomainModel):
cached_input_tokens: int | None = None
retry_of_dispatch_id: str | None = None
transport_index: int = 0
+ # Why the call was made when it is not the investigation's own work (for example
+ # MEMORY_QUERY_EXPANSION), so its usage can be shown apart.
+ purpose: str | None = None
@dataclass
diff --git a/src/thoth/domain/research_codec.py b/src/thoth/domain/research_codec.py
index 2cb81f4..8117cdc 100644
--- a/src/thoth/domain/research_codec.py
+++ b/src/thoth/domain/research_codec.py
@@ -1,6 +1,7 @@
"""Explicit kind/version dispatch; unsupported records are never treated as reviewed."""
from thoth.domain.base import DomainModel
+from thoth.domain.discrimination import DiscriminationLedgerRecord
from thoth.domain.evidence_requirements import (
CoverageAssessment,
HypothesisSemanticReviewRecord,
@@ -8,6 +9,8 @@
RequirementSetRevision,
SemanticReviewRecord,
)
+from thoth.domain.hypothesis_same import SameHypothesisRecord
+from thoth.domain.lesson_ref import LessonReferenceRecord
from thoth.domain.memory_settings import ProjectMemorySettings
from thoth.domain.model_call_settings import ProjectModelCallSettings
from thoth.domain.model_settings import ModelPreferenceRevision
@@ -18,6 +21,9 @@
CurrentResultManifestV21,
)
from thoth.domain.research_stage import ResearchStageRecord
+from thoth.domain.trace_closure import TraceClosureRecord
+from thoth.domain.verdict_link import HypothesisLinkRecheckRecord
+from thoth.domain.verification_trace import VerificationTraceRecord, VerificationVerdictRecord
CODECS: dict[str, type[DomainModel]] = {
**REQUEST_CODECS,
@@ -27,6 +33,13 @@
ModelPreferenceRevision,
ProjectMemorySettings,
ProjectModelCallSettings,
+ VerificationTraceRecord,
+ VerificationVerdictRecord,
+ HypothesisLinkRecheckRecord,
+ DiscriminationLedgerRecord,
+ SameHypothesisRecord,
+ TraceClosureRecord,
+ LessonReferenceRecord,
CoverageAssessment,
HypothesisSemanticReviewRecord,
RequirementSetRevision,
diff --git a/src/thoth/domain/research_execution.py b/src/thoth/domain/research_execution.py
index d512614..040afa3 100644
--- a/src/thoth/domain/research_execution.py
+++ b/src/thoth/domain/research_execution.py
@@ -74,9 +74,14 @@ class ResearchWork:
oauth_retry_policy: OAuthRetryPolicy | None = None
# The project allows one cut-off model call to be sent once more (off unless it turned it on).
auto_retry_interrupted_call: bool = False
+ # The project turned on hypothesis generation contract v3 (off unless it did); read once when
+ # the attempt starts, so the value it ran with is the value recorded.
+ hypothesis_contract_v3: bool = False
# A user-requested resume names the interrupted operation; its completed stages may be reused
# when this run's input for the same stage is exactly the same.
resume_from_operation_id: str | None = None
+ # Source spans the request names up front (a trace row's positions); the shortlist keeps them.
+ pinned_spans: tuple[str, ...] = ()
reused_stage_sources: set[str] = field(default_factory=set[str])
consumed_heads: dict[str, str] = field(default_factory=dict)
produced_refs: list[RevisionRef] = field(default_factory=list)
@@ -90,6 +95,7 @@ class ResearchWork:
research_work: ContextVar[ResearchWork | None] = ContextVar("research_work", default=None)
model_call: ContextVar[ModelCallContext | None] = ContextVar("model_call", default=None)
+model_purpose: ContextVar[str | None] = ContextVar("model_purpose", default=None)
def reserve_model_dispatch(
diff --git a/src/thoth/domain/research_projection.py b/src/thoth/domain/research_projection.py
index d598f24..46de0f8 100644
--- a/src/thoth/domain/research_projection.py
+++ b/src/thoth/domain/research_projection.py
@@ -16,12 +16,27 @@
PortfolioStatus,
Reversibility,
)
-from thoth.domain.hypothesis import DiscriminatingTest
+from thoth.domain.hypothesis import DiscriminatingTest, ExpectedResult
from thoth.domain.r2_loop import HypothesisExecutionAppraisal, R2ExecutionSummary
from thoth.domain.recovery import RecoveryCandidateRevision
from thoth.domain.test_validity import PredictionProposal
+class ExpectedByTest(DomainModel):
+ """What each hypothesis was said to expect from one discriminating test (contract v3)."""
+
+ test_id: str
+ rows: tuple[ExpectedResult, ...]
+
+
+class DroppedExpectation(DomainModel):
+ """An expected-result item that was not kept, and why; the rest of the output stands."""
+
+ test_id: str
+ hypothesis_id: str
+ reason: str
+
+
class HypothesisGenerationDetails(DomainModel):
primary_locus: CausalLocus | None
contributing_loci: tuple[CausalLocus, ...]
@@ -36,6 +51,12 @@ class HypothesisGenerationDetails(DomainModel):
execution_appraisal: HypothesisExecutionAppraisal | None = None
prediction_proposal: PredictionProposal | None = None
semantic_review_ref: str | None = None
+ # Contract v3 only (absent means the v2 contract made it): the refutation conditions, what each
+ # hypothesis expects from each test, what was dropped, and the contract version.
+ refutation_conditions: tuple[str, ...] = ()
+ expected_results: tuple[ExpectedByTest, ...] = ()
+ dropped_expected_results: tuple[DroppedExpectation, ...] = ()
+ contract_version: str | None = None
class PortfolioGenerationDetails(DomainModel):
diff --git a/src/thoth/domain/trace_closure.py b/src/thoth/domain/trace_closure.py
new file mode 100644
index 0000000..7b87fd7
--- /dev/null
+++ b/src/thoth/domain/trace_closure.py
@@ -0,0 +1,80 @@
+"""What a person records about a trace row that was not met, and the one closure rules compute.
+
+A person's closure is a decision made outside the system (a fix was made, the row was closed by
+hand, a deviation or waiver was approved, an operating condition changed). It is recorded with the
+document it rests on and never changes the verdict, which keeps saying what the rule computed.
+THOTH grants no waiver. "Effect confirmed" is the only one the rules work out: after a fix was
+recorded for a row that was not met, a later verdict of that row is met on results the earlier
+verdict did not stand on.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from typing import Literal
+
+from pydantic import AwareDatetime, Field
+
+from thoth.domain.base import DomainModel
+from thoth.domain.verification_trace import VerdictRevision
+
+ClosureKind = Literal["FIX_APPLIED", "HUMAN_CLOSED", "WAIVER_RECORDED", "CONDITION_CHANGED"]
+CLOSURE_KINDS: tuple[ClosureKind, ...] = (
+ "FIX_APPLIED",
+ "HUMAN_CLOSED",
+ "WAIVER_RECORDED",
+ "CONDITION_CHANGED",
+)
+_MET = frozenset({"PASS_COMPUTED", "PASS"})
+
+
+class ClosureEvent(DomainModel):
+ event_id: str = Field(min_length=1)
+ subject_kind: Literal["CRITERION", "REQUIREMENT"]
+ subject_id: str = Field(min_length=1, max_length=500)
+ kind: ClosureKind
+ basis_ref: str = Field(min_length=1, max_length=500) # the document the decision rests on
+ note: str = Field(default="", max_length=2_000)
+ scope: str | None = Field(default=None, max_length=500) # for a changed operating condition
+ verdict_revision: str = Field(min_length=64, max_length=64) # the verdict the person looked at
+ verdict_digest: str = Field(min_length=1, max_length=200)
+ verdict_state: str = Field(min_length=1, max_length=100) # what the rule said at that time
+ actor_id: str = Field(min_length=1, max_length=160)
+ created_at: AwareDatetime
+
+
+class TraceClosureRecord(DomainModel):
+ """Every closure of the project's trace rows, oldest first; a write adds, never removes."""
+
+ record_kind: Literal["TraceClosureRecord"] = "TraceClosureRecord"
+ schema_version: Literal["2.0.0"] = "2.0.0"
+ project_id: str
+ events: tuple[ClosureEvent, ...] = ()
+
+
+def is_met(state: str) -> bool:
+ return state in _MET
+
+
+def _stands_on(item: VerdictRevision) -> tuple[str, ...]:
+ """What the verdict was computed from: the chosen results, or for a requirement its criteria."""
+ if item.selection is not None:
+ return tuple(f"{c.result_id}@{c.result_revision}" for c in item.selection.chosen)
+ return item.basis.child_verdict_digests
+
+
+def effect_confirmed_by(
+ before: VerdictRevision, later: Sequence[VerdictRevision]
+) -> VerdictRevision | None:
+ """The first later verdict that is met on new results, when the verdict at the fix was not met.
+
+ `later` holds only verdicts computed after the fix was recorded, so a change that came before
+ the fix is never counted.
+ """
+ if is_met(before.state.value):
+ return None
+ known = _stands_on(before)
+ return next(
+ (item for item in later if is_met(item.state.value) and _stands_on(item) != known),
+ None,
+ )
diff --git a/src/thoth/domain/trace_origin.py b/src/thoth/domain/trace_origin.py
new file mode 100644
index 0000000..2bb1c2e
--- /dev/null
+++ b/src/thoth/domain/trace_origin.py
@@ -0,0 +1,48 @@
+"""Where a research request started: one verdict row of the project's verification trace.
+
+The client names the row (kind, project, subject, the verdict revision it saw). The server
+checks that name against the stored trace and writes the facts itself (state, reasons, rule,
+results, source positions), so what the model and the result carry is never text the client made up.
+"""
+
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import Field
+
+from thoth.domain.base import DomainModel
+
+ORIGIN_SCHEMA_VERSION = "1.0.0"
+MAX_ORIGIN_REFS = 20
+
+
+class TraceOriginInput(DomainModel):
+ """What the client sends: only the name of the row and the revision it was looking at."""
+
+ kind: Literal["TRACE_VERDICT"]
+ project_id: str = Field(min_length=1, max_length=160)
+ subject_kind: Literal["CRITERION", "REQUIREMENT"]
+ subject_id: str = Field(min_length=1, max_length=500)
+ verdict_revision: str = Field(min_length=64, max_length=64)
+
+
+class TraceVerdictOrigin(DomainModel):
+ """The row as the stored trace has it, written by the server."""
+
+ kind: Literal["TRACE_VERDICT"] = "TRACE_VERDICT"
+ schema_version: Literal["1.0.0"] = ORIGIN_SCHEMA_VERSION
+ project_id: str = Field(min_length=1, max_length=160)
+ trace_set_digest: str = Field(min_length=1, max_length=200)
+ subject_kind: Literal["CRITERION", "REQUIREMENT"]
+ subject_id: str = Field(min_length=1, max_length=500)
+ subject_title: str = Field(default="", max_length=500)
+ verdict_revision: str = Field(min_length=64, max_length=64)
+ # What the verdict says, not who confirmed it or when it was recomputed (the hypothesis link).
+ verdict_digest: str | None = Field(default=None, max_length=200)
+ state: str = Field(min_length=1, max_length=100)
+ reason_codes: tuple[str, ...] = Field(default=(), max_length=MAX_ORIGIN_REFS)
+ condition: str | None = Field(default=None, max_length=500)
+ rule_summary: str | None = Field(default=None, max_length=500)
+ chosen_result_ids: tuple[str, ...] = Field(default=(), max_length=MAX_ORIGIN_REFS)
+ source_span_refs: tuple[str, ...] = Field(default=(), max_length=MAX_ORIGIN_REFS)
diff --git a/src/thoth/domain/verdict_link.py b/src/thoth/domain/verdict_link.py
new file mode 100644
index 0000000..653d5b0
--- /dev/null
+++ b/src/thoth/domain/verdict_link.py
@@ -0,0 +1,189 @@
+"""Which verdict a hypothesis came from, whether that verdict has changed, and a person's re-check.
+
+A hypothesis made by an investigation that started from a trace row remembers the verdict it
+stood on: the row, the verdict's revision and its content digest (what the verdict says, not who
+confirmed it or when it was recomputed) and its state. Read against today's verdict, the link is
+CURRENT, STALE (the verdict's content changed, or its row is gone) or RECHECKED (a person looked
+at this very change and kept the link). A person's confirmation of a verdict is not a change, so it
+never makes a link stale. A re-check is an event; nothing is overwritten.
+
+A stale link also says how it changed. When the verdict's state, its computed reasons and the rule
+that applied are the same and only the results or evidence positions behind it differ, the change
+is called EVIDENCE_ONLY. That is a wording for the screen: the link is stale all the same.
+"""
+
+from __future__ import annotations
+
+from enum import StrEnum
+from typing import Literal
+
+from pydantic import AwareDatetime, Field
+
+from thoth.domain.base import DomainModel
+from thoth.domain.canonical import canonical_payload, domain_digest
+from thoth.domain.verification_trace import TRACE_SCHEMA_VERSION, VerdictDraft
+
+LINK_SCHEMA_VERSION = "1.0.0"
+MISSING_VERDICT = "MISSING"
+_MET = frozenset({"PASS_COMPUTED", "PASS"})
+_FAILED = frozenset({"FAIL_COMPUTED", "FAIL", "FAIL_WITH_INCOMPLETE_COVERAGE"})
+
+RecheckReason = Literal["UNRELATED", "STILL_MATCHES", "NEEDS_RESEARCH", "OTHER"]
+RECHECK_REASONS: tuple[RecheckReason, ...] = (
+ "UNRELATED",
+ "STILL_MATCHES",
+ "NEEDS_RESEARCH",
+ "OTHER",
+)
+# A reason that keeps the link: the person says the hypothesis still stands on this verdict.
+KEEPS_LINK: frozenset[str] = frozenset({"UNRELATED", "STILL_MATCHES", "OTHER"})
+
+
+class LinkState(StrEnum):
+ NONE = "NONE"
+ CURRENT = "CURRENT"
+ STALE = "STALE"
+ RECHECKED = "RECHECKED"
+
+
+class VerdictLink(DomainModel):
+ """Stored on the hypothesis record; absent on a hypothesis that did not start from a row."""
+
+ schema_version: Literal["1.0.0"] = LINK_SCHEMA_VERSION
+ subject_kind: Literal["CRITERION", "REQUIREMENT"]
+ subject_id: str = Field(min_length=1, max_length=500)
+ verdict_revision: str = Field(min_length=64, max_length=64)
+ verdict_digest: str = Field(min_length=1, max_length=200)
+ state: str = Field(min_length=1, max_length=100)
+
+
+class CurrentVerdict(DomainModel):
+ """A row's verdict as the trace has it now."""
+
+ verdict_revision: str
+ verdict_digest: str
+ state: str
+ core_digest: str | None = None # see verdict_core_digest; None when it cannot be worked out
+
+
+class LinkRecheckEvent(DomainModel):
+ event_id: str = Field(min_length=1)
+ batch_id: str = Field(min_length=1) # events written by one call, for one verdict change
+ hypothesis_id: str = Field(min_length=1)
+ hypothesis_revision_digest: str = Field(min_length=64, max_length=64)
+ subject_kind: Literal["CRITERION", "REQUIREMENT"]
+ subject_id: str = Field(min_length=1)
+ link_verdict_digest: str # the verdict the hypothesis stood on
+ link_state: str
+ current_verdict_revision: str # the verdict the person looked at
+ current_verdict_digest: str
+ current_state: str
+ flipped: bool
+ reason_code: RecheckReason
+ note: str = Field(default="", max_length=2_000)
+ actor_id: str = Field(min_length=1, max_length=160)
+ created_at: AwareDatetime
+
+
+class HypothesisLinkRecheckRecord(DomainModel):
+ """Every re-check of the project, oldest first; a new write adds events and removes none."""
+
+ record_kind: Literal["HypothesisLinkRecheckRecord"] = "HypothesisLinkRecheckRecord"
+ schema_version: Literal["2.0.0"] = "2.0.0"
+ project_id: str
+ events: tuple[LinkRecheckEvent, ...] = ()
+
+
+LinkChange = Literal["CONTENT_CHANGED", "EVIDENCE_ONLY", "FLIPPED", "SUBJECT_MISSING"]
+
+
+class LinkAssessment(DomainModel):
+ state: LinkState
+ change: LinkChange | None = None
+ link_state: str | None = None
+ current_state: str | None = None
+ current_verdict_revision: str | None = None
+ current_verdict_digest: str | None = None
+ recheck: LinkRecheckEvent | None = None # the event that kept (or did not keep) this change
+
+
+def is_flip(before: str, after: str) -> bool:
+ """A met row that now fails, or a failing row that now is met. Held rows are not a flip."""
+ return (before in _MET and after in _FAILED) or (before in _FAILED and after in _MET)
+
+
+def note_required(reason: str, flipped: bool) -> bool:
+ """Free text is needed for "other", and to keep a link across a met/failed flip."""
+ return reason == "OTHER" or (flipped and reason in KEEPS_LINK)
+
+
+def verdict_core_digest(item: VerdictDraft) -> str:
+ """What a verdict decided and why, without the results and evidence positions behind it.
+
+ The same state, the same computed reasons and the same rule give the same core, whichever
+ result revision or source position the verdict happened to stand on.
+ """
+ payload: dict[str, object] = {
+ "subject_kind": item.subject_kind,
+ "subject_id": item.subject_id,
+ "state": item.state,
+ "conditions": item.conditions,
+ "computed_reasons": item.reasons.computed,
+ }
+ return domain_digest("TRACE_VERDICT_CORE", TRACE_SCHEMA_VERSION, canonical_payload(payload))
+
+
+def assess_link(
+ link: VerdictLink | None,
+ current: CurrentVerdict | None,
+ events: tuple[LinkRecheckEvent, ...] = (),
+ *,
+ hypothesis_id: str = "",
+ link_core_digest: str | None = None,
+) -> LinkAssessment:
+ """The link against today's verdict. Only the content digest decides, never a confirmation.
+
+ The core digests (the link's, when known, and the current one) only name the kind of change.
+ """
+ if link is None:
+ return LinkAssessment(state=LinkState.NONE)
+ change: LinkChange
+ if current is None:
+ change = "SUBJECT_MISSING"
+ digest = MISSING_VERDICT
+ else:
+ if current.verdict_digest == link.verdict_digest:
+ return LinkAssessment(
+ state=LinkState.CURRENT,
+ link_state=link.state,
+ current_state=current.state,
+ current_verdict_revision=current.verdict_revision,
+ current_verdict_digest=current.verdict_digest,
+ )
+ if is_flip(link.state, current.state):
+ change = "FLIPPED"
+ elif link_core_digest is not None and current.core_digest == link_core_digest:
+ change = "EVIDENCE_ONLY"
+ else:
+ change = "CONTENT_CHANGED"
+ digest = current.verdict_digest
+ seen = next(
+ (
+ event
+ for event in reversed(events)
+ if event.hypothesis_id == hypothesis_id
+ and event.link_verdict_digest == link.verdict_digest
+ and event.current_verdict_digest == digest
+ ),
+ None,
+ )
+ kept = seen is not None and seen.reason_code in KEEPS_LINK
+ return LinkAssessment(
+ state=LinkState.RECHECKED if kept else LinkState.STALE,
+ change=change,
+ link_state=link.state,
+ current_state=None if current is None else current.state,
+ current_verdict_revision=None if current is None else current.verdict_revision,
+ current_verdict_digest=digest,
+ recheck=seen,
+ )
diff --git a/src/thoth/domain/verification_trace.py b/src/thoth/domain/verification_trace.py
new file mode 100644
index 0000000..d49fb59
--- /dev/null
+++ b/src/thoth/domain/verification_trace.py
@@ -0,0 +1,433 @@
+"""A trace from requirement to result, and the verdicts a rule computes from it.
+
+A requirement is refined by criteria; a criterion has one rule (comparator, threshold, unit,
+condition); a test case produces results; a result points at one criterion. The verdict of a
+criterion is computed from the rule and the chosen result, never by a model. A verdict has three
+separate parts: the computed state, whether it still matches today's inputs (currentness), and
+whether a person confirmed it (confirmation). Old verdict revisions are kept, each linked to the
+one before it.
+
+Ids are text exactly as the user wrote them ("007" stays "007"). Numbers that decide a verdict are
+Decimal, never binary floats.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import ROUND_HALF_EVEN, ROUND_HALF_UP, Decimal, InvalidOperation
+from enum import StrEnum
+from typing import Literal, Self
+
+from pydantic import Field, field_validator, model_validator
+
+from thoth.domain.base import DomainModel
+from thoth.domain.canonical import canonical_payload, domain_digest
+
+TRACE_SCHEMA_VERSION = "1.0.0"
+SYSTEM_ACTOR = "system:verification-trace"
+
+
+class TraceKind(StrEnum):
+ REQUIREMENT = "REQUIREMENT"
+ CRITERION = "CRITERION"
+ TEST_CASE = "TEST_CASE"
+ RESULT = "RESULT"
+ EVIDENCE = "EVIDENCE"
+
+
+class TraceRelation(StrEnum):
+ REFINES = "REFINES"
+ VERIFIED_BY = "VERIFIED_BY"
+ PRODUCES = "PRODUCES"
+ SUPPORTED_BY = "SUPPORTED_BY"
+
+
+# relation -> (kind of the "from" item, kind of the "to" item). The owner of a link is "from".
+LINK_ENDPOINTS: dict[TraceRelation, tuple[TraceKind, TraceKind]] = {
+ TraceRelation.REFINES: (TraceKind.CRITERION, TraceKind.REQUIREMENT),
+ TraceRelation.VERIFIED_BY: (TraceKind.CRITERION, TraceKind.TEST_CASE),
+ TraceRelation.PRODUCES: (TraceKind.TEST_CASE, TraceKind.RESULT),
+ TraceRelation.SUPPORTED_BY: (TraceKind.RESULT, TraceKind.EVIDENCE),
+}
+
+
+class Comparator(StrEnum):
+ AT_LEAST = ">="
+ AT_MOST = "<="
+
+
+class SelectionPolicyName(StrEnum):
+ LATEST_PER_CONDITION = "LATEST_PER_CONDITION"
+ ALL_MUST_PASS = "ALL_MUST_PASS"
+
+
+class SubjectKind(StrEnum):
+ CRITERION = "CRITERION"
+ REQUIREMENT = "REQUIREMENT"
+
+
+class CriterionVerdictState(StrEnum):
+ PASS_COMPUTED = "PASS_COMPUTED"
+ FAIL_COMPUTED = "FAIL_COMPUTED"
+ HOLD_NO_RESULT = "HOLD_NO_RESULT"
+ HOLD_INVALID_RESULT = "HOLD_INVALID_RESULT"
+ HOLD_NO_RULE = "HOLD_NO_RULE"
+
+
+class RequirementVerdictState(StrEnum):
+ PASS = "PASS"
+ FAIL = "FAIL"
+ FAIL_WITH_INCOMPLETE_COVERAGE = "FAIL_WITH_INCOMPLETE_COVERAGE"
+ HOLD_INCOMPLETE = "HOLD_INCOMPLETE"
+ HOLD_NO_CRITERIA = "HOLD_NO_CRITERIA"
+
+
+class CurrentnessState(StrEnum):
+ CURRENT = "CURRENT"
+ STALE_BASIS = "STALE_BASIS"
+
+
+def _require_aware(value: datetime) -> datetime:
+ if value.tzinfo is None or value.utcoffset() is None:
+ raise ValueError("TRACE_TIMESTAMP_NEEDS_TIMEZONE")
+ return value
+
+
+def _require_finite(value: Decimal | None) -> Decimal | None:
+ if value is not None and not value.is_finite():
+ raise ValueError("TRACE_DECIMAL_NOT_FINITE")
+ return value
+
+
+class SelectionPolicy(DomainModel):
+ name: SelectionPolicyName = SelectionPolicyName.LATEST_PER_CONDITION
+ version: int = Field(default=1, ge=1)
+
+
+class Rounding(DomainModel):
+ """How a measured value is rounded before it is compared with the threshold."""
+
+ digits: int = Field(default=2, ge=0, le=9)
+ mode: Literal["HALF_UP", "HALF_EVEN"] = "HALF_UP"
+
+ def apply(self, value: Decimal) -> Decimal:
+ rule = ROUND_HALF_UP if self.mode == "HALF_UP" else ROUND_HALF_EVEN
+ try:
+ return value.quantize(Decimal(1).scaleb(-self.digits), rounding=rule)
+ except InvalidOperation as exc:
+ raise ValueError("TRACE_VALUE_OUT_OF_RANGE") from exc
+
+
+class TraceItem(DomainModel):
+ item_id: str = Field(min_length=1) # the user's id, kept exactly (leading zeros included)
+ item_key: str = Field(min_length=1) # internal fixed key, never shown as the id
+ kind: TraceKind
+ title: str = ""
+ fields: dict[str, str] = Field(default_factory=dict)
+ source_span_refs: tuple[str, ...] = ()
+
+ @field_validator("item_id")
+ @classmethod
+ def _id_is_exact(cls, value: str) -> str:
+ if value != value.strip():
+ raise ValueError("TRACE_ID_HAS_EDGE_WHITESPACE")
+ return value
+
+
+class TraceLink(DomainModel):
+ link_id: str = Field(min_length=1)
+ from_id: str = Field(min_length=1)
+ to_id: str = Field(min_length=1)
+ relation: TraceRelation
+
+
+class CriterionRule(DomainModel):
+ rule_id: str = Field(min_length=1)
+ rule_revision: int = Field(default=1, ge=1)
+ criterion_id: str = Field(min_length=1)
+ measure: str = Field(min_length=1)
+ comparator: Comparator
+ threshold: Decimal
+ unit: str = Field(min_length=1)
+ condition: str = Field(min_length=1)
+ rounding: Rounding = Field(default_factory=Rounding)
+ required: bool = True
+
+ check_finite = field_validator("threshold")(_require_finite)
+
+
+class ResultRecord(DomainModel):
+ result_id: str = Field(min_length=1)
+ result_revision: int = Field(default=1, ge=1)
+ criterion_id: str = Field(min_length=1)
+ condition: str = Field(min_length=1)
+ value: Decimal | None = None
+ raw_value: str | None = None # what was written, kept when it is not a number
+ unit: str = ""
+ numerator: int | None = None
+ denominator: int | None = None
+ observed_at: datetime
+ source_span_refs: tuple[str, ...] = ()
+
+ check_finite = field_validator("value")(_require_finite)
+ check_aware = field_validator("observed_at")(_require_aware)
+
+
+class TraceSet(DomainModel):
+ """Everything the verdicts are computed from. Order of the lists does not matter."""
+
+ items: tuple[TraceItem, ...] = ()
+ links: tuple[TraceLink, ...] = ()
+ rules: tuple[CriterionRule, ...] = ()
+ results: tuple[ResultRecord, ...] = ()
+
+ @model_validator(mode="after")
+ def _graph_is_consistent(self) -> Self:
+ _unique("TRACE_ITEM_ID_DUPLICATE", [item.item_id for item in self.items])
+ _unique("TRACE_ITEM_KEY_DUPLICATE", [item.item_key for item in self.items])
+ _unique("TRACE_LINK_ID_DUPLICATE", [link.link_id for link in self.links])
+ _unique("TRACE_RULE_ID_DUPLICATE", [rule.rule_id for rule in self.rules])
+ _unique("TRACE_RULE_CRITERION_DUPLICATE", [rule.criterion_id for rule in self.rules])
+ _unique("TRACE_RESULT_ID_DUPLICATE", [result.result_id for result in self.results])
+ kinds = {item.item_id: item.kind for item in self.items}
+ for link in self.links:
+ expected = LINK_ENDPOINTS[link.relation]
+ if (kinds.get(link.from_id), kinds.get(link.to_id)) != expected:
+ raise ValueError(f"TRACE_LINK_ENDPOINT_INVALID:{link.link_id}")
+ for rule in self.rules:
+ if kinds.get(rule.criterion_id) is not TraceKind.CRITERION:
+ raise ValueError(f"TRACE_RULE_TARGET_INVALID:{rule.rule_id}")
+ for result in self.results:
+ if kinds.get(result.result_id) is not TraceKind.RESULT:
+ raise ValueError(f"TRACE_RESULT_ITEM_MISSING:{result.result_id}")
+ if kinds.get(result.criterion_id) is not TraceKind.CRITERION:
+ raise ValueError(f"TRACE_RESULT_TARGET_INVALID:{result.result_id}")
+ return self
+
+ @property
+ def set_digest(self) -> str:
+ paths = frozenset({("items",), ("links",), ("rules",), ("results",)})
+ return domain_digest(
+ "TRACE_SET", TRACE_SCHEMA_VERSION, canonical_payload(self, set_paths=paths)
+ )
+
+
+def _unique(code: str, values: list[str]) -> None:
+ if len(values) != len(set(values)):
+ raise ValueError(code)
+
+
+class ChangedDependency(DomainModel):
+ """One change to something a verdict depends on, with before and after."""
+
+ kind: Literal["RULE", "RESULT", "LINK", "POLICY"]
+ ref_id: str
+ field: str # a field name, or "*" when the whole thing was added or removed
+ before: str | None = None
+ after: str | None = None
+ before_revision: int | None = None
+ after_revision: int | None = None
+ criterion_ids: tuple[str, ...] = ()
+ requirement_ids: tuple[str, ...] = ()
+
+
+class Currentness(DomainModel):
+ state: CurrentnessState = CurrentnessState.CURRENT
+ changed_dependencies: tuple[ChangedDependency, ...] = ()
+
+
+class CauseOfChange(DomainModel):
+ trigger: str
+ changed: tuple[ChangedDependency, ...] = ()
+
+
+class VerdictBasis(DomainModel):
+ input_digest: str
+ source_span_refs: tuple[str, ...] = ()
+ # The trace keeps references to where the evidence is, not the evidence text itself.
+ evidence_text_preserved: bool = False
+ child_verdict_digests: tuple[str, ...] = ()
+
+
+class ResultCandidate(DomainModel):
+ result_id: str
+ result_revision: int
+
+
+class ExcludedResult(DomainModel):
+ result_id: str
+ result_revision: int
+ reason: Literal["CONDITION_MISMATCH", "SUPERSEDED_BY_LATER_RESULT"]
+
+
+class ResultSelection(DomainModel):
+ policy: SelectionPolicy
+ candidates: tuple[ResultCandidate, ...] = ()
+ chosen: tuple[ResultCandidate, ...] = ()
+ excluded: tuple[ExcludedResult, ...] = ()
+
+
+class AppliedConditions(DomainModel):
+ required: bool = True
+ rule_id: str | None = None
+ rule_revision: int | None = None
+ condition: str | None = None
+ unit: str | None = None
+ comparator: Comparator | None = None
+ threshold: Decimal | None = None
+ rounding: Rounding | None = None
+ required_criteria: tuple[str, ...] = () # for a requirement: the criteria that decide it
+
+
+class VerdictActor(DomainModel):
+ actor_id: str = SYSTEM_ACTOR
+ computed_at: datetime
+
+ check_aware = field_validator("computed_at")(_require_aware)
+
+
+class VerdictReasons(DomainModel):
+ computed: tuple[str, ...] = () # codes found by the rule: violations and what is missing
+ human: tuple[str, ...] = () # words a person wrote; never produced by the computation
+
+
+VerdictState = CriterionVerdictState | RequirementVerdictState
+
+
+def verdict_digest_of(item: VerdictDraft) -> str:
+ """What the verdict says, without who computed it, when, or why it was recomputed."""
+ payload: dict[str, object] = {
+ "subject_kind": item.subject_kind,
+ "subject_id": item.subject_id,
+ "state": item.state,
+ "basis": item.basis,
+ "selection": item.selection,
+ "conditions": item.conditions,
+ "computed_reasons": item.reasons.computed,
+ }
+ return domain_digest("TRACE_VERDICT", TRACE_SCHEMA_VERSION, canonical_payload(payload))
+
+
+def revision_digest_of(item: VerdictDraft, verdict_digest: str, parent_digest: str | None) -> str:
+ payload: dict[str, object] = {
+ "verdict_digest": verdict_digest,
+ "parent_digest": parent_digest,
+ "cause": item.cause,
+ "actor": item.actor,
+ "human_reasons": item.reasons.human,
+ }
+ return domain_digest("TRACE_VERDICT_REVISION", TRACE_SCHEMA_VERSION, canonical_payload(payload))
+
+
+class VerdictDraft(DomainModel):
+ """A computed verdict before it is given its digests and its place in the history."""
+
+ subject_kind: SubjectKind
+ subject_id: str
+ state: VerdictState
+ cause: CauseOfChange # 1. what changed and what triggered the recompute
+ basis: VerdictBasis # 2. what it stood on at the time
+ selection: ResultSelection | None = None # 3. which results were looked at (criteria only)
+ conditions: AppliedConditions # 4. condition, unit, required set that applied
+ actor: VerdictActor # 5. who computed it and when (a person's confirmation is separate)
+ reasons: VerdictReasons # 6. computed reasons apart from human-written ones
+
+ @model_validator(mode="after")
+ def _state_matches_subject(self) -> Self:
+ allowed = (
+ CriterionVerdictState
+ if self.subject_kind is SubjectKind.CRITERION
+ else RequirementVerdictState
+ )
+ if type(self.state) is not allowed:
+ raise ValueError("TRACE_VERDICT_STATE_WRONG_KIND")
+ return self
+
+
+class VerdictRevision(VerdictDraft):
+ """One computed verdict with the six things needed to explain it later."""
+
+ parent_digest: str | None = None # the revision this one follows; old ones are never deleted
+ verdict_digest: str
+ revision_digest: str
+
+ @model_validator(mode="after")
+ def _digests_hold(self) -> Self:
+ if self.verdict_digest != verdict_digest_of(self):
+ raise ValueError("TRACE_VERDICT_DIGEST_MISMATCH")
+ expected = revision_digest_of(self, self.verdict_digest, self.parent_digest)
+ if self.revision_digest != expected:
+ raise ValueError("TRACE_VERDICT_REVISION_DIGEST_MISMATCH")
+ return self
+
+
+class Confirmation(DomainModel):
+ """A person looked at one verdict revision. It does not change the verdict and is not copied
+ to a recomputed one."""
+
+ verdict_revision_digest: str
+ actor_id: str = Field(min_length=1)
+ confirmed_at: datetime
+ rationale: str = Field(min_length=1)
+
+ check_aware = field_validator("confirmed_at")(_require_aware)
+
+
+def subject_key(kind: SubjectKind, subject_id: str) -> str:
+ return f"{kind.value}:{subject_id}"
+
+
+class VerificationVerdictRecord(DomainModel):
+ """One verdict revision, stored once under its own digest and never changed."""
+
+ record_kind: Literal["VerificationVerdictRecord"] = "VerificationVerdictRecord"
+ schema_version: Literal["2.0.0"] = "2.0.0"
+ project_id: str
+ revision: VerdictRevision
+
+
+class VerificationTraceRecord(DomainModel):
+ """The stored state of a project's trace: the set, which verdict revisions belong to each
+ subject (their digests, oldest first) and the confirmations. The revisions themselves are
+ separate records, so saving the trace does not copy the whole history again."""
+
+ record_kind: Literal["VerificationTraceRecord"] = "VerificationTraceRecord"
+ schema_version: Literal["2.0.0"] = "2.0.0"
+ project_id: str
+ trace_set: TraceSet
+ policy: SelectionPolicy = Field(default_factory=SelectionPolicy)
+ history: dict[str, tuple[str, ...]] = Field(default_factory=dict)
+ confirmations: tuple[Confirmation, ...] = ()
+ pending_changes: tuple[ChangedDependency, ...] = () # changes not yet recomputed
+
+ def current_digests(self) -> dict[tuple[SubjectKind, str], str]:
+ found: dict[tuple[SubjectKind, str], str] = {}
+ for key, digests in self.history.items():
+ kind, _, subject_id = key.partition(":")
+ if digests:
+ found[(SubjectKind(kind), subject_id)] = digests[-1]
+ return found
+
+ def confirmations_of(self, revision_digest: str) -> tuple[Confirmation, ...]:
+ return tuple(
+ item for item in self.confirmations if item.verdict_revision_digest == revision_digest
+ )
+
+
+def seal_verdict(draft: VerdictDraft, parent_digest: str | None) -> VerdictRevision:
+ """Give a draft its two digests and link it to the revision it follows."""
+ verdict_digest = verdict_digest_of(draft)
+ return VerdictRevision(
+ subject_kind=draft.subject_kind,
+ subject_id=draft.subject_id,
+ state=draft.state,
+ cause=draft.cause,
+ basis=draft.basis,
+ selection=draft.selection,
+ conditions=draft.conditions,
+ actor=draft.actor,
+ reasons=draft.reasons,
+ parent_digest=parent_digest,
+ verdict_digest=verdict_digest,
+ revision_digest=revision_digest_of(draft, verdict_digest, parent_digest),
+ )
diff --git a/src/thoth/ports/memory.py b/src/thoth/ports/memory.py
index a9b7763..e3f1890 100644
--- a/src/thoth/ports/memory.py
+++ b/src/thoth/ports/memory.py
@@ -12,6 +12,7 @@
MemoryRoleReview,
MemoryTransitionReceipt,
)
+from thoth.domain.memory_expansion import QueryExpansion
from thoth.domain.memory_relation import MemoryRelationProposal, MemoryRelationQuestion
@@ -74,3 +75,15 @@ class MemoryInjectionPort(Protocol):
"""Whether a project gives its recalled memory to an investigation."""
def enabled(self, project_id: str) -> bool: ...
+
+
+class MemoryExpansionSwitchPort(Protocol):
+ """Whether a project lets an investigation ask a model for extra search words."""
+
+ def expansion_enabled(self, project_id: str) -> bool: ...
+
+
+class MemoryQueryExpanderPort(Protocol):
+ """Other words for a question, to find memory written in different words; never an authority."""
+
+ async def expand(self, query: str) -> QueryExpansion: ...
diff --git a/src/thoth/protocol/bus.py b/src/thoth/protocol/bus.py
index a389392..bb4e06e 100644
--- a/src/thoth/protocol/bus.py
+++ b/src/thoth/protocol/bus.py
@@ -52,40 +52,9 @@
RpcErrorCode,
)
from thoth.protocol.notifications import notifications_for
+from thoth.protocol.read_queries import READ_QUERY_METHODS
from thoth.protocol.registry import CommandHandler, MethodRegistry
-READ_QUERY_METHODS = frozenset(
- {
- "revision/timeline/read",
- "revision/restore/preview",
- "revision/diff/read",
- "revision/history/read",
- "revision/read",
- "revision/content/read",
- "revision/head/read",
- "revision/timeline/item/read",
- "thread/result/read",
- "thread/result/compare/read",
- "thread/read",
- "thread/list",
- "thread/activity/list",
- "model/settings/read",
- "model/credential/list",
- "model/credential/login/status",
- "workspace/setup/read",
- "workspace/ready",
- "project/read",
- "project/list",
- "project/review/list",
- "project/source/list",
- "evidence/list",
- "operation/read",
- "operation/result/read",
- "operation/checkpoint/read",
- }
-)
-RUNNING_RESEARCH_METHODS = RESEARCH_OPERATION_METHODS
-
@dataclass(frozen=True)
class DispatchTicket:
@@ -245,7 +214,7 @@ def claim(self, request: JsonRpcRequest) -> DispatchTicket | JsonRpcResponse:
live_task = operation.operation_id in self._research_tasks
if (
self._seal_abandoned_running
- and request.method in RUNNING_RESEARCH_METHODS
+ and request.method in RESEARCH_OPERATION_METHODS
and not live_task
):
return self._fail_abandoned_running(request, operation)
diff --git a/src/thoth/protocol/read_queries.py b/src/thoth/protocol/read_queries.py
new file mode 100644
index 0000000..b6ae455
--- /dev/null
+++ b/src/thoth/protocol/read_queries.py
@@ -0,0 +1,43 @@
+"""The public methods that only read: they are served by the query route, never the command bus."""
+
+from __future__ import annotations
+
+READ_QUERY_METHODS = frozenset(
+ {
+ "revision/timeline/read",
+ "revision/restore/preview",
+ "revision/diff/read",
+ "revision/history/read",
+ "revision/read",
+ "revision/content/read",
+ "revision/head/read",
+ "revision/timeline/item/read",
+ "thread/result/read",
+ "thread/result/compare/read",
+ "thread/read",
+ "thread/list",
+ "thread/activity/list",
+ "model/settings/read",
+ "model/credential/list",
+ "model/credential/login/status",
+ "trace/read",
+ "trace/history",
+ "trace/export",
+ "trace/importPreview",
+ "trace/closure/list",
+ "trace/lesson/list",
+ "hypothesis/link/list",
+ "hypothesis/test/result/list",
+ "hypothesis/same/list",
+ "workspace/setup/read",
+ "workspace/ready",
+ "project/read",
+ "project/list",
+ "project/review/list",
+ "project/source/list",
+ "evidence/list",
+ "operation/read",
+ "operation/result/read",
+ "operation/checkpoint/read",
+ }
+)
diff --git a/src/thoth/protocol/registry.py b/src/thoth/protocol/registry.py
index 71c3c4f..4ab76cb 100644
--- a/src/thoth/protocol/registry.py
+++ b/src/thoth/protocol/registry.py
@@ -32,6 +32,15 @@
"model/credential/login/complete",
"model/catalog/refresh",
"model/tooling/install",
+ "trace/read",
+ "trace/history",
+ "trace/export",
+ "trace/importPreview",
+ "trace/importApply",
+ "trace/confirm",
+ "trace/closure/list",
+ "trace/closure/record",
+ "trace/lesson/list",
"workspace/setup/read",
"workspace/setup/update",
"workspace/ready",
@@ -200,6 +209,13 @@
"hypothesis/counterevidence/request",
"hypothesis/review/request",
"hypothesis/review/list",
+ "hypothesis/link/list",
+ "hypothesis/link/recheck",
+ "hypothesis/test/result/list",
+ "hypothesis/test/result/record",
+ "hypothesis/refutation/record",
+ "hypothesis/same/list",
+ "hypothesis/same/record",
"hypothesis/portfolio/compose",
"hypothesis/portfolio/revalidate",
"hypothesis/test/bind",
@@ -219,6 +235,7 @@
"action/audit/read",
"action/generate",
"action/create",
+ "action/draft/fromTest",
"action/revise",
"action/portfolio/compose",
"action/portfolio/evaluate",
diff --git a/tests/architecture/public_source_profile.py b/tests/architecture/public_source_profile.py
index c941e30..b6cf643 100644
--- a/tests/architecture/public_source_profile.py
+++ b/tests/architecture/public_source_profile.py
@@ -28,6 +28,12 @@
)
+_STALE_LIST_HINT = (
+ "; if the file list is stale, rebuild it with scripts/update_source_manifest.py "
+ "(--check shows what differs)"
+)
+
+
class PublicPackageError(ValueError):
"""A declared public package path or byte contract does not match the tree."""
@@ -130,7 +136,9 @@ def read_bytes(self, relative: str) -> bytes:
except OSError as exc:
raise PublicPackageError(f"listed public file is unreadable: {relative}") from exc
if len(data) != row.bytes or hashlib.sha256(data).hexdigest() != row.sha256:
- raise PublicPackageError(f"listed public file bytes differ: {relative}")
+ raise PublicPackageError(
+ f"listed public file bytes differ: {relative}{_STALE_LIST_HINT}"
+ )
return data
def read_text(self, relative: str) -> str:
@@ -163,7 +171,9 @@ def public_source_profile(root: Path) -> PublicSourceProfile | None:
if path.is_symlink() or not resolved.is_relative_to(root) or not path.is_file():
raise PublicPackageError(f"listed public path is redirected: {row.path}")
except OSError as exc:
- raise PublicPackageError(f"listed public file is missing: {row.path}") from exc
+ raise PublicPackageError(
+ f"listed public file is missing: {row.path}{_STALE_LIST_HINT}"
+ ) from exc
profile.read_bytes(row.path)
for relative in _ANCHORS:
profile.read_bytes(relative)
diff --git a/tests/architecture/test_metadata_rule_transaction.py b/tests/architecture/test_metadata_rule_transaction.py
index 04ec318..4609952 100644
--- a/tests/architecture/test_metadata_rule_transaction.py
+++ b/tests/architecture/test_metadata_rule_transaction.py
@@ -208,7 +208,7 @@ def test_201_to_193_watch_retirement_uses_candidate_then_transaction() -> None:
engine.apply(proposal.identity, proposal.identity)
updated = json.loads((root / path).read_bytes())
assert key not in updated["watched_long_functions"]
- assert updated["function_line_limit"] == 200 and updated["module_line_limit"] == 900
+ assert updated["function_line_limit"] == 200 and updated["module_line_limit"] == 600
def test_head_drift_rejects_before_metadata_write() -> None:
diff --git a/tests/architecture/test_module_budget.py b/tests/architecture/test_module_budget.py
index c6a758d..e049708 100644
--- a/tests/architecture/test_module_budget.py
+++ b/tests/architecture/test_module_budget.py
@@ -107,3 +107,118 @@ def test_new_top_level_responsibility_fails(tmp_path: Path) -> None:
result = _run(tmp_path, manifest)
assert result.returncode == 1
assert "top-level responsibility changed" in result.stdout
+
+
+def _write_web_manifest(
+ path: Path,
+ *,
+ watched: dict[str, int] | None = None,
+ excluded: list[str] | None = None,
+) -> None:
+ payload = {
+ "schema_version": "1.0.0",
+ "module_line_limit": 5,
+ "function_line_limit": 3,
+ "scan_roots": ["src/thoth"],
+ "web_scan_roots": ["apps/web/src"],
+ "web_excluded_suffixes": [".test.ts", ".test.tsx"] if excluded is None else excluded,
+ "watched_modules": {
+ relative: {"baseline_lines": baseline, "top_level_symbols": [], "policy": "fixture"}
+ for relative, baseline in (watched or {}).items()
+ },
+ "watched_long_functions": {},
+ }
+ path.write_text(json.dumps(payload), encoding="utf-8")
+
+
+def _web_file(root: Path, name: str, lines: int) -> Path:
+ path = root / "apps/web/src" / name
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text("\n".join(["const value = 1;"] * lines) + "\n", encoding="utf-8")
+ return path
+
+
+def test_new_oversized_web_file_fails(tmp_path: Path) -> None:
+ _web_file(tmp_path, "components/Big.tsx", 6)
+ _web_file(tmp_path, "api/Small.ts", 5)
+ manifest = tmp_path / "budget.json"
+ _write_web_manifest(manifest)
+ result = _run(tmp_path, manifest)
+ assert result.returncode == 1
+ assert (
+ "new oversized module is not ratcheted: apps/web/src/components/Big.tsx=6" in result.stdout
+ )
+ assert "Small.ts" not in result.stdout
+
+
+def test_watched_web_file_may_not_grow_and_must_lower_its_baseline_when_it_shrinks(
+ tmp_path: Path,
+) -> None:
+ _web_file(tmp_path, "api/progress.ts", 7)
+ manifest = tmp_path / "budget.json"
+ _write_web_manifest(manifest, watched={"apps/web/src/api/progress.ts": 7})
+ assert _run(tmp_path, manifest).returncode == 0
+ _web_file(tmp_path, "api/progress.ts", 8)
+ grown = _run(tmp_path, manifest)
+ assert grown.returncode == 1 and "watched module grew" in grown.stdout
+ _web_file(tmp_path, "api/progress.ts", 6)
+ shrunk = _run(tmp_path, manifest)
+ assert shrunk.returncode == 1 and "watched module shrank" in shrunk.stdout
+ assert "update/refactor the ratchet" in shrunk.stdout
+
+
+def test_web_test_files_are_not_measured_but_other_suffixes_are(tmp_path: Path) -> None:
+ _web_file(tmp_path, "components/Big.test.tsx", 50)
+ _web_file(tmp_path, "api/big.test.ts", 50)
+ manifest = tmp_path / "budget.json"
+ _write_web_manifest(manifest)
+ assert _run(tmp_path, manifest).returncode == 0
+ _web_file(tmp_path, "api/bigFixture.ts", 50)
+ result = _run(tmp_path, manifest)
+ assert result.returncode == 1 and "bigFixture.ts=50" in result.stdout
+ _write_web_manifest(manifest, excluded=[])
+ assert "Big.test.tsx" in _run(tmp_path, manifest).stdout
+
+
+def test_watched_web_file_that_was_removed_is_reported(tmp_path: Path) -> None:
+ manifest = tmp_path / "budget.json"
+ _write_web_manifest(manifest, watched={"apps/web/src/api/gone.ts": 7})
+ result = _run(tmp_path, manifest)
+ assert result.returncode == 1 and "watched modules missing" in result.stdout
+
+
+def test_a_web_file_is_measured_in_lines_not_by_python_syntax(tmp_path: Path) -> None:
+ path = _web_file(tmp_path, "components/Odd.tsx", 5)
+ path.write_text("export const a = {{ not python }}
;\n" * 6, encoding="utf-8")
+ manifest = tmp_path / "budget.json"
+ _write_web_manifest(manifest)
+ result = _run(tmp_path, manifest)
+ assert result.returncode == 1 and "Odd.tsx=6" in result.stdout
+
+
+def test_the_repository_limit_is_600_lines_and_the_function_limit_is_unchanged() -> None:
+ manifest = json.loads((ROOT / "config/module-responsibility-budget.json").read_text("utf-8"))
+ assert manifest["module_line_limit"] == 600
+ assert manifest["function_line_limit"] == 200
+ assert manifest["web_scan_roots"] == ["apps/web/src"]
+ assert manifest["web_excluded_suffixes"] == [".test.ts", ".test.tsx"]
+
+
+def test_every_oversized_repository_file_is_watched_with_its_exact_current_length() -> None:
+ manifest = json.loads((ROOT / "config/module-responsibility-budget.json").read_text("utf-8"))
+ limit = manifest["module_line_limit"]
+ measured = {
+ path.relative_to(ROOT).as_posix(): len(path.read_text(encoding="utf-8").splitlines())
+ for pattern, base in (
+ ("*.py", "src/thoth"),
+ ("*.ts", "apps/web/src"),
+ ("*.tsx", "apps/web/src"),
+ )
+ for path in (ROOT / base).rglob(pattern)
+ if not path.name.endswith((".test.ts", ".test.tsx"))
+ }
+ oversized = {name for name, count in measured.items() if count > limit}
+ watched = manifest["watched_modules"]
+ assert oversized <= set(watched), sorted(oversized - set(watched))
+ for name, definition in watched.items():
+ assert definition["baseline_lines"] == measured[name], name
diff --git a/tests/architecture/test_public_protocol_exact_parity.py b/tests/architecture/test_public_protocol_exact_parity.py
index e84b8bf..ca64ee7 100644
--- a/tests/architecture/test_public_protocol_exact_parity.py
+++ b/tests/architecture/test_public_protocol_exact_parity.py
@@ -1,107 +1,124 @@
-from __future__ import annotations
-
-import json
-import subprocess
-import sys
-from pathlib import Path
-
-from thoth.protocol.bus import READ_QUERY_METHODS
-from thoth.protocol.notifications import IMPLEMENTED_NOTIFICATIONS
-from thoth.protocol.registry import PUBLIC_METHODS
-
-ROOT = Path(__file__).resolve().parents[2]
-MANIFEST = ROOT / "schemas/protocol/public-method-catalog.json"
-
-
-def test_public_method_manifest_exactly_matches_runtime_and_notifications() -> None:
- manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
- methods = manifest["methods"]
- by_name = {item["name"]: item for item in methods}
- assert len(methods) == len(by_name) == 351
- assert set(by_name) == set(PUBLIC_METHODS)
- assert sum(item["canonical"] for item in methods) == 345
- assert manifest["runtime_method_count"] == 351
- assert manifest["canonical_method_count"] == 345
- assert manifest["compatibility_alias_count"] == 6
- assert {
- f"project/source/scope/{name}" for name in ("read", "grant", "revoke", "assign", "update")
- } <= set(by_name)
- aliases = {item["name"]: item["alias_target"] for item in methods if not item["canonical"]}
- assert aliases == {
- "action/preflight/read": "action/authorization/prepare",
- "closure/finalize": "closure/decide",
- "export/prepare": "export/plan/create",
- "revision/compare": "revision/diff/read",
- "revision/history/read": "revision/list",
- "project/delete": "project/archive",
- }
- required = {
- "namespace",
- "surface",
- "canonical_owner",
- "policy",
- "normal_entrypoint",
- "behavioral_evidence",
- "alias_target",
- }
- assert all(required <= set(item) for item in methods)
- assert set(manifest["notifications"]) == set(IMPLEMENTED_NOTIFICATIONS)
- assert len(manifest["notifications"]) == 221
- assert all(item["namespace"] == item["name"].split("/", 1)[0] for item in methods)
- model = {name: item for name, item in by_name.items() if item["namespace"] == "model"}
- assert len(model) == 11
- assert {name: model[name]["surface"] for name in model} == {
- "model/credential/list": "QUERY",
- "model/credential/login/status": "QUERY",
- "model/credential/login/cancel": "COMMAND",
- "model/credential/register": "COMMAND",
- "model/credential/login/complete": "COMMAND",
- "model/catalog/refresh": "COMMAND",
- "model/tooling/install": "COMMAND",
- "model/settings/read": "QUERY",
- "model/settings/update": "COMMAND",
- "model/callSettings/read": "QUERY",
- "model/callSettings/update": "COMMAND",
- }
- assert "model/credential/login/status" in READ_QUERY_METHODS
- assert "model/credential/login/cancel" not in READ_QUERY_METHODS
- for name, item in model.items():
- assert item["canonical_owner"] == "THREAD_REQUEST_SETTINGS"
- if name.startswith(("model/credential/", "model/catalog/", "model/tooling/")):
- assert item["policy"].startswith("system:workspace; LOCAL only; HOSTED_REVIEW denied")
- assert "ModelCredentialPanel" in item["normal_entrypoint"]
- if name != "model/credential/login/complete":
- assert "FirstRunSetup" in item["normal_entrypoint"]
- assert "tests/unit/test_hosted_review_credential_rpc.py" in item["behavioral_evidence"]
- if "/login/" in name and name != "model/credential/login/complete":
- assert "XaiDeviceLogin" in item["normal_entrypoint"]
- assert (
- "tests/integration/test_xai_workspace_route.py" in item["behavioral_evidence"]
- )
- else:
- assert item["normal_entrypoint"] == "thread/input"
- assert item["policy"] == "research-model-settings:versioned"
- assert item["behavioral_evidence"] == ["U08", "U07"]
- workspace = [item for item in methods if item["namespace"] == "workspace"]
- assert len(workspace) == 3
- for item in workspace:
- assert item["canonical_owner"] == "THREAD_REQUEST_SETTINGS"
- assert item["normal_entrypoint"] == "thread/input"
- assert item["policy"] == "research-model-settings:versioned"
- assert item["behavioral_evidence"] == ["U08"]
-
-
-def test_public_protocol_checker_reports_zero_drift() -> None:
- result = subprocess.run(
- [sys.executable, str(ROOT / "scripts/check_public_protocol_parity.py")],
- cwd=ROOT,
- capture_output=True,
- text=True,
- check=False,
- )
- assert result.returncode == 0, result.stdout + result.stderr
- payload = json.loads(result.stdout)
- assert payload["verdict"] == "PASS"
- assert payload["undeclared_runtime_methods"] == []
- assert payload["missing_runtime_methods"] == []
- assert payload["missing_documented_methods"] == []
+from __future__ import annotations
+
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+from thoth.protocol.bus import READ_QUERY_METHODS
+from thoth.protocol.notifications import IMPLEMENTED_NOTIFICATIONS
+from thoth.protocol.registry import PUBLIC_METHODS
+
+ROOT = Path(__file__).resolve().parents[2]
+MANIFEST = ROOT / "schemas/protocol/public-method-catalog.json"
+
+
+def test_public_method_manifest_exactly_matches_runtime_and_notifications() -> None:
+ manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
+ methods = manifest["methods"]
+ by_name = {item["name"]: item for item in methods}
+ assert len(methods) == len(by_name) == 368
+ assert set(by_name) == set(PUBLIC_METHODS)
+ assert sum(item["canonical"] for item in methods) == 362
+ assert manifest["runtime_method_count"] == 368
+ assert manifest["canonical_method_count"] == 362
+ assert manifest["compatibility_alias_count"] == 6
+ assert {
+ f"project/source/scope/{name}" for name in ("read", "grant", "revoke", "assign", "update")
+ } <= set(by_name)
+ aliases = {item["name"]: item["alias_target"] for item in methods if not item["canonical"]}
+ assert aliases == {
+ "action/preflight/read": "action/authorization/prepare",
+ "closure/finalize": "closure/decide",
+ "export/prepare": "export/plan/create",
+ "revision/compare": "revision/diff/read",
+ "revision/history/read": "revision/list",
+ "project/delete": "project/archive",
+ }
+ required = {
+ "namespace",
+ "surface",
+ "canonical_owner",
+ "policy",
+ "normal_entrypoint",
+ "behavioral_evidence",
+ "alias_target",
+ }
+ assert all(required <= set(item) for item in methods)
+ assert set(manifest["notifications"]) == set(IMPLEMENTED_NOTIFICATIONS)
+ assert len(manifest["notifications"]) == 221
+ assert all(item["namespace"] == item["name"].split("/", 1)[0] for item in methods)
+ model = {name: item for name, item in by_name.items() if item["namespace"] == "model"}
+ assert len(model) == 11
+ assert {name: model[name]["surface"] for name in model} == {
+ "model/credential/list": "QUERY",
+ "model/credential/login/status": "QUERY",
+ "model/credential/login/cancel": "COMMAND",
+ "model/credential/register": "COMMAND",
+ "model/credential/login/complete": "COMMAND",
+ "model/catalog/refresh": "COMMAND",
+ "model/tooling/install": "COMMAND",
+ "model/settings/read": "QUERY",
+ "model/settings/update": "COMMAND",
+ "model/callSettings/read": "QUERY",
+ "model/callSettings/update": "COMMAND",
+ }
+ assert "model/credential/login/status" in READ_QUERY_METHODS
+ assert "model/credential/login/cancel" not in READ_QUERY_METHODS
+ for name, item in model.items():
+ assert item["canonical_owner"] == "THREAD_REQUEST_SETTINGS"
+ if name.startswith(("model/credential/", "model/catalog/", "model/tooling/")):
+ assert item["policy"].startswith("system:workspace; LOCAL only; HOSTED_REVIEW denied")
+ assert "ModelCredentialPanel" in item["normal_entrypoint"]
+ if name != "model/credential/login/complete":
+ assert "FirstRunSetup" in item["normal_entrypoint"]
+ assert "tests/unit/test_hosted_review_credential_rpc.py" in item["behavioral_evidence"]
+ if "/login/" in name and name != "model/credential/login/complete":
+ assert "XaiDeviceLogin" in item["normal_entrypoint"]
+ assert (
+ "tests/integration/test_xai_workspace_route.py" in item["behavioral_evidence"]
+ )
+ else:
+ assert item["normal_entrypoint"] == "thread/input"
+ assert item["policy"] == "research-model-settings:versioned"
+ assert item["behavioral_evidence"] == ["U08", "U07"]
+ workspace = [item for item in methods if item["namespace"] == "workspace"]
+ trace = {name: item for name, item in by_name.items() if item["namespace"] == "trace"}
+ assert {name: trace[name]["surface"] for name in trace} == {
+ "trace/read": "QUERY",
+ "trace/export": "QUERY",
+ "trace/history": "QUERY",
+ "trace/importPreview": "QUERY",
+ "trace/importApply": "COMMAND",
+ "trace/confirm": "COMMAND",
+ "trace/closure/list": "QUERY",
+ "trace/closure/record": "COMMAND",
+ "trace/lesson/list": "QUERY",
+ }
+ for name, item in trace.items():
+ assert item["canonical_owner"] == "VERIFICATION_TRACE"
+ assert item["policy"] == "trace:versioned-policy"
+ assert item["behavioral_evidence"] == ["B3"]
+ assert (name in READ_QUERY_METHODS) == (item["surface"] == "QUERY")
+ assert len(workspace) == 3
+ for item in workspace:
+ assert item["canonical_owner"] == "THREAD_REQUEST_SETTINGS"
+ assert item["normal_entrypoint"] == "thread/input"
+ assert item["policy"] == "research-model-settings:versioned"
+ assert item["behavioral_evidence"] == ["U08"]
+
+
+def test_public_protocol_checker_reports_zero_drift() -> None:
+ result = subprocess.run(
+ [sys.executable, str(ROOT / "scripts/check_public_protocol_parity.py")],
+ cwd=ROOT,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ assert result.returncode == 0, result.stdout + result.stderr
+ payload = json.loads(result.stdout)
+ assert payload["verdict"] == "PASS"
+ assert payload["undeclared_runtime_methods"] == []
+ assert payload["missing_runtime_methods"] == []
+ assert payload["missing_documented_methods"] == []
diff --git a/tests/architecture/test_rule_candidate_pretool.py b/tests/architecture/test_rule_candidate_pretool.py
index bc56227..c493f3c 100644
--- a/tests/architecture/test_rule_candidate_pretool.py
+++ b/tests/architecture/test_rule_candidate_pretool.py
@@ -7,6 +7,9 @@
from pathlib import Path
import pytest
+from scripts.rule_candidate_contract import (
+ _preserve_ratchets as preserve_ratchets,
+)
from scripts.rule_candidate_contract import hook_smoke_applicability, validate_candidate
from tests.architecture.owner_helpers import bind_owner
@@ -235,3 +238,73 @@ def test_guard_code_edit_requires_a11_even_when_file_scope_is_present(tmp_path:
)
result = json.loads(hook.stdout)["hookSpecificOutput"]
assert result["permissionDecision"] == "deny" and "A11" in result["permissionDecisionReason"]
+
+
+BUDGET = "config/module-responsibility-budget.json"
+
+
+def _budget() -> dict[str, object]:
+ return json.loads((ROOT / BUDGET).read_bytes())
+
+
+def test_a_candidate_that_keeps_the_web_size_scope_passes_the_ratchet_guard() -> None:
+ preserve_ratchets(ROOT, {BUDGET: json.dumps(_budget()).encode()})
+
+
+@pytest.mark.parametrize("new_roots", [[], ["apps/web/src/api"], ["apps/web/src", "apps/other"]])
+def test_a_candidate_cannot_change_the_web_scan_roots(new_roots: list[str]) -> None:
+ value = _budget()
+ value["web_scan_roots"] = new_roots
+ with pytest.raises(ValueError, match="web scan roots"):
+ preserve_ratchets(ROOT, {BUDGET: json.dumps(value).encode()})
+
+
+@pytest.mark.parametrize("new_suffixes", [[], [".test.ts"], [".test.ts", ".test.tsx", ".tsx"]])
+def test_a_candidate_cannot_widen_or_change_the_web_exclusions(new_suffixes: list[str]) -> None:
+ value = _budget()
+ value["web_excluded_suffixes"] = new_suffixes
+ with pytest.raises(ValueError, match="web scan exclusions"):
+ preserve_ratchets(ROOT, {BUDGET: json.dumps(value).encode()})
+
+
+@pytest.mark.parametrize("key", ["web_scan_roots", "web_excluded_suffixes"])
+def test_a_candidate_cannot_drop_a_web_size_key_that_the_repository_has(key: str) -> None:
+ value = _budget()
+ del value[key]
+ with pytest.raises(ValueError, match="web scan"):
+ preserve_ratchets(ROOT, {BUDGET: json.dumps(value).encode()})
+
+
+def test_before_the_web_keys_existed_a_candidate_may_add_them_and_may_leave_them_out(
+ tmp_path: Path,
+) -> None:
+ old = _budget()
+ del old["web_scan_roots"]
+ del old["web_excluded_suffixes"]
+ (tmp_path / "config").mkdir()
+ (tmp_path / BUDGET).write_text(json.dumps(old), encoding="utf-8")
+ preserve_ratchets(tmp_path, {BUDGET: json.dumps(old).encode()})
+ preserve_ratchets(tmp_path, {BUDGET: json.dumps(_budget()).encode()})
+
+
+def test_a_candidate_cannot_enlarge_or_add_a_watched_web_file(tmp_path: Path) -> None:
+ old = _budget()
+ old["watched_modules"]["apps/web/src/api/example.ts"] = {
+ "baseline_lines": 700,
+ "top_level_symbols": [],
+ "policy": "fixture",
+ }
+ (tmp_path / "config").mkdir()
+ (tmp_path / BUDGET).write_text(json.dumps(old), encoding="utf-8")
+ bigger = json.loads(json.dumps(old))
+ bigger["watched_modules"]["apps/web/src/api/example.ts"]["baseline_lines"] = 701
+ with pytest.raises(ValueError, match="module ratchet"):
+ preserve_ratchets(tmp_path, {BUDGET: json.dumps(bigger).encode()})
+ added = json.loads(json.dumps(old))
+ added["watched_modules"]["apps/web/src/api/another.ts"] = {
+ "baseline_lines": 650,
+ "top_level_symbols": [],
+ "policy": "fixture",
+ }
+ with pytest.raises(ValueError, match="module ratchet"):
+ preserve_ratchets(tmp_path, {BUDGET: json.dumps(added).encode()})
diff --git a/tests/contract/test_file_upload.py b/tests/contract/test_file_upload.py
index 183c895..7b143c0 100644
--- a/tests/contract/test_file_upload.py
+++ b/tests/contract/test_file_upload.py
@@ -94,3 +94,43 @@ async def test_file_stage_rejects_corrupt_existing_digest_path(
)
assert second.status_code == 409
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("name", ["30_RESULT.yaml", "plan.YML"])
+async def test_file_stage_takes_yaml_as_plain_text_and_keeps_its_name(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, name: str
+) -> None:
+ workspace = tmp_path / "workspace"
+ monkeypatch.setenv("THOTH_WORKSPACE", str(workspace))
+ transport = httpx.ASGITransport(app=create_app())
+ body = b"identity: example\nnumerator: 19\n"
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
+ response = await client.post(
+ "/files/stage",
+ files={"file": (name, body, "application/octet-stream")},
+ headers={"x-thoth-project-id": "project:file-upload"},
+ )
+
+ assert response.status_code == 200
+ payload = response.json()
+ assert payload["media_type"] == "text/plain" and payload["filename"] == name
+ assert payload["relative_path"].lower().endswith(name.lower())
+ assert (workspace / "inbox" / payload["relative_path"]).read_bytes() == body
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("name", ["settings.toml", "notes.yaml.exe", "config.ini", "payload.yamlx"])
+async def test_file_stage_still_rejects_other_types_next_to_yaml(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, name: str
+) -> None:
+ monkeypatch.setenv("THOTH_WORKSPACE", str(tmp_path / "workspace"))
+ transport = httpx.ASGITransport(app=create_app())
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
+ response = await client.post(
+ "/files/stage",
+ files={"file": (name, b"a: 1\n", "text/plain")},
+ headers={"x-thoth-project-id": "project:file-upload"},
+ )
+
+ assert response.status_code == 415
diff --git a/tests/fixtures/hypothesis_eval/case_builder.py b/tests/fixtures/hypothesis_eval/case_builder.py
new file mode 100644
index 0000000..cbbc6f4
--- /dev/null
+++ b/tests/fixtures/hypothesis_eval/case_builder.py
@@ -0,0 +1,501 @@
+"""Synthetic cases for the trace reason tags: inputs, the answer key and the computed verdict view.
+
+Every value is invented (nothing measured). `cases.json` is written from this module and a test
+recomputes the verdict view from each case's trace set, so the stored view cannot drift from the
+server rule. The web tests read the same file.
+Rewrite it with `python -m tests.fixtures.hypothesis_eval.case_builder` (PYTHONPATH=src;.).
+"""
+
+from __future__ import annotations
+
+import json
+from datetime import UTC, datetime
+from decimal import Decimal
+from pathlib import Path
+from typing import Any
+
+from thoth.application.services.verification_trace import (
+ compute_verdicts,
+ dependency_changes,
+ mark_stale,
+)
+from thoth.domain.verification_trace import (
+ Comparator,
+ CriterionRule,
+ ResultRecord,
+ SelectionPolicy,
+ TraceItem,
+ TraceKind,
+ TraceLink,
+ TraceRelation,
+ TraceSet,
+ VerdictRevision,
+)
+
+NOTICE = "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+FILE = Path(__file__).with_name("cases.json")
+POLICY = SelectionPolicy()
+T0 = datetime(2026, 10, 6, 9, 0, tzinfo=UTC)
+T1 = datetime(2026, 10, 6, 10, 0, tzinfo=UTC)
+OBSERVED = datetime(2026, 10, 5, 12, 0, tzinfo=UTC)
+BANNED = [
+ "재시험 필수",
+ "원인은",
+ "기준을 낮추",
+ "통과 가능",
+ "승인됨",
+ "종결",
+ "해결",
+ "면제",
+ "AI 추천",
+]
+REQ = "SYN-REQ-1"
+
+
+def _item(item_id: str, kind: TraceKind, title: str = "") -> TraceItem:
+ return TraceItem(
+ item_id=item_id,
+ item_key="k-" + item_id,
+ kind=kind,
+ title=title,
+ fields={"synthetic_notice": NOTICE},
+ )
+
+
+def _crit(
+ number: str,
+ *,
+ condition: str = "weather=rain",
+ rule: bool = True,
+ unit: str = "ratio",
+ tested: bool = True,
+) -> dict[str, Any]:
+ return {
+ "id": "SYN-C-" + number,
+ "condition": condition,
+ "rule": rule,
+ "unit": unit,
+ "tested": tested,
+ }
+
+
+def _result(
+ criterion: str,
+ value: str | None,
+ *,
+ condition: str = "weather=rain",
+ unit: str = "ratio",
+ numerator: int | None = None,
+ denominator: int | None = None,
+ raw: str | None = None,
+ revision: int = 1,
+) -> ResultRecord:
+ return ResultRecord(
+ result_id="SYN-RES-" + criterion,
+ result_revision=revision,
+ criterion_id="SYN-C-" + criterion,
+ condition=condition,
+ value=None if value is None else Decimal(value),
+ raw_value=raw,
+ unit=unit,
+ numerator=numerator,
+ denominator=denominator,
+ observed_at=OBSERVED,
+ source_span_refs=("SYN-SOURCE.yaml#" + criterion,),
+ )
+
+
+def _trace(criteria: list[dict[str, Any]], results: list[ResultRecord]) -> TraceSet:
+ items = [_item(REQ, TraceKind.REQUIREMENT, "Synthetic requirement (invented example)")]
+ links: list[TraceLink] = []
+ rules: list[CriterionRule] = []
+ for index, spec in enumerate(criteria):
+ cid = spec["id"]
+ items.append(_item(cid, TraceKind.CRITERION, "Criterion " + cid))
+ links.append(
+ TraceLink(
+ link_id="L-ref-" + cid, from_id=cid, to_id=REQ, relation=TraceRelation.REFINES
+ )
+ )
+ if spec["rule"]:
+ rules.append(
+ CriterionRule(
+ rule_id="R-" + cid,
+ criterion_id=cid,
+ measure="detection_rate",
+ comparator=Comparator.AT_LEAST,
+ threshold=Decimal("0.90"),
+ unit=spec["unit"],
+ condition=spec["condition"],
+ )
+ )
+ if spec["tested"]:
+ case = f"SYN-TC-{index}"
+ items.append(_item(case, TraceKind.TEST_CASE, "Test " + case))
+ links.append(
+ TraceLink(
+ link_id="L-ver-" + cid,
+ from_id=cid,
+ to_id=case,
+ relation=TraceRelation.VERIFIED_BY,
+ )
+ )
+ for result in results:
+ items.append(_item(result.result_id, TraceKind.RESULT))
+ return TraceSet(
+ items=tuple(items), links=tuple(links), rules=tuple(rules), results=tuple(results)
+ )
+
+
+def _check(
+ kind: str,
+ subject: str,
+ state: str,
+ heads: list[str],
+ tags: list[str],
+ *,
+ next_step: bool,
+ stale: bool = False,
+) -> dict[str, Any]:
+ return {
+ "kind": kind,
+ "subject_id": subject,
+ "state": state,
+ "reason_codes": heads,
+ "tags": tags,
+ "next_step": next_step,
+ "stale": stale,
+ }
+
+
+def _crit_check(
+ number: str, state: str, heads: list[str], tags: list[str], **rest: Any
+) -> dict[str, Any]:
+ return _check("CRITERION", "SYN-C-" + number, state, heads, tags, **rest)
+
+
+def _req_check(state: str, heads: list[str], tags: list[str], **rest: Any) -> dict[str, Any]:
+ return _check("REQUIREMENT", REQ, state, heads, tags, **rest)
+
+
+def _case(
+ case_id: str,
+ title: str,
+ source: str,
+ mode: str,
+ before: TraceSet,
+ after: TraceSet | None,
+ checks: list[dict[str, Any]],
+) -> dict[str, Any]:
+ return {
+ "id": case_id,
+ "title": title,
+ "origin": source,
+ "mode": mode,
+ "synthetic_notice": NOTICE,
+ "trace_set": before.model_dump(mode="json"),
+ "after_trace_set": None if after is None else after.model_dump(mode="json"),
+ "checks": checks,
+ "banned_phrases": BANNED,
+ }
+
+
+def definitions() -> list[dict[str, Any]]:
+ dry = _crit("DET-DRY", condition="weather=dry")
+ rain = _crit("DET-RAIN")
+ fog = _crit("DET-FOG", condition="weather=fog")
+ dry_ok = _result("DET-DRY", "0.95", condition="weather=dry", numerator=19, denominator=20)
+ rain_miss = _result("DET-RAIN", "0.80", numerator=16, denominator=20)
+ fog_miss = _result("DET-FOG", "0.70", condition="weather=fog", numerator=14, denominator=20)
+ miss = "THRESHOLD_NOT_MET"
+ edited = _result("DET-RAIN", "0.92", numerator=23, denominator=25, revision=2)
+ rain_set = _trace([rain], [rain_miss])
+ return [
+ _case(
+ "fog-no-result",
+ "A tested condition has no result yet",
+ "demo: fog row, phase 1",
+ "plain",
+ _trace([dry, fog], [dry_ok]),
+ None,
+ [
+ _crit_check(
+ "DET-FOG", "HOLD_NO_RESULT", ["NO_RESULT"], ["UNTESTED"], next_step=True
+ ),
+ _crit_check("DET-DRY", "PASS_COMPUTED", [], [], next_step=False),
+ _req_check(
+ "HOLD_INCOMPLETE", ["SYN-C-DET-FOG"], ["REQUIRED_INCOMPLETE"], next_step=True
+ ),
+ ],
+ ),
+ _case(
+ "rain-valid-miss",
+ "A usable result misses the threshold",
+ "demo: rain row, phase 2",
+ "plain",
+ rain_set,
+ None,
+ [
+ _crit_check("DET-RAIN", "FAIL_COMPUTED", [miss], ["VALID_MISS"], next_step=True),
+ _req_check("FAIL", ["SYN-C-DET-RAIN"], [], next_step=True),
+ ],
+ ),
+ _case(
+ "rain-miss-and-fog-untested",
+ "A miss and an untested condition at once",
+ "demo: requirement row, phase 2",
+ "plain",
+ _trace([rain, fog], [rain_miss]),
+ None,
+ [
+ _crit_check("DET-RAIN", "FAIL_COMPUTED", [miss], ["VALID_MISS"], next_step=True),
+ _crit_check(
+ "DET-FOG", "HOLD_NO_RESULT", ["NO_RESULT"], ["UNTESTED"], next_step=True
+ ),
+ _req_check(
+ "FAIL_WITH_INCOMPLETE_COVERAGE",
+ ["SYN-C-DET-RAIN", "SYN-C-DET-FOG"],
+ ["REQUIRED_INCOMPLETE"],
+ next_step=True,
+ ),
+ ],
+ ),
+ _case(
+ "dry-pass",
+ "A met line gets no tags and no next check",
+ "demo: dry row",
+ "plain",
+ _trace([dry], [dry_ok]),
+ None,
+ [
+ _crit_check("DET-DRY", "PASS_COMPUTED", [], [], next_step=False),
+ _req_check("PASS", [], [], next_step=False),
+ ],
+ ),
+ _case(
+ "rain-result-edited",
+ "A result is edited and the verdict is not yet recomputed",
+ "demo: rain row, edited",
+ "stale",
+ rain_set,
+ _trace([rain], [edited]),
+ [
+ _crit_check(
+ "DET-RAIN",
+ "FAIL_COMPUTED",
+ [miss],
+ ["VALID_MISS", "BASIS_CHANGED"],
+ next_step=True,
+ stale=True,
+ ),
+ _req_check(
+ "FAIL", ["SYN-C-DET-RAIN"], ["BASIS_CHANGED"], next_step=True, stale=True
+ ),
+ ],
+ ),
+ _case(
+ "rain-result-edited-recomputed",
+ "The edited result is recomputed and now meets the threshold",
+ "demo: rain row, edited and recomputed",
+ "recomputed",
+ rain_set,
+ _trace([rain], [edited]),
+ [
+ _crit_check("DET-RAIN", "PASS_COMPUTED", [], [], next_step=False),
+ _req_check("PASS", [], [], next_step=False),
+ ],
+ ),
+ _case(
+ "unit-mismatch",
+ "The result unit differs from the rule unit",
+ "new",
+ "plain",
+ _trace([rain], [_result("DET-RAIN", "80", unit="percent")]),
+ None,
+ [
+ _crit_check(
+ "DET-RAIN", "HOLD_INVALID_RESULT", ["UNIT_MISMATCH"], ["UNIT"], next_step=True
+ ),
+ _req_check(
+ "HOLD_INCOMPLETE", ["SYN-C-DET-RAIN"], ["REQUIRED_INCOMPLETE"], next_step=True
+ ),
+ ],
+ ),
+ _case(
+ "denominator-zero",
+ "The denominator is zero",
+ "new",
+ "plain",
+ _trace([rain], [_result("DET-RAIN", "0.50", numerator=5, denominator=0)]),
+ None,
+ [
+ _crit_check(
+ "DET-RAIN",
+ "HOLD_INVALID_RESULT",
+ ["DENOMINATOR_NOT_POSITIVE"],
+ ["DENOMINATOR"],
+ next_step=True,
+ )
+ ],
+ ),
+ _case(
+ "value-disagrees-with-counts",
+ "The value is not numerator over denominator",
+ "new",
+ "plain",
+ _trace([rain], [_result("DET-RAIN", "0.50", numerator=9, denominator=10)]),
+ None,
+ [
+ _crit_check(
+ "DET-RAIN",
+ "HOLD_INVALID_RESULT",
+ ["VALUE_DISAGREES_WITH_COUNTS"],
+ ["DENOMINATOR"],
+ next_step=True,
+ )
+ ],
+ ),
+ _case(
+ "value-not-a-number",
+ "The value was written as text",
+ "new",
+ "plain",
+ _trace([rain], [_result("DET-RAIN", None, raw="n/a")]),
+ None,
+ [
+ _crit_check(
+ "DET-RAIN",
+ "HOLD_INVALID_RESULT",
+ ["VALUE_NOT_A_NUMBER"],
+ ["VALUE"],
+ next_step=True,
+ )
+ ],
+ ),
+ _case(
+ "condition-mismatch",
+ "A result exists but for another condition",
+ "new",
+ "plain",
+ _trace([rain], [_result("DET-RAIN", "0.95", condition="weather=fog")]),
+ None,
+ [
+ _crit_check(
+ "DET-RAIN",
+ "HOLD_NO_RESULT",
+ ["NO_RESULT"],
+ ["CONDITION_MISMATCH"],
+ next_step=True,
+ ),
+ _req_check(
+ "HOLD_INCOMPLETE", ["SYN-C-DET-RAIN"], ["REQUIRED_INCOMPLETE"], next_step=True
+ ),
+ ],
+ ),
+ _case(
+ "no-rule",
+ "A criterion has no rule",
+ "new",
+ "plain",
+ _trace([_crit("DET-RAIN", rule=False)], []),
+ None,
+ [
+ _crit_check("DET-RAIN", "HOLD_NO_RULE", ["NO_RULE"], ["NO_RULE"], next_step=True),
+ _req_check(
+ "HOLD_INCOMPLETE", ["SYN-C-DET-RAIN"], ["REQUIRED_INCOMPLETE"], next_step=True
+ ),
+ ],
+ ),
+ _case(
+ "no-test-linked",
+ "A criterion has a rule but no test is linked",
+ "new",
+ "plain",
+ _trace([_crit("DET-RAIN", tested=False)], []),
+ None,
+ [
+ _crit_check(
+ "DET-RAIN", "HOLD_NO_RESULT", ["NO_RESULT"], ["UNLINKED"], next_step=True
+ )
+ ],
+ ),
+ _case(
+ "two-conditions-miss",
+ "Two conditions miss at the same time",
+ "new",
+ "plain",
+ _trace([dry, rain, fog], [dry_ok, rain_miss, fog_miss]),
+ None,
+ [
+ _crit_check("DET-DRY", "PASS_COMPUTED", [], [], next_step=False),
+ _crit_check("DET-RAIN", "FAIL_COMPUTED", [miss], ["VALID_MISS"], next_step=True),
+ _crit_check("DET-FOG", "FAIL_COMPUTED", [miss], ["VALID_MISS"], next_step=True),
+ _req_check("FAIL", ["SYN-C-DET-RAIN", "SYN-C-DET-FOG"], [], next_step=True),
+ ],
+ ),
+ ]
+
+
+def _view_of(
+ current: TraceSet, verdicts: tuple[VerdictRevision, ...], stale: dict[Any, Any], pending: Any
+) -> dict[str, Any]:
+ entries: list[dict[str, Any]] = []
+ for verdict in sorted(verdicts, key=lambda item: (item.subject_kind.value, item.subject_id)):
+ shown = verdict.model_dump(mode="json")
+ entries.append(
+ {
+ **shown,
+ "currentness": stale[(verdict.subject_kind, verdict.subject_id)].model_dump(
+ mode="json"
+ ),
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1,
+ }
+ )
+ return {
+ "record_digest": None,
+ "set_digest": current.set_digest,
+ "items": [item.model_dump(mode="json") for item in current.items],
+ "links": [item.model_dump(mode="json") for item in current.links],
+ "rules": [item.model_dump(mode="json") for item in current.rules],
+ "results": [item.model_dump(mode="json") for item in current.results],
+ "policy": POLICY.model_dump(mode="json"),
+ "verdicts": entries,
+ "confirmations": [],
+ "pending_changes": [item.model_dump(mode="json") for item in pending],
+ }
+
+
+def view_of_case(case: dict[str, Any]) -> dict[str, Any]:
+ """The trace view the server would show for this case (what trace/read returns)."""
+ before = TraceSet.model_validate(case["trace_set"])
+ verdicts = compute_verdicts(before, POLICY, computed_at=T0, trigger="INITIAL_COMPUTE")
+ if case["mode"] == "plain":
+ return _view_of(before, verdicts, mark_stale(before, ()), ())
+ after = TraceSet.model_validate(case["after_trace_set"])
+ changes = dependency_changes(before, after)
+ if case["mode"] == "stale":
+ return _view_of(after, verdicts, mark_stale(after, changes), changes)
+ again = compute_verdicts(
+ after, POLICY, computed_at=T1, trigger="RECOMPUTE", changes=changes, previous=verdicts
+ )
+ return _view_of(after, again, mark_stale(after, ()), ())
+
+
+def build() -> dict[str, Any]:
+ cases = definitions()
+ for case in cases:
+ case["view"] = view_of_case(case)
+ return {"schema": "hypothesis-eval-cases/1", "synthetic_notice": NOTICE, "cases": cases}
+
+
+def main() -> None:
+ FILE.write_text(
+ json.dumps(build(), ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n"
+ )
+ print(f"wrote {FILE.name}: {len(build()['cases'])} cases")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/fixtures/hypothesis_eval/cases.json b/tests/fixtures/hypothesis_eval/cases.json
new file mode 100644
index 0000000..179df2b
--- /dev/null
+++ b/tests/fixtures/hypothesis_eval/cases.json
@@ -0,0 +1,6086 @@
+{
+ "schema": "hypothesis-eval-cases/1",
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE",
+ "cases": [
+ {
+ "id": "fog-no-result",
+ "title": "A tested condition has no result yet",
+ "origin": "demo: fog row, phase 1",
+ "mode": "plain",
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE",
+ "trace_set": {
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-DRY",
+ "item_key": "k-SYN-C-DET-DRY",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-DRY",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-0",
+ "item_key": "k-SYN-TC-0",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-0",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-FOG",
+ "item_key": "k-SYN-C-DET-FOG",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-FOG",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-1",
+ "item_key": "k-SYN-TC-1",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-1",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-DRY",
+ "item_key": "k-SYN-RES-DET-DRY",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-DRY",
+ "from_id": "SYN-C-DET-DRY",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-DRY",
+ "from_id": "SYN-C-DET-DRY",
+ "to_id": "SYN-TC-0",
+ "relation": "VERIFIED_BY"
+ },
+ {
+ "link_id": "L-ref-SYN-C-DET-FOG",
+ "from_id": "SYN-C-DET-FOG",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-FOG",
+ "from_id": "SYN-C-DET-FOG",
+ "to_id": "SYN-TC-1",
+ "relation": "VERIFIED_BY"
+ }
+ ],
+ "rules": [
+ {
+ "rule_id": "R-SYN-C-DET-DRY",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-DRY",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=dry",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ },
+ {
+ "rule_id": "R-SYN-C-DET-FOG",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-FOG",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=fog",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ }
+ ],
+ "results": [
+ {
+ "result_id": "SYN-RES-DET-DRY",
+ "result_revision": 1,
+ "criterion_id": "SYN-C-DET-DRY",
+ "condition": "weather=dry",
+ "value": "0.95",
+ "raw_value": null,
+ "unit": "ratio",
+ "numerator": 19,
+ "denominator": 20,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-DRY"
+ ]
+ }
+ ]
+ },
+ "after_trace_set": null,
+ "checks": [
+ {
+ "kind": "CRITERION",
+ "subject_id": "SYN-C-DET-FOG",
+ "state": "HOLD_NO_RESULT",
+ "reason_codes": [
+ "NO_RESULT"
+ ],
+ "tags": [
+ "UNTESTED"
+ ],
+ "next_step": true,
+ "stale": false
+ },
+ {
+ "kind": "CRITERION",
+ "subject_id": "SYN-C-DET-DRY",
+ "state": "PASS_COMPUTED",
+ "reason_codes": [],
+ "tags": [],
+ "next_step": false,
+ "stale": false
+ },
+ {
+ "kind": "REQUIREMENT",
+ "subject_id": "SYN-REQ-1",
+ "state": "HOLD_INCOMPLETE",
+ "reason_codes": [
+ "SYN-C-DET-FOG"
+ ],
+ "tags": [
+ "REQUIRED_INCOMPLETE"
+ ],
+ "next_step": true,
+ "stale": false
+ }
+ ],
+ "banned_phrases": [
+ "재시험 필수",
+ "원인은",
+ "기준을 낮추",
+ "통과 가능",
+ "승인됨",
+ "종결",
+ "해결",
+ "면제",
+ "AI 추천"
+ ],
+ "view": {
+ "record_digest": null,
+ "set_digest": "f7a37287717cbafe375e6d26b21e0abe2457ffca3e4e346ef69fffe33eeb6e74",
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-DRY",
+ "item_key": "k-SYN-C-DET-DRY",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-DRY",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-0",
+ "item_key": "k-SYN-TC-0",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-0",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-FOG",
+ "item_key": "k-SYN-C-DET-FOG",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-FOG",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-1",
+ "item_key": "k-SYN-TC-1",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-1",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-DRY",
+ "item_key": "k-SYN-RES-DET-DRY",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-DRY",
+ "from_id": "SYN-C-DET-DRY",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-DRY",
+ "from_id": "SYN-C-DET-DRY",
+ "to_id": "SYN-TC-0",
+ "relation": "VERIFIED_BY"
+ },
+ {
+ "link_id": "L-ref-SYN-C-DET-FOG",
+ "from_id": "SYN-C-DET-FOG",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-FOG",
+ "from_id": "SYN-C-DET-FOG",
+ "to_id": "SYN-TC-1",
+ "relation": "VERIFIED_BY"
+ }
+ ],
+ "rules": [
+ {
+ "rule_id": "R-SYN-C-DET-DRY",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-DRY",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=dry",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ },
+ {
+ "rule_id": "R-SYN-C-DET-FOG",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-FOG",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=fog",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ }
+ ],
+ "results": [
+ {
+ "result_id": "SYN-RES-DET-DRY",
+ "result_revision": 1,
+ "criterion_id": "SYN-C-DET-DRY",
+ "condition": "weather=dry",
+ "value": "0.95",
+ "raw_value": null,
+ "unit": "ratio",
+ "numerator": 19,
+ "denominator": 20,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-DRY"
+ ]
+ }
+ ],
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "verdicts": [
+ {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-DRY",
+ "state": "PASS_COMPUTED",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "068194228704bec79641eb9f34d5e7cff023942d263c2a5e47db8cf88da24118",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-DRY"
+ ],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": []
+ },
+ "selection": {
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "candidates": [
+ {
+ "result_id": "SYN-RES-DET-DRY",
+ "result_revision": 1
+ }
+ ],
+ "chosen": [
+ {
+ "result_id": "SYN-RES-DET-DRY",
+ "result_revision": 1
+ }
+ ],
+ "excluded": []
+ },
+ "conditions": {
+ "required": true,
+ "rule_id": "R-SYN-C-DET-DRY",
+ "rule_revision": 1,
+ "condition": "weather=dry",
+ "unit": "ratio",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required_criteria": []
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "a132542a80e53090e753aa039f6eb695f5ac4aff4f9bd3e77de8ba63146077ec",
+ "revision_digest": "5e2ae89df0d68e1ca093fdd633379a12abe2a5646b1665fef297c453ec53b1ff",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ },
+ {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-FOG",
+ "state": "HOLD_NO_RESULT",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990",
+ "source_span_refs": [],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": []
+ },
+ "selection": {
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "candidates": [],
+ "chosen": [],
+ "excluded": []
+ },
+ "conditions": {
+ "required": true,
+ "rule_id": "R-SYN-C-DET-FOG",
+ "rule_revision": 1,
+ "condition": "weather=fog",
+ "unit": "ratio",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required_criteria": []
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [
+ "NO_RESULT:weather=fog"
+ ],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40",
+ "revision_digest": "152cc15834d82f0b9b85ac6adcadcefa3fe6bfd2d56059851feea9efa6bbff13",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ },
+ {
+ "subject_kind": "REQUIREMENT",
+ "subject_id": "SYN-REQ-1",
+ "state": "HOLD_INCOMPLETE",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "2b6c702a9c81db30b813dd21ef6ec4809e2ad0d3ed38188714e5114e26c81a30",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-DRY"
+ ],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": [
+ "a132542a80e53090e753aa039f6eb695f5ac4aff4f9bd3e77de8ba63146077ec",
+ "c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40"
+ ]
+ },
+ "selection": null,
+ "conditions": {
+ "required": true,
+ "rule_id": null,
+ "rule_revision": null,
+ "condition": null,
+ "unit": null,
+ "comparator": null,
+ "threshold": null,
+ "rounding": null,
+ "required_criteria": [
+ "SYN-C-DET-DRY",
+ "SYN-C-DET-FOG"
+ ]
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [
+ "SYN-C-DET-FOG:HOLD_NO_RESULT"
+ ],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "4f2428af5f6d7126edfebd42fb54aac4300f53d04824cea56981c478f15e24bb",
+ "revision_digest": "6f24af4a6a05fca04588277be5deb364544b07fddaec6484fd28c9de636945c5",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ }
+ ],
+ "confirmations": [],
+ "pending_changes": []
+ }
+ },
+ {
+ "id": "rain-valid-miss",
+ "title": "A usable result misses the threshold",
+ "origin": "demo: rain row, phase 2",
+ "mode": "plain",
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE",
+ "trace_set": {
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-RAIN",
+ "item_key": "k-SYN-C-DET-RAIN",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-RAIN",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-0",
+ "item_key": "k-SYN-TC-0",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-0",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-RAIN",
+ "item_key": "k-SYN-RES-DET-RAIN",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-TC-0",
+ "relation": "VERIFIED_BY"
+ }
+ ],
+ "rules": [
+ {
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ }
+ ],
+ "results": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "condition": "weather=rain",
+ "value": "0.80",
+ "raw_value": null,
+ "unit": "ratio",
+ "numerator": 16,
+ "denominator": 20,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ]
+ }
+ ]
+ },
+ "after_trace_set": null,
+ "checks": [
+ {
+ "kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "state": "FAIL_COMPUTED",
+ "reason_codes": [
+ "THRESHOLD_NOT_MET"
+ ],
+ "tags": [
+ "VALID_MISS"
+ ],
+ "next_step": true,
+ "stale": false
+ },
+ {
+ "kind": "REQUIREMENT",
+ "subject_id": "SYN-REQ-1",
+ "state": "FAIL",
+ "reason_codes": [
+ "SYN-C-DET-RAIN"
+ ],
+ "tags": [],
+ "next_step": true,
+ "stale": false
+ }
+ ],
+ "banned_phrases": [
+ "재시험 필수",
+ "원인은",
+ "기준을 낮추",
+ "통과 가능",
+ "승인됨",
+ "종결",
+ "해결",
+ "면제",
+ "AI 추천"
+ ],
+ "view": {
+ "record_digest": null,
+ "set_digest": "38df3f3318e1558f1baadf8f73dd5eb7e5b347a837915e1dfae70a59c43344e9",
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-RAIN",
+ "item_key": "k-SYN-C-DET-RAIN",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-RAIN",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-0",
+ "item_key": "k-SYN-TC-0",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-0",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-RAIN",
+ "item_key": "k-SYN-RES-DET-RAIN",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-TC-0",
+ "relation": "VERIFIED_BY"
+ }
+ ],
+ "rules": [
+ {
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ }
+ ],
+ "results": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "condition": "weather=rain",
+ "value": "0.80",
+ "raw_value": null,
+ "unit": "ratio",
+ "numerator": 16,
+ "denominator": 20,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ]
+ }
+ ],
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "verdicts": [
+ {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "state": "FAIL_COMPUTED",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "fb04ca2e941377a6b6afc255ae652b018e54f00881839090870fe44dff8118b1",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": []
+ },
+ "selection": {
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "candidates": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1
+ }
+ ],
+ "chosen": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1
+ }
+ ],
+ "excluded": []
+ },
+ "conditions": {
+ "required": true,
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "condition": "weather=rain",
+ "unit": "ratio",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required_criteria": []
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [
+ "THRESHOLD_NOT_MET:SYN-RES-DET-RAIN:value=0.80 need >= 0.90 ratio"
+ ],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "9b3c31762a13829734688a7b9d77131dcad6f2e38a3e2d5719f41cf7323d3923",
+ "revision_digest": "4f227ec96462d428dba48207a00537c7b9a919c081bd1500ebb015675eba7048",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ },
+ {
+ "subject_kind": "REQUIREMENT",
+ "subject_id": "SYN-REQ-1",
+ "state": "FAIL",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "5fe28aa8309f26ccf26a9b87ba69e97f439f57ab41592b93a67c6d1df7f3b8ab",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": [
+ "9b3c31762a13829734688a7b9d77131dcad6f2e38a3e2d5719f41cf7323d3923"
+ ]
+ },
+ "selection": null,
+ "conditions": {
+ "required": true,
+ "rule_id": null,
+ "rule_revision": null,
+ "condition": null,
+ "unit": null,
+ "comparator": null,
+ "threshold": null,
+ "rounding": null,
+ "required_criteria": [
+ "SYN-C-DET-RAIN"
+ ]
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [
+ "SYN-C-DET-RAIN:FAIL_COMPUTED"
+ ],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "ee890b9d2a7bb2d6e70c88c343e797f3c8a5033c4d8df40c0ae2b1112eac1660",
+ "revision_digest": "3da56089801a66bf21cb3f7452ff68b2ea421adb727d56ca23eeef01a8985a39",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ }
+ ],
+ "confirmations": [],
+ "pending_changes": []
+ }
+ },
+ {
+ "id": "rain-miss-and-fog-untested",
+ "title": "A miss and an untested condition at once",
+ "origin": "demo: requirement row, phase 2",
+ "mode": "plain",
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE",
+ "trace_set": {
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-RAIN",
+ "item_key": "k-SYN-C-DET-RAIN",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-RAIN",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-0",
+ "item_key": "k-SYN-TC-0",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-0",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-FOG",
+ "item_key": "k-SYN-C-DET-FOG",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-FOG",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-1",
+ "item_key": "k-SYN-TC-1",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-1",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-RAIN",
+ "item_key": "k-SYN-RES-DET-RAIN",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-TC-0",
+ "relation": "VERIFIED_BY"
+ },
+ {
+ "link_id": "L-ref-SYN-C-DET-FOG",
+ "from_id": "SYN-C-DET-FOG",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-FOG",
+ "from_id": "SYN-C-DET-FOG",
+ "to_id": "SYN-TC-1",
+ "relation": "VERIFIED_BY"
+ }
+ ],
+ "rules": [
+ {
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ },
+ {
+ "rule_id": "R-SYN-C-DET-FOG",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-FOG",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=fog",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ }
+ ],
+ "results": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "condition": "weather=rain",
+ "value": "0.80",
+ "raw_value": null,
+ "unit": "ratio",
+ "numerator": 16,
+ "denominator": 20,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ]
+ }
+ ]
+ },
+ "after_trace_set": null,
+ "checks": [
+ {
+ "kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "state": "FAIL_COMPUTED",
+ "reason_codes": [
+ "THRESHOLD_NOT_MET"
+ ],
+ "tags": [
+ "VALID_MISS"
+ ],
+ "next_step": true,
+ "stale": false
+ },
+ {
+ "kind": "CRITERION",
+ "subject_id": "SYN-C-DET-FOG",
+ "state": "HOLD_NO_RESULT",
+ "reason_codes": [
+ "NO_RESULT"
+ ],
+ "tags": [
+ "UNTESTED"
+ ],
+ "next_step": true,
+ "stale": false
+ },
+ {
+ "kind": "REQUIREMENT",
+ "subject_id": "SYN-REQ-1",
+ "state": "FAIL_WITH_INCOMPLETE_COVERAGE",
+ "reason_codes": [
+ "SYN-C-DET-RAIN",
+ "SYN-C-DET-FOG"
+ ],
+ "tags": [
+ "REQUIRED_INCOMPLETE"
+ ],
+ "next_step": true,
+ "stale": false
+ }
+ ],
+ "banned_phrases": [
+ "재시험 필수",
+ "원인은",
+ "기준을 낮추",
+ "통과 가능",
+ "승인됨",
+ "종결",
+ "해결",
+ "면제",
+ "AI 추천"
+ ],
+ "view": {
+ "record_digest": null,
+ "set_digest": "f9a10b6ccae9da5df3bf8de45945fa6373ba87ffe0cfc7efad8e4aa7282843f7",
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-RAIN",
+ "item_key": "k-SYN-C-DET-RAIN",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-RAIN",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-0",
+ "item_key": "k-SYN-TC-0",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-0",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-FOG",
+ "item_key": "k-SYN-C-DET-FOG",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-FOG",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-1",
+ "item_key": "k-SYN-TC-1",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-1",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-RAIN",
+ "item_key": "k-SYN-RES-DET-RAIN",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-TC-0",
+ "relation": "VERIFIED_BY"
+ },
+ {
+ "link_id": "L-ref-SYN-C-DET-FOG",
+ "from_id": "SYN-C-DET-FOG",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-FOG",
+ "from_id": "SYN-C-DET-FOG",
+ "to_id": "SYN-TC-1",
+ "relation": "VERIFIED_BY"
+ }
+ ],
+ "rules": [
+ {
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ },
+ {
+ "rule_id": "R-SYN-C-DET-FOG",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-FOG",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=fog",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ }
+ ],
+ "results": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "condition": "weather=rain",
+ "value": "0.80",
+ "raw_value": null,
+ "unit": "ratio",
+ "numerator": 16,
+ "denominator": 20,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ]
+ }
+ ],
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "verdicts": [
+ {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-FOG",
+ "state": "HOLD_NO_RESULT",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "1cf128fd38a5dc67da406cb676e8d095d02e4ceb49af4e9602ed77a44003d990",
+ "source_span_refs": [],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": []
+ },
+ "selection": {
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "candidates": [],
+ "chosen": [],
+ "excluded": []
+ },
+ "conditions": {
+ "required": true,
+ "rule_id": "R-SYN-C-DET-FOG",
+ "rule_revision": 1,
+ "condition": "weather=fog",
+ "unit": "ratio",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required_criteria": []
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [
+ "NO_RESULT:weather=fog"
+ ],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40",
+ "revision_digest": "152cc15834d82f0b9b85ac6adcadcefa3fe6bfd2d56059851feea9efa6bbff13",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ },
+ {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "state": "FAIL_COMPUTED",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "fb04ca2e941377a6b6afc255ae652b018e54f00881839090870fe44dff8118b1",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": []
+ },
+ "selection": {
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "candidates": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1
+ }
+ ],
+ "chosen": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1
+ }
+ ],
+ "excluded": []
+ },
+ "conditions": {
+ "required": true,
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "condition": "weather=rain",
+ "unit": "ratio",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required_criteria": []
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [
+ "THRESHOLD_NOT_MET:SYN-RES-DET-RAIN:value=0.80 need >= 0.90 ratio"
+ ],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "9b3c31762a13829734688a7b9d77131dcad6f2e38a3e2d5719f41cf7323d3923",
+ "revision_digest": "4f227ec96462d428dba48207a00537c7b9a919c081bd1500ebb015675eba7048",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ },
+ {
+ "subject_kind": "REQUIREMENT",
+ "subject_id": "SYN-REQ-1",
+ "state": "FAIL_WITH_INCOMPLETE_COVERAGE",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "e2836b04039ad2b17baf5ad0208c6b1517b3b8a709c26a388a1506d0ff684d12",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": [
+ "c059638c27046c3d31f66a3bfe39c5a7841026672f30e896e7e83234302aac40",
+ "9b3c31762a13829734688a7b9d77131dcad6f2e38a3e2d5719f41cf7323d3923"
+ ]
+ },
+ "selection": null,
+ "conditions": {
+ "required": true,
+ "rule_id": null,
+ "rule_revision": null,
+ "condition": null,
+ "unit": null,
+ "comparator": null,
+ "threshold": null,
+ "rounding": null,
+ "required_criteria": [
+ "SYN-C-DET-FOG",
+ "SYN-C-DET-RAIN"
+ ]
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [
+ "SYN-C-DET-FOG:HOLD_NO_RESULT",
+ "SYN-C-DET-RAIN:FAIL_COMPUTED"
+ ],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "608b4992ff579bc73e2a93519d223da32a31e2c461fd03cbb3dcd827fa63b17a",
+ "revision_digest": "792218117ab1b1346d3ea4c0e6d7f8ad19606b18cfad91960e880cff444a92fb",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ }
+ ],
+ "confirmations": [],
+ "pending_changes": []
+ }
+ },
+ {
+ "id": "dry-pass",
+ "title": "A met line gets no tags and no next check",
+ "origin": "demo: dry row",
+ "mode": "plain",
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE",
+ "trace_set": {
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-DRY",
+ "item_key": "k-SYN-C-DET-DRY",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-DRY",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-0",
+ "item_key": "k-SYN-TC-0",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-0",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-DRY",
+ "item_key": "k-SYN-RES-DET-DRY",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-DRY",
+ "from_id": "SYN-C-DET-DRY",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-DRY",
+ "from_id": "SYN-C-DET-DRY",
+ "to_id": "SYN-TC-0",
+ "relation": "VERIFIED_BY"
+ }
+ ],
+ "rules": [
+ {
+ "rule_id": "R-SYN-C-DET-DRY",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-DRY",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=dry",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ }
+ ],
+ "results": [
+ {
+ "result_id": "SYN-RES-DET-DRY",
+ "result_revision": 1,
+ "criterion_id": "SYN-C-DET-DRY",
+ "condition": "weather=dry",
+ "value": "0.95",
+ "raw_value": null,
+ "unit": "ratio",
+ "numerator": 19,
+ "denominator": 20,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-DRY"
+ ]
+ }
+ ]
+ },
+ "after_trace_set": null,
+ "checks": [
+ {
+ "kind": "CRITERION",
+ "subject_id": "SYN-C-DET-DRY",
+ "state": "PASS_COMPUTED",
+ "reason_codes": [],
+ "tags": [],
+ "next_step": false,
+ "stale": false
+ },
+ {
+ "kind": "REQUIREMENT",
+ "subject_id": "SYN-REQ-1",
+ "state": "PASS",
+ "reason_codes": [],
+ "tags": [],
+ "next_step": false,
+ "stale": false
+ }
+ ],
+ "banned_phrases": [
+ "재시험 필수",
+ "원인은",
+ "기준을 낮추",
+ "통과 가능",
+ "승인됨",
+ "종결",
+ "해결",
+ "면제",
+ "AI 추천"
+ ],
+ "view": {
+ "record_digest": null,
+ "set_digest": "b3318e0f7fd7afd69b65193cbaa37517f289149a32dfb453e90766b86bdd9fc2",
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-DRY",
+ "item_key": "k-SYN-C-DET-DRY",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-DRY",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-0",
+ "item_key": "k-SYN-TC-0",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-0",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-DRY",
+ "item_key": "k-SYN-RES-DET-DRY",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-DRY",
+ "from_id": "SYN-C-DET-DRY",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-DRY",
+ "from_id": "SYN-C-DET-DRY",
+ "to_id": "SYN-TC-0",
+ "relation": "VERIFIED_BY"
+ }
+ ],
+ "rules": [
+ {
+ "rule_id": "R-SYN-C-DET-DRY",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-DRY",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=dry",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ }
+ ],
+ "results": [
+ {
+ "result_id": "SYN-RES-DET-DRY",
+ "result_revision": 1,
+ "criterion_id": "SYN-C-DET-DRY",
+ "condition": "weather=dry",
+ "value": "0.95",
+ "raw_value": null,
+ "unit": "ratio",
+ "numerator": 19,
+ "denominator": 20,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-DRY"
+ ]
+ }
+ ],
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "verdicts": [
+ {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-DRY",
+ "state": "PASS_COMPUTED",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "068194228704bec79641eb9f34d5e7cff023942d263c2a5e47db8cf88da24118",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-DRY"
+ ],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": []
+ },
+ "selection": {
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "candidates": [
+ {
+ "result_id": "SYN-RES-DET-DRY",
+ "result_revision": 1
+ }
+ ],
+ "chosen": [
+ {
+ "result_id": "SYN-RES-DET-DRY",
+ "result_revision": 1
+ }
+ ],
+ "excluded": []
+ },
+ "conditions": {
+ "required": true,
+ "rule_id": "R-SYN-C-DET-DRY",
+ "rule_revision": 1,
+ "condition": "weather=dry",
+ "unit": "ratio",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required_criteria": []
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "a132542a80e53090e753aa039f6eb695f5ac4aff4f9bd3e77de8ba63146077ec",
+ "revision_digest": "5e2ae89df0d68e1ca093fdd633379a12abe2a5646b1665fef297c453ec53b1ff",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ },
+ {
+ "subject_kind": "REQUIREMENT",
+ "subject_id": "SYN-REQ-1",
+ "state": "PASS",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "7c6bc3ce48b0389f462b92d84a2c85cd715a1092de662418841a698391b1dcc5",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-DRY"
+ ],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": [
+ "a132542a80e53090e753aa039f6eb695f5ac4aff4f9bd3e77de8ba63146077ec"
+ ]
+ },
+ "selection": null,
+ "conditions": {
+ "required": true,
+ "rule_id": null,
+ "rule_revision": null,
+ "condition": null,
+ "unit": null,
+ "comparator": null,
+ "threshold": null,
+ "rounding": null,
+ "required_criteria": [
+ "SYN-C-DET-DRY"
+ ]
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "cbd70bf1e8c0de47e8a5378dc0e9188a426d850fb17d81977789f0e2ebcbb16b",
+ "revision_digest": "3ce4c78909103fb55cd9de11af225bd923468d503534b440a776ac740fc8b962",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ }
+ ],
+ "confirmations": [],
+ "pending_changes": []
+ }
+ },
+ {
+ "id": "rain-result-edited",
+ "title": "A result is edited and the verdict is not yet recomputed",
+ "origin": "demo: rain row, edited",
+ "mode": "stale",
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE",
+ "trace_set": {
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-RAIN",
+ "item_key": "k-SYN-C-DET-RAIN",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-RAIN",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-0",
+ "item_key": "k-SYN-TC-0",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-0",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-RAIN",
+ "item_key": "k-SYN-RES-DET-RAIN",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-TC-0",
+ "relation": "VERIFIED_BY"
+ }
+ ],
+ "rules": [
+ {
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ }
+ ],
+ "results": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "condition": "weather=rain",
+ "value": "0.80",
+ "raw_value": null,
+ "unit": "ratio",
+ "numerator": 16,
+ "denominator": 20,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ]
+ }
+ ]
+ },
+ "after_trace_set": {
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-RAIN",
+ "item_key": "k-SYN-C-DET-RAIN",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-RAIN",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-0",
+ "item_key": "k-SYN-TC-0",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-0",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-RAIN",
+ "item_key": "k-SYN-RES-DET-RAIN",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-TC-0",
+ "relation": "VERIFIED_BY"
+ }
+ ],
+ "rules": [
+ {
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ }
+ ],
+ "results": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 2,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "condition": "weather=rain",
+ "value": "0.92",
+ "raw_value": null,
+ "unit": "ratio",
+ "numerator": 23,
+ "denominator": 25,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ]
+ }
+ ]
+ },
+ "checks": [
+ {
+ "kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "state": "FAIL_COMPUTED",
+ "reason_codes": [
+ "THRESHOLD_NOT_MET"
+ ],
+ "tags": [
+ "VALID_MISS",
+ "BASIS_CHANGED"
+ ],
+ "next_step": true,
+ "stale": true
+ },
+ {
+ "kind": "REQUIREMENT",
+ "subject_id": "SYN-REQ-1",
+ "state": "FAIL",
+ "reason_codes": [
+ "SYN-C-DET-RAIN"
+ ],
+ "tags": [
+ "BASIS_CHANGED"
+ ],
+ "next_step": true,
+ "stale": true
+ }
+ ],
+ "banned_phrases": [
+ "재시험 필수",
+ "원인은",
+ "기준을 낮추",
+ "통과 가능",
+ "승인됨",
+ "종결",
+ "해결",
+ "면제",
+ "AI 추천"
+ ],
+ "view": {
+ "record_digest": null,
+ "set_digest": "d78a5f408eee24c4e55c0f5cffd69cb8181e61f86a5141e84037b57d527dbaee",
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-RAIN",
+ "item_key": "k-SYN-C-DET-RAIN",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-RAIN",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-0",
+ "item_key": "k-SYN-TC-0",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-0",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-RAIN",
+ "item_key": "k-SYN-RES-DET-RAIN",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-TC-0",
+ "relation": "VERIFIED_BY"
+ }
+ ],
+ "rules": [
+ {
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ }
+ ],
+ "results": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 2,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "condition": "weather=rain",
+ "value": "0.92",
+ "raw_value": null,
+ "unit": "ratio",
+ "numerator": 23,
+ "denominator": 25,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ]
+ }
+ ],
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "verdicts": [
+ {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "state": "FAIL_COMPUTED",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "fb04ca2e941377a6b6afc255ae652b018e54f00881839090870fe44dff8118b1",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": []
+ },
+ "selection": {
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "candidates": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1
+ }
+ ],
+ "chosen": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1
+ }
+ ],
+ "excluded": []
+ },
+ "conditions": {
+ "required": true,
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "condition": "weather=rain",
+ "unit": "ratio",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required_criteria": []
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [
+ "THRESHOLD_NOT_MET:SYN-RES-DET-RAIN:value=0.80 need >= 0.90 ratio"
+ ],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "9b3c31762a13829734688a7b9d77131dcad6f2e38a3e2d5719f41cf7323d3923",
+ "revision_digest": "4f227ec96462d428dba48207a00537c7b9a919c081bd1500ebb015675eba7048",
+ "currentness": {
+ "state": "STALE_BASIS",
+ "changed_dependencies": [
+ {
+ "kind": "RESULT",
+ "ref_id": "SYN-RES-DET-RAIN",
+ "field": "denominator",
+ "before": "20",
+ "after": "25",
+ "before_revision": 1,
+ "after_revision": 2,
+ "criterion_ids": [
+ "SYN-C-DET-RAIN"
+ ],
+ "requirement_ids": [
+ "SYN-REQ-1"
+ ]
+ },
+ {
+ "kind": "RESULT",
+ "ref_id": "SYN-RES-DET-RAIN",
+ "field": "numerator",
+ "before": "16",
+ "after": "23",
+ "before_revision": 1,
+ "after_revision": 2,
+ "criterion_ids": [
+ "SYN-C-DET-RAIN"
+ ],
+ "requirement_ids": [
+ "SYN-REQ-1"
+ ]
+ },
+ {
+ "kind": "RESULT",
+ "ref_id": "SYN-RES-DET-RAIN",
+ "field": "result_revision",
+ "before": "1",
+ "after": "2",
+ "before_revision": 1,
+ "after_revision": 2,
+ "criterion_ids": [
+ "SYN-C-DET-RAIN"
+ ],
+ "requirement_ids": [
+ "SYN-REQ-1"
+ ]
+ },
+ {
+ "kind": "RESULT",
+ "ref_id": "SYN-RES-DET-RAIN",
+ "field": "value",
+ "before": "0.80",
+ "after": "0.92",
+ "before_revision": 1,
+ "after_revision": 2,
+ "criterion_ids": [
+ "SYN-C-DET-RAIN"
+ ],
+ "requirement_ids": [
+ "SYN-REQ-1"
+ ]
+ }
+ ]
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ },
+ {
+ "subject_kind": "REQUIREMENT",
+ "subject_id": "SYN-REQ-1",
+ "state": "FAIL",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "5fe28aa8309f26ccf26a9b87ba69e97f439f57ab41592b93a67c6d1df7f3b8ab",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": [
+ "9b3c31762a13829734688a7b9d77131dcad6f2e38a3e2d5719f41cf7323d3923"
+ ]
+ },
+ "selection": null,
+ "conditions": {
+ "required": true,
+ "rule_id": null,
+ "rule_revision": null,
+ "condition": null,
+ "unit": null,
+ "comparator": null,
+ "threshold": null,
+ "rounding": null,
+ "required_criteria": [
+ "SYN-C-DET-RAIN"
+ ]
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [
+ "SYN-C-DET-RAIN:FAIL_COMPUTED"
+ ],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "ee890b9d2a7bb2d6e70c88c343e797f3c8a5033c4d8df40c0ae2b1112eac1660",
+ "revision_digest": "3da56089801a66bf21cb3f7452ff68b2ea421adb727d56ca23eeef01a8985a39",
+ "currentness": {
+ "state": "STALE_BASIS",
+ "changed_dependencies": [
+ {
+ "kind": "RESULT",
+ "ref_id": "SYN-RES-DET-RAIN",
+ "field": "denominator",
+ "before": "20",
+ "after": "25",
+ "before_revision": 1,
+ "after_revision": 2,
+ "criterion_ids": [
+ "SYN-C-DET-RAIN"
+ ],
+ "requirement_ids": [
+ "SYN-REQ-1"
+ ]
+ },
+ {
+ "kind": "RESULT",
+ "ref_id": "SYN-RES-DET-RAIN",
+ "field": "numerator",
+ "before": "16",
+ "after": "23",
+ "before_revision": 1,
+ "after_revision": 2,
+ "criterion_ids": [
+ "SYN-C-DET-RAIN"
+ ],
+ "requirement_ids": [
+ "SYN-REQ-1"
+ ]
+ },
+ {
+ "kind": "RESULT",
+ "ref_id": "SYN-RES-DET-RAIN",
+ "field": "result_revision",
+ "before": "1",
+ "after": "2",
+ "before_revision": 1,
+ "after_revision": 2,
+ "criterion_ids": [
+ "SYN-C-DET-RAIN"
+ ],
+ "requirement_ids": [
+ "SYN-REQ-1"
+ ]
+ },
+ {
+ "kind": "RESULT",
+ "ref_id": "SYN-RES-DET-RAIN",
+ "field": "value",
+ "before": "0.80",
+ "after": "0.92",
+ "before_revision": 1,
+ "after_revision": 2,
+ "criterion_ids": [
+ "SYN-C-DET-RAIN"
+ ],
+ "requirement_ids": [
+ "SYN-REQ-1"
+ ]
+ }
+ ]
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ }
+ ],
+ "confirmations": [],
+ "pending_changes": [
+ {
+ "kind": "RESULT",
+ "ref_id": "SYN-RES-DET-RAIN",
+ "field": "denominator",
+ "before": "20",
+ "after": "25",
+ "before_revision": 1,
+ "after_revision": 2,
+ "criterion_ids": [
+ "SYN-C-DET-RAIN"
+ ],
+ "requirement_ids": [
+ "SYN-REQ-1"
+ ]
+ },
+ {
+ "kind": "RESULT",
+ "ref_id": "SYN-RES-DET-RAIN",
+ "field": "numerator",
+ "before": "16",
+ "after": "23",
+ "before_revision": 1,
+ "after_revision": 2,
+ "criterion_ids": [
+ "SYN-C-DET-RAIN"
+ ],
+ "requirement_ids": [
+ "SYN-REQ-1"
+ ]
+ },
+ {
+ "kind": "RESULT",
+ "ref_id": "SYN-RES-DET-RAIN",
+ "field": "result_revision",
+ "before": "1",
+ "after": "2",
+ "before_revision": 1,
+ "after_revision": 2,
+ "criterion_ids": [
+ "SYN-C-DET-RAIN"
+ ],
+ "requirement_ids": [
+ "SYN-REQ-1"
+ ]
+ },
+ {
+ "kind": "RESULT",
+ "ref_id": "SYN-RES-DET-RAIN",
+ "field": "value",
+ "before": "0.80",
+ "after": "0.92",
+ "before_revision": 1,
+ "after_revision": 2,
+ "criterion_ids": [
+ "SYN-C-DET-RAIN"
+ ],
+ "requirement_ids": [
+ "SYN-REQ-1"
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "id": "rain-result-edited-recomputed",
+ "title": "The edited result is recomputed and now meets the threshold",
+ "origin": "demo: rain row, edited and recomputed",
+ "mode": "recomputed",
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE",
+ "trace_set": {
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-RAIN",
+ "item_key": "k-SYN-C-DET-RAIN",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-RAIN",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-0",
+ "item_key": "k-SYN-TC-0",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-0",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-RAIN",
+ "item_key": "k-SYN-RES-DET-RAIN",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-TC-0",
+ "relation": "VERIFIED_BY"
+ }
+ ],
+ "rules": [
+ {
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ }
+ ],
+ "results": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "condition": "weather=rain",
+ "value": "0.80",
+ "raw_value": null,
+ "unit": "ratio",
+ "numerator": 16,
+ "denominator": 20,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ]
+ }
+ ]
+ },
+ "after_trace_set": {
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-RAIN",
+ "item_key": "k-SYN-C-DET-RAIN",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-RAIN",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-0",
+ "item_key": "k-SYN-TC-0",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-0",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-RAIN",
+ "item_key": "k-SYN-RES-DET-RAIN",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-TC-0",
+ "relation": "VERIFIED_BY"
+ }
+ ],
+ "rules": [
+ {
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ }
+ ],
+ "results": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 2,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "condition": "weather=rain",
+ "value": "0.92",
+ "raw_value": null,
+ "unit": "ratio",
+ "numerator": 23,
+ "denominator": 25,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ]
+ }
+ ]
+ },
+ "checks": [
+ {
+ "kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "state": "PASS_COMPUTED",
+ "reason_codes": [],
+ "tags": [],
+ "next_step": false,
+ "stale": false
+ },
+ {
+ "kind": "REQUIREMENT",
+ "subject_id": "SYN-REQ-1",
+ "state": "PASS",
+ "reason_codes": [],
+ "tags": [],
+ "next_step": false,
+ "stale": false
+ }
+ ],
+ "banned_phrases": [
+ "재시험 필수",
+ "원인은",
+ "기준을 낮추",
+ "통과 가능",
+ "승인됨",
+ "종결",
+ "해결",
+ "면제",
+ "AI 추천"
+ ],
+ "view": {
+ "record_digest": null,
+ "set_digest": "d78a5f408eee24c4e55c0f5cffd69cb8181e61f86a5141e84037b57d527dbaee",
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-RAIN",
+ "item_key": "k-SYN-C-DET-RAIN",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-RAIN",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-0",
+ "item_key": "k-SYN-TC-0",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-0",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-RAIN",
+ "item_key": "k-SYN-RES-DET-RAIN",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-TC-0",
+ "relation": "VERIFIED_BY"
+ }
+ ],
+ "rules": [
+ {
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ }
+ ],
+ "results": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 2,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "condition": "weather=rain",
+ "value": "0.92",
+ "raw_value": null,
+ "unit": "ratio",
+ "numerator": 23,
+ "denominator": 25,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ]
+ }
+ ],
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "verdicts": [
+ {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "state": "PASS_COMPUTED",
+ "cause": {
+ "trigger": "RECOMPUTE",
+ "changed": [
+ {
+ "kind": "RESULT",
+ "ref_id": "SYN-RES-DET-RAIN",
+ "field": "denominator",
+ "before": "20",
+ "after": "25",
+ "before_revision": 1,
+ "after_revision": 2,
+ "criterion_ids": [
+ "SYN-C-DET-RAIN"
+ ],
+ "requirement_ids": [
+ "SYN-REQ-1"
+ ]
+ },
+ {
+ "kind": "RESULT",
+ "ref_id": "SYN-RES-DET-RAIN",
+ "field": "numerator",
+ "before": "16",
+ "after": "23",
+ "before_revision": 1,
+ "after_revision": 2,
+ "criterion_ids": [
+ "SYN-C-DET-RAIN"
+ ],
+ "requirement_ids": [
+ "SYN-REQ-1"
+ ]
+ },
+ {
+ "kind": "RESULT",
+ "ref_id": "SYN-RES-DET-RAIN",
+ "field": "result_revision",
+ "before": "1",
+ "after": "2",
+ "before_revision": 1,
+ "after_revision": 2,
+ "criterion_ids": [
+ "SYN-C-DET-RAIN"
+ ],
+ "requirement_ids": [
+ "SYN-REQ-1"
+ ]
+ },
+ {
+ "kind": "RESULT",
+ "ref_id": "SYN-RES-DET-RAIN",
+ "field": "value",
+ "before": "0.80",
+ "after": "0.92",
+ "before_revision": 1,
+ "after_revision": 2,
+ "criterion_ids": [
+ "SYN-C-DET-RAIN"
+ ],
+ "requirement_ids": [
+ "SYN-REQ-1"
+ ]
+ }
+ ]
+ },
+ "basis": {
+ "input_digest": "d7a69ee39700393a9627f6630f35181894e29e3121e2df5435d8efcb8c20f93b",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": []
+ },
+ "selection": {
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "candidates": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 2
+ }
+ ],
+ "chosen": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 2
+ }
+ ],
+ "excluded": []
+ },
+ "conditions": {
+ "required": true,
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "condition": "weather=rain",
+ "unit": "ratio",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required_criteria": []
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T10:00:00Z"
+ },
+ "reasons": {
+ "computed": [],
+ "human": []
+ },
+ "parent_digest": "4f227ec96462d428dba48207a00537c7b9a919c081bd1500ebb015675eba7048",
+ "verdict_digest": "5db922c1155bc3405f7ee661eea5b35cff4090d07f8e0f3fb9f9db76b4784959",
+ "revision_digest": "96c4f8aecc2c7333ecbec5c8e2f677aa4acd7128f745f23dda1ccb3beb38761b",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ },
+ {
+ "subject_kind": "REQUIREMENT",
+ "subject_id": "SYN-REQ-1",
+ "state": "PASS",
+ "cause": {
+ "trigger": "RECOMPUTE",
+ "changed": [
+ {
+ "kind": "RESULT",
+ "ref_id": "SYN-RES-DET-RAIN",
+ "field": "denominator",
+ "before": "20",
+ "after": "25",
+ "before_revision": 1,
+ "after_revision": 2,
+ "criterion_ids": [
+ "SYN-C-DET-RAIN"
+ ],
+ "requirement_ids": [
+ "SYN-REQ-1"
+ ]
+ },
+ {
+ "kind": "RESULT",
+ "ref_id": "SYN-RES-DET-RAIN",
+ "field": "numerator",
+ "before": "16",
+ "after": "23",
+ "before_revision": 1,
+ "after_revision": 2,
+ "criterion_ids": [
+ "SYN-C-DET-RAIN"
+ ],
+ "requirement_ids": [
+ "SYN-REQ-1"
+ ]
+ },
+ {
+ "kind": "RESULT",
+ "ref_id": "SYN-RES-DET-RAIN",
+ "field": "result_revision",
+ "before": "1",
+ "after": "2",
+ "before_revision": 1,
+ "after_revision": 2,
+ "criterion_ids": [
+ "SYN-C-DET-RAIN"
+ ],
+ "requirement_ids": [
+ "SYN-REQ-1"
+ ]
+ },
+ {
+ "kind": "RESULT",
+ "ref_id": "SYN-RES-DET-RAIN",
+ "field": "value",
+ "before": "0.80",
+ "after": "0.92",
+ "before_revision": 1,
+ "after_revision": 2,
+ "criterion_ids": [
+ "SYN-C-DET-RAIN"
+ ],
+ "requirement_ids": [
+ "SYN-REQ-1"
+ ]
+ }
+ ]
+ },
+ "basis": {
+ "input_digest": "65ad41bcb6ea9155e5f594c1640b627d3b818b32ca4e0817cfe091652e00ac68",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": [
+ "5db922c1155bc3405f7ee661eea5b35cff4090d07f8e0f3fb9f9db76b4784959"
+ ]
+ },
+ "selection": null,
+ "conditions": {
+ "required": true,
+ "rule_id": null,
+ "rule_revision": null,
+ "condition": null,
+ "unit": null,
+ "comparator": null,
+ "threshold": null,
+ "rounding": null,
+ "required_criteria": [
+ "SYN-C-DET-RAIN"
+ ]
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T10:00:00Z"
+ },
+ "reasons": {
+ "computed": [],
+ "human": []
+ },
+ "parent_digest": "3da56089801a66bf21cb3f7452ff68b2ea421adb727d56ca23eeef01a8985a39",
+ "verdict_digest": "17858b8d0d482b5e32aab5e6c3d8f537106aa8312c728056bce2964580736f6b",
+ "revision_digest": "ea91d7a140c5e778d7a49c4c9092a5e297e56383394314aa1066c1b3116e77a3",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ }
+ ],
+ "confirmations": [],
+ "pending_changes": []
+ }
+ },
+ {
+ "id": "unit-mismatch",
+ "title": "The result unit differs from the rule unit",
+ "origin": "new",
+ "mode": "plain",
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE",
+ "trace_set": {
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-RAIN",
+ "item_key": "k-SYN-C-DET-RAIN",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-RAIN",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-0",
+ "item_key": "k-SYN-TC-0",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-0",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-RAIN",
+ "item_key": "k-SYN-RES-DET-RAIN",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-TC-0",
+ "relation": "VERIFIED_BY"
+ }
+ ],
+ "rules": [
+ {
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ }
+ ],
+ "results": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "condition": "weather=rain",
+ "value": "80",
+ "raw_value": null,
+ "unit": "percent",
+ "numerator": null,
+ "denominator": null,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ]
+ }
+ ]
+ },
+ "after_trace_set": null,
+ "checks": [
+ {
+ "kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "state": "HOLD_INVALID_RESULT",
+ "reason_codes": [
+ "UNIT_MISMATCH"
+ ],
+ "tags": [
+ "UNIT"
+ ],
+ "next_step": true,
+ "stale": false
+ },
+ {
+ "kind": "REQUIREMENT",
+ "subject_id": "SYN-REQ-1",
+ "state": "HOLD_INCOMPLETE",
+ "reason_codes": [
+ "SYN-C-DET-RAIN"
+ ],
+ "tags": [
+ "REQUIRED_INCOMPLETE"
+ ],
+ "next_step": true,
+ "stale": false
+ }
+ ],
+ "banned_phrases": [
+ "재시험 필수",
+ "원인은",
+ "기준을 낮추",
+ "통과 가능",
+ "승인됨",
+ "종결",
+ "해결",
+ "면제",
+ "AI 추천"
+ ],
+ "view": {
+ "record_digest": null,
+ "set_digest": "6e56347cca37cac214d70850c608b1a57d5168c0e88d3d469e4deb0f9e76d9dd",
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-RAIN",
+ "item_key": "k-SYN-C-DET-RAIN",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-RAIN",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-0",
+ "item_key": "k-SYN-TC-0",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-0",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-RAIN",
+ "item_key": "k-SYN-RES-DET-RAIN",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-TC-0",
+ "relation": "VERIFIED_BY"
+ }
+ ],
+ "rules": [
+ {
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ }
+ ],
+ "results": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "condition": "weather=rain",
+ "value": "80",
+ "raw_value": null,
+ "unit": "percent",
+ "numerator": null,
+ "denominator": null,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ]
+ }
+ ],
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "verdicts": [
+ {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "state": "HOLD_INVALID_RESULT",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "a0abe4cfc34bc619ad54e471893f318b83ceac626e187143e833f0bb946ca110",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": []
+ },
+ "selection": {
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "candidates": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1
+ }
+ ],
+ "chosen": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1
+ }
+ ],
+ "excluded": []
+ },
+ "conditions": {
+ "required": true,
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "condition": "weather=rain",
+ "unit": "ratio",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required_criteria": []
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [
+ "UNIT_MISMATCH:SYN-RES-DET-RAIN:percent!=ratio"
+ ],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "1c77ff458430a0a0060f4e2d7e7d9bec9f16c1d5317a6a449af5b98794ffe6ad",
+ "revision_digest": "d7133c7122d3a8fe7ba913aa25aedc0c6000ade868811a2dcb300ef5d61bda37",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ },
+ {
+ "subject_kind": "REQUIREMENT",
+ "subject_id": "SYN-REQ-1",
+ "state": "HOLD_INCOMPLETE",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "26bfdc39a6e95edef1a3ea2229f6648324bc5bc82cbf036d40158fd39fa4eb54",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": [
+ "1c77ff458430a0a0060f4e2d7e7d9bec9f16c1d5317a6a449af5b98794ffe6ad"
+ ]
+ },
+ "selection": null,
+ "conditions": {
+ "required": true,
+ "rule_id": null,
+ "rule_revision": null,
+ "condition": null,
+ "unit": null,
+ "comparator": null,
+ "threshold": null,
+ "rounding": null,
+ "required_criteria": [
+ "SYN-C-DET-RAIN"
+ ]
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [
+ "SYN-C-DET-RAIN:HOLD_INVALID_RESULT"
+ ],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "cd4e7e87e3fa518f623492f10135b561ab3fcaa9d4bcfc128fb614f4ee342e68",
+ "revision_digest": "cdb46f70191e68a48552b4b115c6390386d9530a08082c0cc04d89e3dd0ef85e",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ }
+ ],
+ "confirmations": [],
+ "pending_changes": []
+ }
+ },
+ {
+ "id": "denominator-zero",
+ "title": "The denominator is zero",
+ "origin": "new",
+ "mode": "plain",
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE",
+ "trace_set": {
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-RAIN",
+ "item_key": "k-SYN-C-DET-RAIN",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-RAIN",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-0",
+ "item_key": "k-SYN-TC-0",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-0",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-RAIN",
+ "item_key": "k-SYN-RES-DET-RAIN",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-TC-0",
+ "relation": "VERIFIED_BY"
+ }
+ ],
+ "rules": [
+ {
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ }
+ ],
+ "results": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "condition": "weather=rain",
+ "value": "0.50",
+ "raw_value": null,
+ "unit": "ratio",
+ "numerator": 5,
+ "denominator": 0,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ]
+ }
+ ]
+ },
+ "after_trace_set": null,
+ "checks": [
+ {
+ "kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "state": "HOLD_INVALID_RESULT",
+ "reason_codes": [
+ "DENOMINATOR_NOT_POSITIVE"
+ ],
+ "tags": [
+ "DENOMINATOR"
+ ],
+ "next_step": true,
+ "stale": false
+ }
+ ],
+ "banned_phrases": [
+ "재시험 필수",
+ "원인은",
+ "기준을 낮추",
+ "통과 가능",
+ "승인됨",
+ "종결",
+ "해결",
+ "면제",
+ "AI 추천"
+ ],
+ "view": {
+ "record_digest": null,
+ "set_digest": "0df214806c295c023d40b7705a3bbea11971af69df094357bd0fff615cd2ad41",
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-RAIN",
+ "item_key": "k-SYN-C-DET-RAIN",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-RAIN",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-0",
+ "item_key": "k-SYN-TC-0",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-0",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-RAIN",
+ "item_key": "k-SYN-RES-DET-RAIN",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-TC-0",
+ "relation": "VERIFIED_BY"
+ }
+ ],
+ "rules": [
+ {
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ }
+ ],
+ "results": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "condition": "weather=rain",
+ "value": "0.50",
+ "raw_value": null,
+ "unit": "ratio",
+ "numerator": 5,
+ "denominator": 0,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ]
+ }
+ ],
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "verdicts": [
+ {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "state": "HOLD_INVALID_RESULT",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "abb102f39c1821a1bd3ce6e9770056ca25726d8931c31b8c9463da0a58984aa2",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": []
+ },
+ "selection": {
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "candidates": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1
+ }
+ ],
+ "chosen": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1
+ }
+ ],
+ "excluded": []
+ },
+ "conditions": {
+ "required": true,
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "condition": "weather=rain",
+ "unit": "ratio",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required_criteria": []
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [
+ "DENOMINATOR_NOT_POSITIVE:SYN-RES-DET-RAIN"
+ ],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "53b80e31667b9d01066d18437e8ede1a98d123fdc57620ec8cfd7132e58e3138",
+ "revision_digest": "0633fbe1fc43531efc743a26fde8b6e2699161e453852fcb34c58cc1fdb5391b",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ },
+ {
+ "subject_kind": "REQUIREMENT",
+ "subject_id": "SYN-REQ-1",
+ "state": "HOLD_INCOMPLETE",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "50315e333e3defc6a6012d07fd95e404cadd4e368eddece41303a229c307718e",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": [
+ "53b80e31667b9d01066d18437e8ede1a98d123fdc57620ec8cfd7132e58e3138"
+ ]
+ },
+ "selection": null,
+ "conditions": {
+ "required": true,
+ "rule_id": null,
+ "rule_revision": null,
+ "condition": null,
+ "unit": null,
+ "comparator": null,
+ "threshold": null,
+ "rounding": null,
+ "required_criteria": [
+ "SYN-C-DET-RAIN"
+ ]
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [
+ "SYN-C-DET-RAIN:HOLD_INVALID_RESULT"
+ ],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "f54afc8037778ba400588918a1bec62da19cb35ff58f86acf2de3981c56ddb22",
+ "revision_digest": "810e8e11091ce0f4c0a7c2c4cf046c19027486d5ffd81ee4008c2b6744e8dc12",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ }
+ ],
+ "confirmations": [],
+ "pending_changes": []
+ }
+ },
+ {
+ "id": "value-disagrees-with-counts",
+ "title": "The value is not numerator over denominator",
+ "origin": "new",
+ "mode": "plain",
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE",
+ "trace_set": {
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-RAIN",
+ "item_key": "k-SYN-C-DET-RAIN",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-RAIN",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-0",
+ "item_key": "k-SYN-TC-0",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-0",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-RAIN",
+ "item_key": "k-SYN-RES-DET-RAIN",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-TC-0",
+ "relation": "VERIFIED_BY"
+ }
+ ],
+ "rules": [
+ {
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ }
+ ],
+ "results": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "condition": "weather=rain",
+ "value": "0.50",
+ "raw_value": null,
+ "unit": "ratio",
+ "numerator": 9,
+ "denominator": 10,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ]
+ }
+ ]
+ },
+ "after_trace_set": null,
+ "checks": [
+ {
+ "kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "state": "HOLD_INVALID_RESULT",
+ "reason_codes": [
+ "VALUE_DISAGREES_WITH_COUNTS"
+ ],
+ "tags": [
+ "DENOMINATOR"
+ ],
+ "next_step": true,
+ "stale": false
+ }
+ ],
+ "banned_phrases": [
+ "재시험 필수",
+ "원인은",
+ "기준을 낮추",
+ "통과 가능",
+ "승인됨",
+ "종결",
+ "해결",
+ "면제",
+ "AI 추천"
+ ],
+ "view": {
+ "record_digest": null,
+ "set_digest": "137b05b3dd5821a928556a988c6475f2f14a12be54e94d8075a546562b58283d",
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-RAIN",
+ "item_key": "k-SYN-C-DET-RAIN",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-RAIN",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-0",
+ "item_key": "k-SYN-TC-0",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-0",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-RAIN",
+ "item_key": "k-SYN-RES-DET-RAIN",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-TC-0",
+ "relation": "VERIFIED_BY"
+ }
+ ],
+ "rules": [
+ {
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ }
+ ],
+ "results": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "condition": "weather=rain",
+ "value": "0.50",
+ "raw_value": null,
+ "unit": "ratio",
+ "numerator": 9,
+ "denominator": 10,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ]
+ }
+ ],
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "verdicts": [
+ {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "state": "HOLD_INVALID_RESULT",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "420eca40d2c0fe1acf9e679caeaf71256a50728efee9b3ef0d545d4f3c10c112",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": []
+ },
+ "selection": {
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "candidates": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1
+ }
+ ],
+ "chosen": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1
+ }
+ ],
+ "excluded": []
+ },
+ "conditions": {
+ "required": true,
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "condition": "weather=rain",
+ "unit": "ratio",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required_criteria": []
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [
+ "VALUE_DISAGREES_WITH_COUNTS:SYN-RES-DET-RAIN"
+ ],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "4d83ec6ff2795ef93d73a7c65aa885be4032ee9148660c58e986e2e7f9dfe270",
+ "revision_digest": "61556a554e7d51bf61e6675849860e2dc8ca52d91e2a582538cb1569f286b958",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ },
+ {
+ "subject_kind": "REQUIREMENT",
+ "subject_id": "SYN-REQ-1",
+ "state": "HOLD_INCOMPLETE",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "4ac1b7bf1c91517f9310006c684986742c29d497d9c6a4c6ae1818c67450517f",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": [
+ "4d83ec6ff2795ef93d73a7c65aa885be4032ee9148660c58e986e2e7f9dfe270"
+ ]
+ },
+ "selection": null,
+ "conditions": {
+ "required": true,
+ "rule_id": null,
+ "rule_revision": null,
+ "condition": null,
+ "unit": null,
+ "comparator": null,
+ "threshold": null,
+ "rounding": null,
+ "required_criteria": [
+ "SYN-C-DET-RAIN"
+ ]
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [
+ "SYN-C-DET-RAIN:HOLD_INVALID_RESULT"
+ ],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "dada16bf0c429116818c41383129aee880ecfce1de975acf999f573f1a4bfb50",
+ "revision_digest": "e07468944ce01ecbc5dcec6f38327f2101ae313c51c2a46845392d93b409b069",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ }
+ ],
+ "confirmations": [],
+ "pending_changes": []
+ }
+ },
+ {
+ "id": "value-not-a-number",
+ "title": "The value was written as text",
+ "origin": "new",
+ "mode": "plain",
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE",
+ "trace_set": {
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-RAIN",
+ "item_key": "k-SYN-C-DET-RAIN",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-RAIN",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-0",
+ "item_key": "k-SYN-TC-0",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-0",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-RAIN",
+ "item_key": "k-SYN-RES-DET-RAIN",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-TC-0",
+ "relation": "VERIFIED_BY"
+ }
+ ],
+ "rules": [
+ {
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ }
+ ],
+ "results": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "condition": "weather=rain",
+ "value": null,
+ "raw_value": "n/a",
+ "unit": "ratio",
+ "numerator": null,
+ "denominator": null,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ]
+ }
+ ]
+ },
+ "after_trace_set": null,
+ "checks": [
+ {
+ "kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "state": "HOLD_INVALID_RESULT",
+ "reason_codes": [
+ "VALUE_NOT_A_NUMBER"
+ ],
+ "tags": [
+ "VALUE"
+ ],
+ "next_step": true,
+ "stale": false
+ }
+ ],
+ "banned_phrases": [
+ "재시험 필수",
+ "원인은",
+ "기준을 낮추",
+ "통과 가능",
+ "승인됨",
+ "종결",
+ "해결",
+ "면제",
+ "AI 추천"
+ ],
+ "view": {
+ "record_digest": null,
+ "set_digest": "3b705a103a04b00cc34953cfca4a88afb3b27ddb7a078e1ae931a69253f8d290",
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-RAIN",
+ "item_key": "k-SYN-C-DET-RAIN",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-RAIN",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-0",
+ "item_key": "k-SYN-TC-0",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-0",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-RAIN",
+ "item_key": "k-SYN-RES-DET-RAIN",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-TC-0",
+ "relation": "VERIFIED_BY"
+ }
+ ],
+ "rules": [
+ {
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ }
+ ],
+ "results": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "condition": "weather=rain",
+ "value": null,
+ "raw_value": "n/a",
+ "unit": "ratio",
+ "numerator": null,
+ "denominator": null,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ]
+ }
+ ],
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "verdicts": [
+ {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "state": "HOLD_INVALID_RESULT",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "ce99ae5bb212826001cb161d86543c5885131cdc73a1cf79eb8169953202f131",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": []
+ },
+ "selection": {
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "candidates": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1
+ }
+ ],
+ "chosen": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1
+ }
+ ],
+ "excluded": []
+ },
+ "conditions": {
+ "required": true,
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "condition": "weather=rain",
+ "unit": "ratio",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required_criteria": []
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [
+ "VALUE_NOT_A_NUMBER:SYN-RES-DET-RAIN"
+ ],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "91a9d792a558ef668e18ecf20028e1a880d44d9b9774ce26ca391d3ce2f0ef97",
+ "revision_digest": "2fdbcaf62ede8740ff946345971fce302cb4a68515ee4c6ddc111ebfadfeebec",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ },
+ {
+ "subject_kind": "REQUIREMENT",
+ "subject_id": "SYN-REQ-1",
+ "state": "HOLD_INCOMPLETE",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "7f1e869828043014d870fdd63a02426186aa70830ef73eca9cd7198eafcc69b3",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": [
+ "91a9d792a558ef668e18ecf20028e1a880d44d9b9774ce26ca391d3ce2f0ef97"
+ ]
+ },
+ "selection": null,
+ "conditions": {
+ "required": true,
+ "rule_id": null,
+ "rule_revision": null,
+ "condition": null,
+ "unit": null,
+ "comparator": null,
+ "threshold": null,
+ "rounding": null,
+ "required_criteria": [
+ "SYN-C-DET-RAIN"
+ ]
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [
+ "SYN-C-DET-RAIN:HOLD_INVALID_RESULT"
+ ],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "b0968899d8192537a2131f2a0637d2ebb8510bf4583b5241b9f23db847090cbf",
+ "revision_digest": "d0e32cc0a3edbedd2456e5a21863b55dd85217eb773cacca637e8524b00c7825",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ }
+ ],
+ "confirmations": [],
+ "pending_changes": []
+ }
+ },
+ {
+ "id": "condition-mismatch",
+ "title": "A result exists but for another condition",
+ "origin": "new",
+ "mode": "plain",
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE",
+ "trace_set": {
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-RAIN",
+ "item_key": "k-SYN-C-DET-RAIN",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-RAIN",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-0",
+ "item_key": "k-SYN-TC-0",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-0",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-RAIN",
+ "item_key": "k-SYN-RES-DET-RAIN",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-TC-0",
+ "relation": "VERIFIED_BY"
+ }
+ ],
+ "rules": [
+ {
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ }
+ ],
+ "results": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "condition": "weather=fog",
+ "value": "0.95",
+ "raw_value": null,
+ "unit": "ratio",
+ "numerator": null,
+ "denominator": null,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ]
+ }
+ ]
+ },
+ "after_trace_set": null,
+ "checks": [
+ {
+ "kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "state": "HOLD_NO_RESULT",
+ "reason_codes": [
+ "NO_RESULT"
+ ],
+ "tags": [
+ "CONDITION_MISMATCH"
+ ],
+ "next_step": true,
+ "stale": false
+ },
+ {
+ "kind": "REQUIREMENT",
+ "subject_id": "SYN-REQ-1",
+ "state": "HOLD_INCOMPLETE",
+ "reason_codes": [
+ "SYN-C-DET-RAIN"
+ ],
+ "tags": [
+ "REQUIRED_INCOMPLETE"
+ ],
+ "next_step": true,
+ "stale": false
+ }
+ ],
+ "banned_phrases": [
+ "재시험 필수",
+ "원인은",
+ "기준을 낮추",
+ "통과 가능",
+ "승인됨",
+ "종결",
+ "해결",
+ "면제",
+ "AI 추천"
+ ],
+ "view": {
+ "record_digest": null,
+ "set_digest": "5c053e513cb1e271d0261412228e73d072582bdc206fd6d05970639a20ead79c",
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-RAIN",
+ "item_key": "k-SYN-C-DET-RAIN",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-RAIN",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-0",
+ "item_key": "k-SYN-TC-0",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-0",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-RAIN",
+ "item_key": "k-SYN-RES-DET-RAIN",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-TC-0",
+ "relation": "VERIFIED_BY"
+ }
+ ],
+ "rules": [
+ {
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ }
+ ],
+ "results": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "condition": "weather=fog",
+ "value": "0.95",
+ "raw_value": null,
+ "unit": "ratio",
+ "numerator": null,
+ "denominator": null,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ]
+ }
+ ],
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "verdicts": [
+ {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "state": "HOLD_NO_RESULT",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "d6eb57d77d477789a4e84e64282dcbcbef6d0f3707e296c0d0ad85571bc9b3e3",
+ "source_span_refs": [],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": []
+ },
+ "selection": {
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "candidates": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1
+ }
+ ],
+ "chosen": [],
+ "excluded": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1,
+ "reason": "CONDITION_MISMATCH"
+ }
+ ]
+ },
+ "conditions": {
+ "required": true,
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "condition": "weather=rain",
+ "unit": "ratio",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required_criteria": []
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [
+ "NO_RESULT:weather=rain"
+ ],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "b07be21ffec3771e28574a253d58d860385a9ac74a94d5f3637ff202f244ba2f",
+ "revision_digest": "3c055800a16eab9838aa9aa819b3fa1935f857d4f93395c9bb42b98368a5f2f3",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ },
+ {
+ "subject_kind": "REQUIREMENT",
+ "subject_id": "SYN-REQ-1",
+ "state": "HOLD_INCOMPLETE",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "952cbf737f3ded44b7cae93e1479ab1c86c8d742d0dbc8cb70efa1b952082c30",
+ "source_span_refs": [],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": [
+ "b07be21ffec3771e28574a253d58d860385a9ac74a94d5f3637ff202f244ba2f"
+ ]
+ },
+ "selection": null,
+ "conditions": {
+ "required": true,
+ "rule_id": null,
+ "rule_revision": null,
+ "condition": null,
+ "unit": null,
+ "comparator": null,
+ "threshold": null,
+ "rounding": null,
+ "required_criteria": [
+ "SYN-C-DET-RAIN"
+ ]
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [
+ "SYN-C-DET-RAIN:HOLD_NO_RESULT"
+ ],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "210ac70710dad553f4e560a0cd545883bdaac02bef4cd8fc1cea4fcf52ce6ad4",
+ "revision_digest": "4b26337d966a2b155de492779c2bfd48af09932b56d55edbf09d48d07fb7c7e5",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ }
+ ],
+ "confirmations": [],
+ "pending_changes": []
+ }
+ },
+ {
+ "id": "no-rule",
+ "title": "A criterion has no rule",
+ "origin": "new",
+ "mode": "plain",
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE",
+ "trace_set": {
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-RAIN",
+ "item_key": "k-SYN-C-DET-RAIN",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-RAIN",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-0",
+ "item_key": "k-SYN-TC-0",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-0",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-TC-0",
+ "relation": "VERIFIED_BY"
+ }
+ ],
+ "rules": [],
+ "results": []
+ },
+ "after_trace_set": null,
+ "checks": [
+ {
+ "kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "state": "HOLD_NO_RULE",
+ "reason_codes": [
+ "NO_RULE"
+ ],
+ "tags": [
+ "NO_RULE"
+ ],
+ "next_step": true,
+ "stale": false
+ },
+ {
+ "kind": "REQUIREMENT",
+ "subject_id": "SYN-REQ-1",
+ "state": "HOLD_INCOMPLETE",
+ "reason_codes": [
+ "SYN-C-DET-RAIN"
+ ],
+ "tags": [
+ "REQUIRED_INCOMPLETE"
+ ],
+ "next_step": true,
+ "stale": false
+ }
+ ],
+ "banned_phrases": [
+ "재시험 필수",
+ "원인은",
+ "기준을 낮추",
+ "통과 가능",
+ "승인됨",
+ "종결",
+ "해결",
+ "면제",
+ "AI 추천"
+ ],
+ "view": {
+ "record_digest": null,
+ "set_digest": "f8a24b258e514923f7ac950ab132aeda25b4b7512a9b38a553a4418d5b6af362",
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-RAIN",
+ "item_key": "k-SYN-C-DET-RAIN",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-RAIN",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-0",
+ "item_key": "k-SYN-TC-0",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-0",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-TC-0",
+ "relation": "VERIFIED_BY"
+ }
+ ],
+ "rules": [],
+ "results": [],
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "verdicts": [
+ {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "state": "HOLD_NO_RULE",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "596f01ecb31eefd937c823fe4d9b01cff137c6ac3b8bf4547b7ffcc698d9aec9",
+ "source_span_refs": [],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": []
+ },
+ "selection": null,
+ "conditions": {
+ "required": true,
+ "rule_id": null,
+ "rule_revision": null,
+ "condition": null,
+ "unit": null,
+ "comparator": null,
+ "threshold": null,
+ "rounding": null,
+ "required_criteria": []
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [
+ "NO_RULE"
+ ],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "33a75140937999b2961a74835518a343bead8f8996fa84b8101037d8da54b97a",
+ "revision_digest": "29f9e06190ac0bb710d7d126c75ca2194fe870ef8500e4b6763930f0abac8392",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ },
+ {
+ "subject_kind": "REQUIREMENT",
+ "subject_id": "SYN-REQ-1",
+ "state": "HOLD_INCOMPLETE",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "e76cc72ce1b03ad3832dcf17e0ff1b80c6fe9db166dc96289fc32c5435d70062",
+ "source_span_refs": [],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": [
+ "33a75140937999b2961a74835518a343bead8f8996fa84b8101037d8da54b97a"
+ ]
+ },
+ "selection": null,
+ "conditions": {
+ "required": true,
+ "rule_id": null,
+ "rule_revision": null,
+ "condition": null,
+ "unit": null,
+ "comparator": null,
+ "threshold": null,
+ "rounding": null,
+ "required_criteria": [
+ "SYN-C-DET-RAIN"
+ ]
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [
+ "SYN-C-DET-RAIN:HOLD_NO_RULE"
+ ],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "f801abbb066668b249eac5cefbf80762d5c829d160c6d96550211931f5d08eb4",
+ "revision_digest": "a899ef3e6c3719d1b1c7a659cbe5f024a389c82d48bc7cd347ffeb1705d17603",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ }
+ ],
+ "confirmations": [],
+ "pending_changes": []
+ }
+ },
+ {
+ "id": "no-test-linked",
+ "title": "A criterion has a rule but no test is linked",
+ "origin": "new",
+ "mode": "plain",
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE",
+ "trace_set": {
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-RAIN",
+ "item_key": "k-SYN-C-DET-RAIN",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-RAIN",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ }
+ ],
+ "rules": [
+ {
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ }
+ ],
+ "results": []
+ },
+ "after_trace_set": null,
+ "checks": [
+ {
+ "kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "state": "HOLD_NO_RESULT",
+ "reason_codes": [
+ "NO_RESULT"
+ ],
+ "tags": [
+ "UNLINKED"
+ ],
+ "next_step": true,
+ "stale": false
+ }
+ ],
+ "banned_phrases": [
+ "재시험 필수",
+ "원인은",
+ "기준을 낮추",
+ "통과 가능",
+ "승인됨",
+ "종결",
+ "해결",
+ "면제",
+ "AI 추천"
+ ],
+ "view": {
+ "record_digest": null,
+ "set_digest": "2801b03ccee16f1bd5c6ece502ea70fc9eb20703670e4adb11b122d6909d8eb4",
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-RAIN",
+ "item_key": "k-SYN-C-DET-RAIN",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-RAIN",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ }
+ ],
+ "rules": [
+ {
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ }
+ ],
+ "results": [],
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "verdicts": [
+ {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "state": "HOLD_NO_RESULT",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "08eff1bb3f9e3321ce7d93ab37ebd9847bfbd881ddd83776e0e530fe017a43f3",
+ "source_span_refs": [],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": []
+ },
+ "selection": {
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "candidates": [],
+ "chosen": [],
+ "excluded": []
+ },
+ "conditions": {
+ "required": true,
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "condition": "weather=rain",
+ "unit": "ratio",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required_criteria": []
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [
+ "NO_RESULT:weather=rain"
+ ],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0",
+ "revision_digest": "3127f32a6c14fc1a551145f59b586e7da07ae653fc2550ec77b440261485a91d",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ },
+ {
+ "subject_kind": "REQUIREMENT",
+ "subject_id": "SYN-REQ-1",
+ "state": "HOLD_INCOMPLETE",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "0fbedd77cef44ff20c9b60769ee0cebb5e9dd04db5b517826f8ecdfc81755f6a",
+ "source_span_refs": [],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": [
+ "aab511e55de22485bc0b4a4fd1803120d0a1c100ce5dbbeb98a41d9c2c738da0"
+ ]
+ },
+ "selection": null,
+ "conditions": {
+ "required": true,
+ "rule_id": null,
+ "rule_revision": null,
+ "condition": null,
+ "unit": null,
+ "comparator": null,
+ "threshold": null,
+ "rounding": null,
+ "required_criteria": [
+ "SYN-C-DET-RAIN"
+ ]
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [
+ "SYN-C-DET-RAIN:HOLD_NO_RESULT"
+ ],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "c88d981152ef43aef2a3214afc9d5cc47e84a576846d70ab18b989cc07e95d79",
+ "revision_digest": "2b365edfc1c6079e205a04f79396226a746b019e288dd3fb5297ab87423ba2ee",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ }
+ ],
+ "confirmations": [],
+ "pending_changes": []
+ }
+ },
+ {
+ "id": "two-conditions-miss",
+ "title": "Two conditions miss at the same time",
+ "origin": "new",
+ "mode": "plain",
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE",
+ "trace_set": {
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-DRY",
+ "item_key": "k-SYN-C-DET-DRY",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-DRY",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-0",
+ "item_key": "k-SYN-TC-0",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-0",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-RAIN",
+ "item_key": "k-SYN-C-DET-RAIN",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-RAIN",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-1",
+ "item_key": "k-SYN-TC-1",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-1",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-FOG",
+ "item_key": "k-SYN-C-DET-FOG",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-FOG",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-2",
+ "item_key": "k-SYN-TC-2",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-2",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-DRY",
+ "item_key": "k-SYN-RES-DET-DRY",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-RAIN",
+ "item_key": "k-SYN-RES-DET-RAIN",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-FOG",
+ "item_key": "k-SYN-RES-DET-FOG",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-DRY",
+ "from_id": "SYN-C-DET-DRY",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-DRY",
+ "from_id": "SYN-C-DET-DRY",
+ "to_id": "SYN-TC-0",
+ "relation": "VERIFIED_BY"
+ },
+ {
+ "link_id": "L-ref-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-TC-1",
+ "relation": "VERIFIED_BY"
+ },
+ {
+ "link_id": "L-ref-SYN-C-DET-FOG",
+ "from_id": "SYN-C-DET-FOG",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-FOG",
+ "from_id": "SYN-C-DET-FOG",
+ "to_id": "SYN-TC-2",
+ "relation": "VERIFIED_BY"
+ }
+ ],
+ "rules": [
+ {
+ "rule_id": "R-SYN-C-DET-DRY",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-DRY",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=dry",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ },
+ {
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ },
+ {
+ "rule_id": "R-SYN-C-DET-FOG",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-FOG",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=fog",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ }
+ ],
+ "results": [
+ {
+ "result_id": "SYN-RES-DET-DRY",
+ "result_revision": 1,
+ "criterion_id": "SYN-C-DET-DRY",
+ "condition": "weather=dry",
+ "value": "0.95",
+ "raw_value": null,
+ "unit": "ratio",
+ "numerator": 19,
+ "denominator": 20,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-DRY"
+ ]
+ },
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "condition": "weather=rain",
+ "value": "0.80",
+ "raw_value": null,
+ "unit": "ratio",
+ "numerator": 16,
+ "denominator": 20,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ]
+ },
+ {
+ "result_id": "SYN-RES-DET-FOG",
+ "result_revision": 1,
+ "criterion_id": "SYN-C-DET-FOG",
+ "condition": "weather=fog",
+ "value": "0.70",
+ "raw_value": null,
+ "unit": "ratio",
+ "numerator": 14,
+ "denominator": 20,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-FOG"
+ ]
+ }
+ ]
+ },
+ "after_trace_set": null,
+ "checks": [
+ {
+ "kind": "CRITERION",
+ "subject_id": "SYN-C-DET-DRY",
+ "state": "PASS_COMPUTED",
+ "reason_codes": [],
+ "tags": [],
+ "next_step": false,
+ "stale": false
+ },
+ {
+ "kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "state": "FAIL_COMPUTED",
+ "reason_codes": [
+ "THRESHOLD_NOT_MET"
+ ],
+ "tags": [
+ "VALID_MISS"
+ ],
+ "next_step": true,
+ "stale": false
+ },
+ {
+ "kind": "CRITERION",
+ "subject_id": "SYN-C-DET-FOG",
+ "state": "FAIL_COMPUTED",
+ "reason_codes": [
+ "THRESHOLD_NOT_MET"
+ ],
+ "tags": [
+ "VALID_MISS"
+ ],
+ "next_step": true,
+ "stale": false
+ },
+ {
+ "kind": "REQUIREMENT",
+ "subject_id": "SYN-REQ-1",
+ "state": "FAIL",
+ "reason_codes": [
+ "SYN-C-DET-RAIN",
+ "SYN-C-DET-FOG"
+ ],
+ "tags": [],
+ "next_step": true,
+ "stale": false
+ }
+ ],
+ "banned_phrases": [
+ "재시험 필수",
+ "원인은",
+ "기준을 낮추",
+ "통과 가능",
+ "승인됨",
+ "종결",
+ "해결",
+ "면제",
+ "AI 추천"
+ ],
+ "view": {
+ "record_digest": null,
+ "set_digest": "62dec0f492ca3d2f7a660030266fe1ca1b3259b2412dfefcfceed18c4d34b13f",
+ "items": [
+ {
+ "item_id": "SYN-REQ-1",
+ "item_key": "k-SYN-REQ-1",
+ "kind": "REQUIREMENT",
+ "title": "Synthetic requirement (invented example)",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-DRY",
+ "item_key": "k-SYN-C-DET-DRY",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-DRY",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-0",
+ "item_key": "k-SYN-TC-0",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-0",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-RAIN",
+ "item_key": "k-SYN-C-DET-RAIN",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-RAIN",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-1",
+ "item_key": "k-SYN-TC-1",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-1",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-C-DET-FOG",
+ "item_key": "k-SYN-C-DET-FOG",
+ "kind": "CRITERION",
+ "title": "Criterion SYN-C-DET-FOG",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-TC-2",
+ "item_key": "k-SYN-TC-2",
+ "kind": "TEST_CASE",
+ "title": "Test SYN-TC-2",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-DRY",
+ "item_key": "k-SYN-RES-DET-DRY",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-RAIN",
+ "item_key": "k-SYN-RES-DET-RAIN",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ },
+ {
+ "item_id": "SYN-RES-DET-FOG",
+ "item_key": "k-SYN-RES-DET-FOG",
+ "kind": "RESULT",
+ "title": "",
+ "fields": {
+ "synthetic_notice": "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+ },
+ "source_span_refs": []
+ }
+ ],
+ "links": [
+ {
+ "link_id": "L-ref-SYN-C-DET-DRY",
+ "from_id": "SYN-C-DET-DRY",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-DRY",
+ "from_id": "SYN-C-DET-DRY",
+ "to_id": "SYN-TC-0",
+ "relation": "VERIFIED_BY"
+ },
+ {
+ "link_id": "L-ref-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-RAIN",
+ "from_id": "SYN-C-DET-RAIN",
+ "to_id": "SYN-TC-1",
+ "relation": "VERIFIED_BY"
+ },
+ {
+ "link_id": "L-ref-SYN-C-DET-FOG",
+ "from_id": "SYN-C-DET-FOG",
+ "to_id": "SYN-REQ-1",
+ "relation": "REFINES"
+ },
+ {
+ "link_id": "L-ver-SYN-C-DET-FOG",
+ "from_id": "SYN-C-DET-FOG",
+ "to_id": "SYN-TC-2",
+ "relation": "VERIFIED_BY"
+ }
+ ],
+ "rules": [
+ {
+ "rule_id": "R-SYN-C-DET-DRY",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-DRY",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=dry",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ },
+ {
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ },
+ {
+ "rule_id": "R-SYN-C-DET-FOG",
+ "rule_revision": 1,
+ "criterion_id": "SYN-C-DET-FOG",
+ "measure": "detection_rate",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "unit": "ratio",
+ "condition": "weather=fog",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required": true
+ }
+ ],
+ "results": [
+ {
+ "result_id": "SYN-RES-DET-DRY",
+ "result_revision": 1,
+ "criterion_id": "SYN-C-DET-DRY",
+ "condition": "weather=dry",
+ "value": "0.95",
+ "raw_value": null,
+ "unit": "ratio",
+ "numerator": 19,
+ "denominator": 20,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-DRY"
+ ]
+ },
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1,
+ "criterion_id": "SYN-C-DET-RAIN",
+ "condition": "weather=rain",
+ "value": "0.80",
+ "raw_value": null,
+ "unit": "ratio",
+ "numerator": 16,
+ "denominator": 20,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ]
+ },
+ {
+ "result_id": "SYN-RES-DET-FOG",
+ "result_revision": 1,
+ "criterion_id": "SYN-C-DET-FOG",
+ "condition": "weather=fog",
+ "value": "0.70",
+ "raw_value": null,
+ "unit": "ratio",
+ "numerator": 14,
+ "denominator": 20,
+ "observed_at": "2026-10-05T12:00:00Z",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-FOG"
+ ]
+ }
+ ],
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "verdicts": [
+ {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-DRY",
+ "state": "PASS_COMPUTED",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "068194228704bec79641eb9f34d5e7cff023942d263c2a5e47db8cf88da24118",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-DRY"
+ ],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": []
+ },
+ "selection": {
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "candidates": [
+ {
+ "result_id": "SYN-RES-DET-DRY",
+ "result_revision": 1
+ }
+ ],
+ "chosen": [
+ {
+ "result_id": "SYN-RES-DET-DRY",
+ "result_revision": 1
+ }
+ ],
+ "excluded": []
+ },
+ "conditions": {
+ "required": true,
+ "rule_id": "R-SYN-C-DET-DRY",
+ "rule_revision": 1,
+ "condition": "weather=dry",
+ "unit": "ratio",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required_criteria": []
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "a132542a80e53090e753aa039f6eb695f5ac4aff4f9bd3e77de8ba63146077ec",
+ "revision_digest": "5e2ae89df0d68e1ca093fdd633379a12abe2a5646b1665fef297c453ec53b1ff",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ },
+ {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-FOG",
+ "state": "FAIL_COMPUTED",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "bf4a92fd9b268077745e477656da6f57817c72b40ee9c374038773ad447cadf4",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-FOG"
+ ],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": []
+ },
+ "selection": {
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "candidates": [
+ {
+ "result_id": "SYN-RES-DET-FOG",
+ "result_revision": 1
+ }
+ ],
+ "chosen": [
+ {
+ "result_id": "SYN-RES-DET-FOG",
+ "result_revision": 1
+ }
+ ],
+ "excluded": []
+ },
+ "conditions": {
+ "required": true,
+ "rule_id": "R-SYN-C-DET-FOG",
+ "rule_revision": 1,
+ "condition": "weather=fog",
+ "unit": "ratio",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required_criteria": []
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [
+ "THRESHOLD_NOT_MET:SYN-RES-DET-FOG:value=0.70 need >= 0.90 ratio"
+ ],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "d724437ee1ef6dd350bc49ce8e11292b1c9404f4ddf96ce8c74fe88d70c623bf",
+ "revision_digest": "16b8a47904ad3a62b8c4d9a2329b471dcfbf9f1de8638ce7a75eb96afdbd6fa7",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ },
+ {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "state": "FAIL_COMPUTED",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "fb04ca2e941377a6b6afc255ae652b018e54f00881839090870fe44dff8118b1",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": []
+ },
+ "selection": {
+ "policy": {
+ "name": "LATEST_PER_CONDITION",
+ "version": 1
+ },
+ "candidates": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1
+ }
+ ],
+ "chosen": [
+ {
+ "result_id": "SYN-RES-DET-RAIN",
+ "result_revision": 1
+ }
+ ],
+ "excluded": []
+ },
+ "conditions": {
+ "required": true,
+ "rule_id": "R-SYN-C-DET-RAIN",
+ "rule_revision": 1,
+ "condition": "weather=rain",
+ "unit": "ratio",
+ "comparator": ">=",
+ "threshold": "0.90",
+ "rounding": {
+ "digits": 2,
+ "mode": "HALF_UP"
+ },
+ "required_criteria": []
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [
+ "THRESHOLD_NOT_MET:SYN-RES-DET-RAIN:value=0.80 need >= 0.90 ratio"
+ ],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "9b3c31762a13829734688a7b9d77131dcad6f2e38a3e2d5719f41cf7323d3923",
+ "revision_digest": "4f227ec96462d428dba48207a00537c7b9a919c081bd1500ebb015675eba7048",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ },
+ {
+ "subject_kind": "REQUIREMENT",
+ "subject_id": "SYN-REQ-1",
+ "state": "FAIL",
+ "cause": {
+ "trigger": "INITIAL_COMPUTE",
+ "changed": []
+ },
+ "basis": {
+ "input_digest": "2f3c8302fd7ff7e64025766b10f32a6373e3b9924e98fb25f11d946fd461c722",
+ "source_span_refs": [
+ "SYN-SOURCE.yaml#DET-DRY",
+ "SYN-SOURCE.yaml#DET-FOG",
+ "SYN-SOURCE.yaml#DET-RAIN"
+ ],
+ "evidence_text_preserved": false,
+ "child_verdict_digests": [
+ "a132542a80e53090e753aa039f6eb695f5ac4aff4f9bd3e77de8ba63146077ec",
+ "d724437ee1ef6dd350bc49ce8e11292b1c9404f4ddf96ce8c74fe88d70c623bf",
+ "9b3c31762a13829734688a7b9d77131dcad6f2e38a3e2d5719f41cf7323d3923"
+ ]
+ },
+ "selection": null,
+ "conditions": {
+ "required": true,
+ "rule_id": null,
+ "rule_revision": null,
+ "condition": null,
+ "unit": null,
+ "comparator": null,
+ "threshold": null,
+ "rounding": null,
+ "required_criteria": [
+ "SYN-C-DET-DRY",
+ "SYN-C-DET-FOG",
+ "SYN-C-DET-RAIN"
+ ]
+ },
+ "actor": {
+ "actor_id": "system:verification-trace",
+ "computed_at": "2026-10-06T09:00:00Z"
+ },
+ "reasons": {
+ "computed": [
+ "SYN-C-DET-FOG:FAIL_COMPUTED",
+ "SYN-C-DET-RAIN:FAIL_COMPUTED"
+ ],
+ "human": []
+ },
+ "parent_digest": null,
+ "verdict_digest": "b31f54009ab8bb0251f17233864a0e1ba370aa3740273fbec1bae4a4ae7f44ba",
+ "revision_digest": "753a61c34a13e21456962c2be15c24e7b102bc28f1a27c12da2005079cab5a7c",
+ "currentness": {
+ "state": "CURRENT",
+ "changed_dependencies": []
+ },
+ "confirmations": [],
+ "recent_history": [],
+ "history_total": 1
+ }
+ ],
+ "confirmations": [],
+ "pending_changes": []
+ }
+ }
+ ]
+}
diff --git a/tests/fixtures/hypothesis_eval/lesson_cases.json b/tests/fixtures/hypothesis_eval/lesson_cases.json
new file mode 100644
index 0000000..02513c0
--- /dev/null
+++ b/tests/fixtures/hypothesis_eval/lesson_cases.json
@@ -0,0 +1,732 @@
+{
+ "schema_version": "hypothesis-lesson-cases/1",
+ "notice": "SYNTHETIC CASES - NO MODEL IS CALLED - THE EXPECTED STATE IS WHAT THE RULE GIVES",
+ "cases": [
+ {
+ "id": "lesson-same-condition",
+ "about": "A recorded result in the same criterion, unit, condition and rule revision is recalled.",
+ "current": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "refs": [
+ {
+ "lesson_id": "a",
+ "kind": "TEST_RESULT",
+ "outcome": "ALTERNATIVE",
+ "hypothesis_id": "h1",
+ "test_id": "t1",
+ "context": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "fingerprint": "f-a"
+ }
+ ],
+ "fingerprints": {
+ "a": "f-a"
+ },
+ "expected": {
+ "states": {
+ "a": "SAME_CONDITION"
+ },
+ "absent": []
+ }
+ },
+ {
+ "id": "lesson-other-condition",
+ "about": "A lesson that differs in the criterion, the unit, the condition or the rule revision is not recalled at all.",
+ "current": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "refs": [
+ {
+ "lesson_id": "other-criterion",
+ "kind": "TEST_RESULT",
+ "outcome": "ALTERNATIVE",
+ "hypothesis_id": "h1",
+ "test_id": "t1",
+ "context": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-FOG",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "fingerprint": "f-other-criterion"
+ },
+ {
+ "lesson_id": "other-unit",
+ "kind": "TEST_RESULT",
+ "outcome": "ALTERNATIVE",
+ "hypothesis_id": "h1",
+ "test_id": "t1",
+ "context": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "percent",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "fingerprint": "f-other-unit"
+ },
+ {
+ "lesson_id": "other-condition",
+ "kind": "TEST_RESULT",
+ "outcome": "ALTERNATIVE",
+ "hypothesis_id": "h1",
+ "test_id": "t1",
+ "context": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=fog",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "fingerprint": "f-other-condition"
+ },
+ {
+ "lesson_id": "other-rule",
+ "kind": "TEST_RESULT",
+ "outcome": "ALTERNATIVE",
+ "hypothesis_id": "h1",
+ "test_id": "t1",
+ "context": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 2
+ },
+ "fingerprint": "f-other-rule"
+ },
+ {
+ "lesson_id": "same",
+ "kind": "TEST_RESULT",
+ "outcome": "THIS_HYPOTHESIS",
+ "hypothesis_id": "h1",
+ "test_id": "t1",
+ "context": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "fingerprint": "f-same"
+ }
+ ],
+ "fingerprints": {
+ "other-criterion": "f-other-criterion",
+ "other-unit": "f-other-unit",
+ "other-condition": "f-other-condition",
+ "other-rule": "f-other-rule",
+ "same": "f-same"
+ },
+ "expected": {
+ "states": {
+ "same": "SAME_CONDITION"
+ },
+ "absent": [
+ "other-criterion",
+ "other-unit",
+ "other-condition",
+ "other-rule"
+ ]
+ }
+ },
+ {
+ "id": "lesson-stale-fingerprint",
+ "about": "When the record the lesson points at has changed or is gone, the lesson is stale and left out of the recall (the record stays).",
+ "current": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "refs": [
+ {
+ "lesson_id": "changed",
+ "kind": "TEST_RESULT",
+ "outcome": "ALTERNATIVE",
+ "hypothesis_id": "h1",
+ "test_id": "t1",
+ "context": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "fingerprint": "f-changed"
+ },
+ {
+ "lesson_id": "gone",
+ "kind": "EFFECT_CONFIRMED",
+ "outcome": "CONFIRMED",
+ "hypothesis_id": null,
+ "test_id": null,
+ "context": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "fingerprint": "f-gone"
+ },
+ {
+ "lesson_id": "fine",
+ "kind": "HUMAN_CLOSURE",
+ "outcome": "WAIVER_RECORDED",
+ "hypothesis_id": null,
+ "test_id": null,
+ "context": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "fingerprint": "f-fine"
+ }
+ ],
+ "fingerprints": {
+ "changed": "f-something-else",
+ "gone": null,
+ "fine": "f-fine"
+ },
+ "expected": {
+ "states": {
+ "changed": "STALE",
+ "gone": "STALE",
+ "fine": "SAME_CONDITION"
+ },
+ "absent": []
+ }
+ },
+ {
+ "id": "lesson-refuted",
+ "about": "A later result in the same condition that says the opposite about the same hypothesis refutes the earlier lesson.",
+ "current": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "refs": [
+ {
+ "lesson_id": "early",
+ "kind": "TEST_RESULT",
+ "outcome": "ALTERNATIVE",
+ "hypothesis_id": "h1",
+ "test_id": "t1",
+ "context": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "fingerprint": "f-early"
+ },
+ {
+ "lesson_id": "late",
+ "kind": "TEST_RESULT",
+ "outcome": "THIS_HYPOTHESIS",
+ "hypothesis_id": "h1",
+ "test_id": "t2",
+ "context": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "fingerprint": "f-late"
+ }
+ ],
+ "fingerprints": {
+ "early": "f-early",
+ "late": "f-late"
+ },
+ "expected": {
+ "states": {
+ "early": "REFUTED",
+ "late": "SAME_CONDITION"
+ },
+ "absent": []
+ }
+ },
+ {
+ "id": "lesson-refuted-both-ways",
+ "about": "The opposite also holds: a later result that eliminates refutes an earlier one that supported.",
+ "current": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "refs": [
+ {
+ "lesson_id": "early",
+ "kind": "TEST_RESULT",
+ "outcome": "THIS_HYPOTHESIS",
+ "hypothesis_id": "h1",
+ "test_id": "t1",
+ "context": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "fingerprint": "f-early"
+ },
+ {
+ "lesson_id": "late",
+ "kind": "TEST_RESULT",
+ "outcome": "ALTERNATIVE",
+ "hypothesis_id": "h1",
+ "test_id": "t2",
+ "context": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "fingerprint": "f-late"
+ }
+ ],
+ "fingerprints": {
+ "early": "f-early",
+ "late": "f-late"
+ },
+ "expected": {
+ "states": {
+ "early": "REFUTED",
+ "late": "SAME_CONDITION"
+ },
+ "absent": []
+ }
+ },
+ {
+ "id": "lesson-elimination-refuted",
+ "about": "An elimination is refuted by a later result in the same condition that fits the hypothesis.",
+ "current": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "refs": [
+ {
+ "lesson_id": "elim",
+ "kind": "ELIMINATION",
+ "outcome": "REPEATED",
+ "hypothesis_id": "h1",
+ "test_id": null,
+ "context": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "fingerprint": "f-elim"
+ },
+ {
+ "lesson_id": "support",
+ "kind": "TEST_RESULT",
+ "outcome": "THIS_HYPOTHESIS",
+ "hypothesis_id": "h1",
+ "test_id": "t3",
+ "context": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "fingerprint": "f-support"
+ }
+ ],
+ "fingerprints": {
+ "elim": "f-elim",
+ "support": "f-support"
+ },
+ "expected": {
+ "states": {
+ "elim": "REFUTED",
+ "support": "SAME_CONDITION"
+ },
+ "absent": []
+ }
+ },
+ {
+ "id": "lesson-not-refuted-by-a-stale-or-other",
+ "about": "A later opposite result that is itself stale, or about another hypothesis, or in another condition, does not refute.",
+ "current": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "refs": [
+ {
+ "lesson_id": "early",
+ "kind": "TEST_RESULT",
+ "outcome": "ALTERNATIVE",
+ "hypothesis_id": "h1",
+ "test_id": "t1",
+ "context": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "fingerprint": "f-early"
+ },
+ {
+ "lesson_id": "stale-later",
+ "kind": "TEST_RESULT",
+ "outcome": "THIS_HYPOTHESIS",
+ "hypothesis_id": "h1",
+ "test_id": "t2",
+ "context": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "fingerprint": "f-stale-later"
+ },
+ {
+ "lesson_id": "other-hypothesis",
+ "kind": "TEST_RESULT",
+ "outcome": "THIS_HYPOTHESIS",
+ "hypothesis_id": "h2",
+ "test_id": "t1",
+ "context": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "fingerprint": "f-other-hypothesis"
+ },
+ {
+ "lesson_id": "other-condition",
+ "kind": "TEST_RESULT",
+ "outcome": "THIS_HYPOTHESIS",
+ "hypothesis_id": "h1",
+ "test_id": "t1",
+ "context": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=fog",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "fingerprint": "f-other-condition"
+ }
+ ],
+ "fingerprints": {
+ "early": "f-early",
+ "stale-later": "f-changed",
+ "other-hypothesis": "f-other-hypothesis",
+ "other-condition": "f-other-condition"
+ },
+ "expected": {
+ "states": {
+ "early": "SAME_CONDITION",
+ "stale-later": "STALE",
+ "other-hypothesis": "SAME_CONDITION"
+ },
+ "absent": [
+ "other-condition"
+ ]
+ }
+ },
+ {
+ "id": "lesson-closure-and-effect-are-only-ever-stale",
+ "about": "A recorded closure and a confirmed effect are not refuted by test results; they only go stale.",
+ "current": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "refs": [
+ {
+ "lesson_id": "closure",
+ "kind": "HUMAN_CLOSURE",
+ "outcome": "HUMAN_CLOSED",
+ "hypothesis_id": null,
+ "test_id": null,
+ "context": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "fingerprint": "f-closure"
+ },
+ {
+ "lesson_id": "effect",
+ "kind": "EFFECT_CONFIRMED",
+ "outcome": "CONFIRMED",
+ "hypothesis_id": null,
+ "test_id": null,
+ "context": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "fingerprint": "f-effect"
+ },
+ {
+ "lesson_id": "later",
+ "kind": "TEST_RESULT",
+ "outcome": "ALTERNATIVE",
+ "hypothesis_id": "h1",
+ "test_id": "t1",
+ "context": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "fingerprint": "f-later"
+ }
+ ],
+ "fingerprints": {
+ "closure": "f-closure",
+ "effect": "f-effect",
+ "later": "f-later"
+ },
+ "expected": {
+ "states": {
+ "closure": "SAME_CONDITION",
+ "effect": "SAME_CONDITION",
+ "later": "SAME_CONDITION"
+ },
+ "absent": []
+ }
+ },
+ {
+ "id": "lesson-without-a-hypothesis-is-never-refuted",
+ "about": "A test-result lesson that names no hypothesis cannot be matched with an opposite one.",
+ "current": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "refs": [
+ {
+ "lesson_id": "a",
+ "kind": "TEST_RESULT",
+ "outcome": "ALTERNATIVE",
+ "hypothesis_id": null,
+ "test_id": "t1",
+ "context": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "fingerprint": "f-a"
+ },
+ {
+ "lesson_id": "b",
+ "kind": "TEST_RESULT",
+ "outcome": "THIS_HYPOTHESIS",
+ "hypothesis_id": null,
+ "test_id": "t1",
+ "context": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-C-DET-RAIN",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "fingerprint": "f-b"
+ }
+ ],
+ "fingerprints": {
+ "a": "f-a",
+ "b": "f-b"
+ },
+ "expected": {
+ "states": {
+ "a": "SAME_CONDITION",
+ "b": "SAME_CONDITION"
+ },
+ "absent": []
+ }
+ },
+ {
+ "id": "lesson-requirement-row",
+ "about": "A requirement row is recalled only for the same requirement.",
+ "current": {
+ "subject_kind": "REQUIREMENT",
+ "subject_id": "SYN-REQ-1",
+ "unit": null,
+ "condition": null,
+ "rule_id": null,
+ "rule_revision": null
+ },
+ "refs": [
+ {
+ "lesson_id": "same",
+ "kind": "HUMAN_CLOSURE",
+ "outcome": "CONDITION_CHANGED",
+ "hypothesis_id": null,
+ "test_id": null,
+ "context": {
+ "subject_kind": "REQUIREMENT",
+ "subject_id": "SYN-REQ-1",
+ "unit": null,
+ "condition": null,
+ "rule_id": null,
+ "rule_revision": null
+ },
+ "fingerprint": "f-same"
+ },
+ {
+ "lesson_id": "criterion-of-same-id",
+ "kind": "HUMAN_CLOSURE",
+ "outcome": "HUMAN_CLOSED",
+ "hypothesis_id": null,
+ "test_id": null,
+ "context": {
+ "subject_kind": "CRITERION",
+ "subject_id": "SYN-REQ-1",
+ "unit": "ratio",
+ "condition": "weather=rain",
+ "rule_id": "R-RAIN",
+ "rule_revision": 1
+ },
+ "fingerprint": "f-criterion-of-same-id"
+ }
+ ],
+ "fingerprints": {
+ "same": "f-same",
+ "criterion-of-same-id": "f-criterion-of-same-id"
+ },
+ "expected": {
+ "states": {
+ "same": "SAME_CONDITION"
+ },
+ "absent": [
+ "criterion-of-same-id"
+ ]
+ }
+ }
+ ],
+ "invalid_lessons": [
+ {
+ "about": "a kind the model could have written",
+ "patch": {
+ "kind": "MODEL_STATEMENT"
+ }
+ },
+ {
+ "about": "a free sentence as the outcome",
+ "patch": {
+ "outcome": "The antenna was probably misaligned"
+ }
+ },
+ {
+ "about": "an outcome that belongs to another kind",
+ "patch": {
+ "kind": "ELIMINATION",
+ "outcome": "ALTERNATIVE"
+ }
+ },
+ {
+ "about": "a fix with no confirmed effect as a closure lesson",
+ "patch": {
+ "kind": "HUMAN_CLOSURE",
+ "outcome": "FIX_APPLIED"
+ }
+ },
+ {
+ "about": "a source of the wrong kind",
+ "patch": {
+ "sources": [
+ {
+ "record_kind": "TRACE_CLOSURE",
+ "record_id": "c1"
+ }
+ ]
+ }
+ },
+ {
+ "about": "no source at all",
+ "patch": {
+ "sources": []
+ }
+ }
+ ]
+}
diff --git a/tests/fixtures/hypothesis_eval/ranking_cases.json b/tests/fixtures/hypothesis_eval/ranking_cases.json
new file mode 100644
index 0000000..0f28f33
--- /dev/null
+++ b/tests/fixtures/hypothesis_eval/ranking_cases.json
@@ -0,0 +1,1615 @@
+{
+ "schema_version": "hypothesis-ranking-cases/1",
+ "notice": "SYNTHETIC CASES - NO MODEL IS CALLED - THE EXPECTED ORDER IS WHAT THE RULE GIVES",
+ "cases": [
+ {
+ "id": "order-balanced-split",
+ "about": "A test that splits the candidates evenly goes before one that isolates a single candidate, and a confirm-only test goes below.",
+ "hypotheses": [
+ {
+ "id": "h1",
+ "elimination": null
+ },
+ {
+ "id": "h2",
+ "elimination": null
+ },
+ {
+ "id": "h3",
+ "elimination": null
+ },
+ {
+ "id": "h4",
+ "elimination": null
+ }
+ ],
+ "actions": [],
+ "items": [
+ {
+ "id": "tIsolate",
+ "kind": "TEST",
+ "hypothesisId": "h1",
+ "riskTier": "R0",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": {
+ "h2": "WITH_ALTERNATIVE",
+ "h3": "WITH_ALTERNATIVE",
+ "h4": "WITH_ALTERNATIVE"
+ }
+ },
+ {
+ "id": "tSplit",
+ "kind": "TEST",
+ "hypothesisId": "h1",
+ "riskTier": "R0",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": {
+ "h2": "WITH_TEST",
+ "h3": "WITH_ALTERNATIVE",
+ "h4": "WITH_ALTERNATIVE"
+ }
+ },
+ {
+ "id": "tConfirm",
+ "kind": "TEST",
+ "hypothesisId": "h3",
+ "riskTier": "R0",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": null
+ }
+ ],
+ "expected": {
+ "candidates": 4,
+ "groups": [
+ {
+ "group": "READY",
+ "ids": [
+ "tSplit",
+ "tIsolate"
+ ]
+ },
+ {
+ "group": "UNCOUNTABLE",
+ "ids": [
+ "tConfirm"
+ ]
+ }
+ ],
+ "worst": {
+ "tSplit": 2,
+ "tIsolate": 3,
+ "tConfirm": 3
+ },
+ "tie": [],
+ "excluded": []
+ }
+ },
+ {
+ "id": "order-confirm-only",
+ "about": "A test with no stated outcome for the other hypotheses is counted as removing one candidate and goes below the tests that can be counted.",
+ "hypotheses": [
+ {
+ "id": "h1",
+ "elimination": null
+ },
+ {
+ "id": "h2",
+ "elimination": null
+ },
+ {
+ "id": "h3",
+ "elimination": null
+ }
+ ],
+ "actions": [],
+ "items": [
+ {
+ "id": "tConfirm",
+ "kind": "TEST",
+ "hypothesisId": "h1",
+ "riskTier": "R0",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": null
+ },
+ {
+ "id": "tSplit",
+ "kind": "TEST",
+ "hypothesisId": "h2",
+ "riskTier": "R0",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": {
+ "h1": "WITH_ALTERNATIVE",
+ "h3": "WITH_ALTERNATIVE"
+ }
+ }
+ ],
+ "expected": {
+ "candidates": 3,
+ "groups": [
+ {
+ "group": "READY",
+ "ids": [
+ "tSplit"
+ ]
+ },
+ {
+ "group": "UNCOUNTABLE",
+ "ids": [
+ "tConfirm"
+ ]
+ }
+ ],
+ "worst": {
+ "tSplit": 2,
+ "tConfirm": 2
+ },
+ "tie": [],
+ "excluded": []
+ }
+ },
+ {
+ "id": "order-cost-unknown",
+ "about": "An unknown cost is not zero: that test is set apart after the tests whose cost is known.",
+ "hypotheses": [
+ {
+ "id": "h1",
+ "elimination": null
+ },
+ {
+ "id": "h2",
+ "elimination": null
+ }
+ ],
+ "actions": [],
+ "items": [
+ {
+ "id": "tUnknown",
+ "kind": "TEST",
+ "hypothesisId": "h1",
+ "riskTier": "R0",
+ "cost": null,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": {
+ "h2": "WITH_ALTERNATIVE"
+ }
+ },
+ {
+ "id": "tKnown",
+ "kind": "TEST",
+ "hypothesisId": "h2",
+ "riskTier": "R0",
+ "cost": 5,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": {
+ "h1": "WITH_ALTERNATIVE"
+ }
+ }
+ ],
+ "expected": {
+ "candidates": 2,
+ "groups": [
+ {
+ "group": "READY",
+ "ids": [
+ "tKnown"
+ ]
+ },
+ {
+ "group": "COST_UNKNOWN",
+ "ids": [
+ "tUnknown"
+ ]
+ }
+ ],
+ "worst": {
+ "tKnown": 1,
+ "tUnknown": 1
+ },
+ "tie": [],
+ "excluded": []
+ }
+ },
+ {
+ "id": "order-cost-unknown-among-uncountable",
+ "about": "Among tests whose split cannot be counted, an unknown cost still comes after a known one.",
+ "hypotheses": [
+ {
+ "id": "h1",
+ "elimination": null
+ },
+ {
+ "id": "h2",
+ "elimination": null
+ },
+ {
+ "id": "h3",
+ "elimination": null
+ }
+ ],
+ "actions": [],
+ "items": [
+ {
+ "id": "tUnknown",
+ "kind": "TEST",
+ "hypothesisId": "h1",
+ "riskTier": "R0",
+ "cost": null,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": null
+ },
+ {
+ "id": "tKnown",
+ "kind": "TEST",
+ "hypothesisId": "h2",
+ "riskTier": "R0",
+ "cost": 3,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": null
+ }
+ ],
+ "expected": {
+ "candidates": 3,
+ "groups": [
+ {
+ "group": "UNCOUNTABLE",
+ "ids": [
+ "tKnown",
+ "tUnknown"
+ ]
+ }
+ ],
+ "worst": {
+ "tKnown": 2,
+ "tUnknown": 2
+ },
+ "tie": [],
+ "excluded": []
+ }
+ },
+ {
+ "id": "order-tie",
+ "about": "Equal tests keep the order they were made in and are marked as a tie.",
+ "hypotheses": [
+ {
+ "id": "h1",
+ "elimination": null
+ },
+ {
+ "id": "h2",
+ "elimination": null
+ }
+ ],
+ "actions": [],
+ "items": [
+ {
+ "id": "t1",
+ "kind": "TEST",
+ "hypothesisId": "h1",
+ "riskTier": "R1",
+ "cost": 2,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": {
+ "h2": "WITH_ALTERNATIVE"
+ }
+ },
+ {
+ "id": "t2",
+ "kind": "TEST",
+ "hypothesisId": "h2",
+ "riskTier": "R1",
+ "cost": 2,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": {
+ "h1": "WITH_ALTERNATIVE"
+ }
+ },
+ {
+ "id": "t3",
+ "kind": "TEST",
+ "hypothesisId": "h1",
+ "riskTier": "R1",
+ "cost": 2,
+ "reversibility": "PARTIAL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": {
+ "h2": "WITH_ALTERNATIVE"
+ }
+ }
+ ],
+ "expected": {
+ "candidates": 2,
+ "groups": [
+ {
+ "group": "READY",
+ "ids": [
+ "t1",
+ "t2",
+ "t3"
+ ]
+ }
+ ],
+ "worst": {
+ "t1": 1,
+ "t2": 1,
+ "t3": 1
+ },
+ "tie": [
+ "t1",
+ "t2"
+ ],
+ "excluded": []
+ }
+ },
+ {
+ "id": "order-risk-before-cost",
+ "about": "With the same split, the lower rule risk goes first even when it costs more; a risk that was never declared counts as protected.",
+ "hypotheses": [
+ {
+ "id": "h1",
+ "elimination": null
+ },
+ {
+ "id": "h2",
+ "elimination": null
+ }
+ ],
+ "actions": [],
+ "items": [
+ {
+ "id": "tCheapProtected",
+ "kind": "TEST",
+ "hypothesisId": "h1",
+ "riskTier": "R2",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": {
+ "h2": "WITH_ALTERNATIVE"
+ }
+ },
+ {
+ "id": "tDear",
+ "kind": "TEST",
+ "hypothesisId": "h2",
+ "riskTier": "R0",
+ "cost": 9,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": {
+ "h1": "WITH_ALTERNATIVE"
+ }
+ },
+ {
+ "id": "tUndeclared",
+ "kind": "TEST",
+ "hypothesisId": "h1",
+ "riskTier": null,
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": {
+ "h2": "WITH_ALTERNATIVE"
+ }
+ }
+ ],
+ "expected": {
+ "candidates": 2,
+ "groups": [
+ {
+ "group": "READY",
+ "ids": [
+ "tDear",
+ "tCheapProtected",
+ "tUndeclared"
+ ]
+ }
+ ],
+ "worst": {
+ "tDear": 1,
+ "tCheapProtected": 1,
+ "tUndeclared": 1
+ },
+ "tie": [],
+ "excluded": []
+ }
+ },
+ {
+ "id": "order-reversible-before-created",
+ "about": "With everything else equal, a test that can be undone goes first.",
+ "hypotheses": [
+ {
+ "id": "h1",
+ "elimination": null
+ },
+ {
+ "id": "h2",
+ "elimination": null
+ }
+ ],
+ "actions": [],
+ "items": [
+ {
+ "id": "tFinal",
+ "kind": "TEST",
+ "hypothesisId": "h1",
+ "riskTier": "R0",
+ "cost": 1,
+ "reversibility": "NONE",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": {
+ "h2": "WITH_ALTERNATIVE"
+ }
+ },
+ {
+ "id": "tUndoable",
+ "kind": "TEST",
+ "hypothesisId": "h2",
+ "riskTier": "R0",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": {
+ "h1": "WITH_ALTERNATIVE"
+ }
+ }
+ ],
+ "expected": {
+ "candidates": 2,
+ "groups": [
+ {
+ "group": "READY",
+ "ids": [
+ "tUndoable",
+ "tFinal"
+ ]
+ }
+ ],
+ "worst": {
+ "tUndoable": 1,
+ "tFinal": 1
+ },
+ "tie": [],
+ "excluded": []
+ }
+ },
+ {
+ "id": "order-infeasible",
+ "about": "A test that cannot be run now is named apart, and a forbidden-level test is left out of the list.",
+ "hypotheses": [
+ {
+ "id": "h1",
+ "elimination": null
+ },
+ {
+ "id": "h2",
+ "elimination": null
+ }
+ ],
+ "actions": [],
+ "items": [
+ {
+ "id": "tBlocked",
+ "kind": "TEST",
+ "hypothesisId": "h1",
+ "riskTier": "R0",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": false,
+ "resultRecorded": false,
+ "predictions": {
+ "h2": "WITH_ALTERNATIVE"
+ }
+ },
+ {
+ "id": "tForbidden",
+ "kind": "TEST",
+ "hypothesisId": "h1",
+ "riskTier": "R4",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": {
+ "h2": "WITH_ALTERNATIVE"
+ }
+ },
+ {
+ "id": "tOk",
+ "kind": "TEST",
+ "hypothesisId": "h2",
+ "riskTier": "R0",
+ "cost": 4,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": {
+ "h1": "WITH_ALTERNATIVE"
+ }
+ }
+ ],
+ "expected": {
+ "candidates": 2,
+ "groups": [
+ {
+ "group": "READY",
+ "ids": [
+ "tOk"
+ ]
+ },
+ {
+ "group": "NOT_EXECUTABLE",
+ "ids": [
+ "tBlocked"
+ ]
+ }
+ ],
+ "worst": {
+ "tOk": 1,
+ "tBlocked": 1
+ },
+ "tie": [],
+ "excluded": [
+ "tForbidden"
+ ]
+ }
+ },
+ {
+ "id": "order-no-decision-change",
+ "about": "When every remaining candidate leads to the same action whatever the result, the test is named apart.",
+ "hypotheses": [
+ {
+ "id": "h1",
+ "elimination": null
+ },
+ {
+ "id": "h2",
+ "elimination": null
+ },
+ {
+ "id": "h3",
+ "elimination": null
+ }
+ ],
+ "actions": [
+ {
+ "id": "a1",
+ "hypothesisIds": [
+ "h1",
+ "h2",
+ "h3"
+ ]
+ }
+ ],
+ "items": [
+ {
+ "id": "tSame",
+ "kind": "TEST",
+ "hypothesisId": "h1",
+ "riskTier": "R0",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": {
+ "h2": "WITH_ALTERNATIVE",
+ "h3": "WITH_ALTERNATIVE"
+ }
+ }
+ ],
+ "expected": {
+ "candidates": 3,
+ "groups": [
+ {
+ "group": "NO_DECISION_CHANGE",
+ "ids": [
+ "tSame"
+ ]
+ }
+ ],
+ "worst": {
+ "tSame": 2
+ },
+ "tie": [],
+ "excluded": []
+ }
+ },
+ {
+ "id": "order-decision-changes",
+ "about": "When the results lead to different actions, the test stays in the main list.",
+ "hypotheses": [
+ {
+ "id": "h1",
+ "elimination": null
+ },
+ {
+ "id": "h2",
+ "elimination": null
+ },
+ {
+ "id": "h3",
+ "elimination": null
+ }
+ ],
+ "actions": [
+ {
+ "id": "a1",
+ "hypothesisIds": [
+ "h1"
+ ]
+ },
+ {
+ "id": "a2",
+ "hypothesisIds": [
+ "h2",
+ "h3"
+ ]
+ }
+ ],
+ "items": [
+ {
+ "id": "tDiffers",
+ "kind": "TEST",
+ "hypothesisId": "h1",
+ "riskTier": "R0",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": {
+ "h2": "WITH_ALTERNATIVE",
+ "h3": "WITH_ALTERNATIVE"
+ }
+ }
+ ],
+ "expected": {
+ "candidates": 3,
+ "groups": [
+ {
+ "group": "READY",
+ "ids": [
+ "tDiffers"
+ ]
+ }
+ ],
+ "worst": {
+ "tDiffers": 2
+ },
+ "tie": [],
+ "excluded": []
+ }
+ },
+ {
+ "id": "order-multi-cause",
+ "about": "Causes that can both be true: a test that cannot tell them apart removes nothing and goes after one that can.",
+ "hypotheses": [
+ {
+ "id": "h1",
+ "elimination": null
+ },
+ {
+ "id": "h2",
+ "elimination": null
+ }
+ ],
+ "actions": [],
+ "items": [
+ {
+ "id": "tBoth",
+ "kind": "TEST",
+ "hypothesisId": "h1",
+ "riskTier": "R0",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": {
+ "h2": "WITH_TEST"
+ }
+ },
+ {
+ "id": "tApart",
+ "kind": "TEST",
+ "hypothesisId": "h1",
+ "riskTier": "R0",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": {
+ "h2": "WITH_ALTERNATIVE"
+ }
+ }
+ ],
+ "expected": {
+ "candidates": 2,
+ "groups": [
+ {
+ "group": "READY",
+ "ids": [
+ "tApart",
+ "tBoth"
+ ]
+ }
+ ],
+ "worst": {
+ "tApart": 1,
+ "tBoth": 2
+ },
+ "tie": [],
+ "excluded": []
+ }
+ },
+ {
+ "id": "noisy-single-result",
+ "about": "One result that fits the other explanation leaves the hypothesis in the count and only marks it.",
+ "hypotheses": [
+ {
+ "id": "h1",
+ "elimination": "SINGLE"
+ },
+ {
+ "id": "h2",
+ "elimination": null
+ },
+ {
+ "id": "h3",
+ "elimination": null
+ }
+ ],
+ "actions": [],
+ "items": [
+ {
+ "id": "tOnce",
+ "kind": "TEST",
+ "hypothesisId": "h1",
+ "riskTier": "R0",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": {
+ "h2": "WITH_ALTERNATIVE",
+ "h3": "WITH_ALTERNATIVE"
+ }
+ }
+ ],
+ "expected": {
+ "candidates": 3,
+ "groups": [
+ {
+ "group": "READY",
+ "ids": [
+ "tOnce"
+ ]
+ }
+ ],
+ "worst": {
+ "tOnce": 2
+ },
+ "tie": [],
+ "excluded": [],
+ "eliminatedOnce": [
+ "tOnce"
+ ]
+ }
+ },
+ {
+ "id": "repeated-elimination",
+ "about": "A hypothesis eliminated by repeated results leaves the candidates; its tests go to their own group and others no longer need to cover it.",
+ "hypotheses": [
+ {
+ "id": "h1",
+ "elimination": "REPEATED"
+ },
+ {
+ "id": "h2",
+ "elimination": null
+ },
+ {
+ "id": "h3",
+ "elimination": null
+ }
+ ],
+ "actions": [],
+ "items": [
+ {
+ "id": "tOld",
+ "kind": "TEST",
+ "hypothesisId": "h1",
+ "riskTier": "R0",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": {
+ "h2": "WITH_ALTERNATIVE",
+ "h3": "WITH_ALTERNATIVE"
+ }
+ },
+ {
+ "id": "tLive",
+ "kind": "TEST",
+ "hypothesisId": "h2",
+ "riskTier": "R0",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": {
+ "h3": "WITH_ALTERNATIVE"
+ }
+ }
+ ],
+ "expected": {
+ "candidates": 2,
+ "groups": [
+ {
+ "group": "READY",
+ "ids": [
+ "tLive"
+ ]
+ },
+ {
+ "group": "HYPOTHESIS_ELIMINATED",
+ "ids": [
+ "tOld"
+ ]
+ }
+ ],
+ "worst": {
+ "tLive": 1
+ },
+ "tie": [],
+ "excluded": []
+ }
+ },
+ {
+ "id": "result-recorded",
+ "about": "A test whose result is already recorded is not offered as the next one.",
+ "hypotheses": [
+ {
+ "id": "h1",
+ "elimination": null
+ },
+ {
+ "id": "h2",
+ "elimination": null
+ }
+ ],
+ "actions": [],
+ "items": [
+ {
+ "id": "tDone",
+ "kind": "TEST",
+ "hypothesisId": "h1",
+ "riskTier": "R0",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": true,
+ "predictions": {
+ "h2": "WITH_ALTERNATIVE"
+ }
+ },
+ {
+ "id": "tNext",
+ "kind": "TEST",
+ "hypothesisId": "h2",
+ "riskTier": "R0",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": {
+ "h1": "WITH_ALTERNATIVE"
+ }
+ }
+ ],
+ "expected": {
+ "candidates": 2,
+ "groups": [
+ {
+ "group": "READY",
+ "ids": [
+ "tNext"
+ ]
+ },
+ {
+ "group": "RESULT_RECORDED",
+ "ids": [
+ "tDone"
+ ]
+ }
+ ],
+ "worst": {
+ "tNext": 1,
+ "tDone": 1
+ },
+ "tie": [],
+ "excluded": []
+ }
+ },
+ {
+ "id": "single-hypothesis",
+ "about": "With one candidate nothing can be narrowed: the test is counted as one, never as zero.",
+ "hypotheses": [
+ {
+ "id": "h1",
+ "elimination": null
+ }
+ ],
+ "actions": [],
+ "items": [
+ {
+ "id": "tOnly",
+ "kind": "TEST",
+ "hypothesisId": "h1",
+ "riskTier": "R0",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": null
+ }
+ ],
+ "expected": {
+ "candidates": 1,
+ "groups": [
+ {
+ "group": "UNCOUNTABLE",
+ "ids": [
+ "tOnly"
+ ]
+ }
+ ],
+ "worst": {
+ "tOnly": 1
+ },
+ "tie": [],
+ "excluded": []
+ }
+ },
+ {
+ "id": "actions-in-the-same-order",
+ "about": "Action candidates use the same rule: rule risk, then cost, then reversibility; a forbidden one is left out.",
+ "hypotheses": [
+ {
+ "id": "h1",
+ "elimination": null
+ },
+ {
+ "id": "h2",
+ "elimination": null
+ }
+ ],
+ "actions": [],
+ "items": [
+ {
+ "id": "aDear",
+ "kind": "ACTION",
+ "hypothesisId": null,
+ "riskTier": "R0",
+ "cost": 8,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": null
+ },
+ {
+ "id": "aProtected",
+ "kind": "ACTION",
+ "hypothesisId": null,
+ "riskTier": "R3",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": null
+ },
+ {
+ "id": "aForbidden",
+ "kind": "ACTION",
+ "hypothesisId": null,
+ "riskTier": "R4",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": null
+ },
+ {
+ "id": "aNoCost",
+ "kind": "ACTION",
+ "hypothesisId": null,
+ "riskTier": "R0",
+ "cost": null,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": null
+ }
+ ],
+ "expected": {
+ "candidates": 2,
+ "groups": [
+ {
+ "group": "UNCOUNTABLE",
+ "ids": [
+ "aDear",
+ "aNoCost",
+ "aProtected"
+ ]
+ }
+ ],
+ "worst": {
+ "aDear": 1
+ },
+ "tie": [],
+ "excluded": [
+ "aForbidden"
+ ]
+ }
+ },
+ {
+ "id": "order-uncountable-before-blocked",
+ "about": "A test that can be run but whose split cannot be counted still goes before one that cannot be run now.",
+ "hypotheses": [
+ {
+ "id": "h1",
+ "elimination": null
+ },
+ {
+ "id": "h2",
+ "elimination": null
+ },
+ {
+ "id": "h3",
+ "elimination": null
+ }
+ ],
+ "actions": [],
+ "items": [
+ {
+ "id": "tBlocked",
+ "kind": "TEST",
+ "hypothesisId": "h1",
+ "riskTier": "R0",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": false,
+ "resultRecorded": false,
+ "predictions": null
+ },
+ {
+ "id": "tRun",
+ "kind": "TEST",
+ "hypothesisId": "h2",
+ "riskTier": "R0",
+ "cost": 9,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": null
+ }
+ ],
+ "expected": {
+ "candidates": 3,
+ "groups": [
+ {
+ "group": "UNCOUNTABLE",
+ "ids": [
+ "tRun"
+ ]
+ },
+ {
+ "group": "NOT_EXECUTABLE",
+ "ids": [
+ "tBlocked"
+ ]
+ }
+ ],
+ "worst": {
+ "tRun": 2,
+ "tBlocked": 2
+ },
+ "tie": [],
+ "excluded": []
+ }
+ },
+ {
+ "id": "v3-three-way",
+ "about": "When every hypothesis expects a different result, the test leaves one candidate whichever way it goes.",
+ "hypotheses": [
+ {
+ "id": "h1",
+ "elimination": null
+ },
+ {
+ "id": "h2",
+ "elimination": null
+ },
+ {
+ "id": "h3",
+ "elimination": null
+ }
+ ],
+ "actions": [],
+ "items": [
+ {
+ "id": "t1",
+ "kind": "TEST",
+ "hypothesisId": "h1",
+ "riskTier": "R0",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": null,
+ "labels": {
+ "h1": "A",
+ "h2": "B",
+ "h3": "C"
+ }
+ }
+ ],
+ "expected": {
+ "candidates": 3,
+ "groups": [
+ {
+ "group": "READY",
+ "ids": [
+ "t1"
+ ]
+ }
+ ],
+ "worst": {
+ "t1": 1
+ },
+ "tie": [],
+ "excluded": []
+ }
+ },
+ {
+ "id": "v3-same-label",
+ "about": "Hypotheses that expect the same label are the same result: the biggest group is what is left.",
+ "hypotheses": [
+ {
+ "id": "h1",
+ "elimination": null
+ },
+ {
+ "id": "h2",
+ "elimination": null
+ },
+ {
+ "id": "h3",
+ "elimination": null
+ }
+ ],
+ "actions": [],
+ "items": [
+ {
+ "id": "t1",
+ "kind": "TEST",
+ "hypothesisId": "h1",
+ "riskTier": "R0",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": null,
+ "labels": {
+ "h1": "worse",
+ "h2": " Worse ",
+ "h3": "same"
+ }
+ }
+ ],
+ "expected": {
+ "candidates": 3,
+ "groups": [
+ {
+ "group": "READY",
+ "ids": [
+ "t1"
+ ]
+ }
+ ],
+ "worst": {
+ "t1": 2
+ },
+ "tie": [],
+ "excluded": []
+ }
+ },
+ {
+ "id": "v3-unknown",
+ "about": "A hypothesis whose expectation is not known stays under every result.",
+ "hypotheses": [
+ {
+ "id": "h1",
+ "elimination": null
+ },
+ {
+ "id": "h2",
+ "elimination": null
+ },
+ {
+ "id": "h3",
+ "elimination": null
+ }
+ ],
+ "actions": [],
+ "items": [
+ {
+ "id": "t1",
+ "kind": "TEST",
+ "hypothesisId": "h1",
+ "riskTier": "R0",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": null,
+ "labels": {
+ "h1": "A",
+ "h2": "B",
+ "h3": "모름"
+ }
+ }
+ ],
+ "expected": {
+ "candidates": 3,
+ "groups": [
+ {
+ "group": "READY",
+ "ids": [
+ "t1"
+ ]
+ }
+ ],
+ "worst": {
+ "t1": 2
+ },
+ "tie": [],
+ "excluded": []
+ }
+ },
+ {
+ "id": "v3-all-unknown",
+ "about": "When nothing else is known the test removes nothing; it is counted, not hidden.",
+ "hypotheses": [
+ {
+ "id": "h1",
+ "elimination": null
+ },
+ {
+ "id": "h2",
+ "elimination": null
+ },
+ {
+ "id": "h3",
+ "elimination": null
+ }
+ ],
+ "actions": [],
+ "items": [
+ {
+ "id": "t1",
+ "kind": "TEST",
+ "hypothesisId": "h1",
+ "riskTier": "R0",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": null,
+ "labels": {
+ "h1": "A",
+ "h2": "모름",
+ "h3": "모름"
+ }
+ }
+ ],
+ "expected": {
+ "candidates": 3,
+ "groups": [
+ {
+ "group": "READY",
+ "ids": [
+ "t1"
+ ]
+ }
+ ],
+ "worst": {
+ "t1": 3
+ },
+ "tie": [],
+ "excluded": []
+ }
+ },
+ {
+ "id": "v3-missing-row",
+ "about": "A candidate with no entry in the table means the split cannot be counted.",
+ "hypotheses": [
+ {
+ "id": "h1",
+ "elimination": null
+ },
+ {
+ "id": "h2",
+ "elimination": null
+ },
+ {
+ "id": "h3",
+ "elimination": null
+ }
+ ],
+ "actions": [],
+ "items": [
+ {
+ "id": "t1",
+ "kind": "TEST",
+ "hypothesisId": "h1",
+ "riskTier": "R0",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": null,
+ "labels": {
+ "h1": "A",
+ "h2": "B"
+ }
+ }
+ ],
+ "expected": {
+ "candidates": 3,
+ "groups": [
+ {
+ "group": "UNCOUNTABLE",
+ "ids": [
+ "t1"
+ ]
+ }
+ ],
+ "worst": {
+ "t1": 2
+ },
+ "tie": [],
+ "excluded": []
+ }
+ },
+ {
+ "id": "v3-eliminated-row-ignored",
+ "about": "A hypothesis eliminated by repeated results is out of the count and its row is not needed.",
+ "hypotheses": [
+ {
+ "id": "h1",
+ "elimination": "REPEATED"
+ },
+ {
+ "id": "h2",
+ "elimination": null
+ },
+ {
+ "id": "h3",
+ "elimination": null
+ }
+ ],
+ "actions": [],
+ "items": [
+ {
+ "id": "t2",
+ "kind": "TEST",
+ "hypothesisId": "h2",
+ "riskTier": "R0",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": null,
+ "labels": {
+ "h2": "A",
+ "h3": "B"
+ }
+ }
+ ],
+ "expected": {
+ "candidates": 2,
+ "groups": [
+ {
+ "group": "READY",
+ "ids": [
+ "t2"
+ ]
+ }
+ ],
+ "worst": {
+ "t2": 1
+ },
+ "tie": [],
+ "excluded": []
+ }
+ },
+ {
+ "id": "v3-order",
+ "about": "With tables the tests are ordered by how many candidates they can leave; one with no table goes below.",
+ "hypotheses": [
+ {
+ "id": "h1",
+ "elimination": null
+ },
+ {
+ "id": "h2",
+ "elimination": null
+ },
+ {
+ "id": "h3",
+ "elimination": null
+ }
+ ],
+ "actions": [],
+ "items": [
+ {
+ "id": "t2",
+ "kind": "TEST",
+ "hypothesisId": "h2",
+ "riskTier": "R0",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": null,
+ "labels": {
+ "h1": "x",
+ "h2": "y",
+ "h3": "y"
+ }
+ },
+ {
+ "id": "t1",
+ "kind": "TEST",
+ "hypothesisId": "h1",
+ "riskTier": "R0",
+ "cost": 9,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": null,
+ "labels": {
+ "h1": "A",
+ "h2": "B",
+ "h3": "C"
+ }
+ },
+ {
+ "id": "t3",
+ "kind": "TEST",
+ "hypothesisId": "h3",
+ "riskTier": "R0",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": null,
+ "labels": null
+ }
+ ],
+ "expected": {
+ "candidates": 3,
+ "groups": [
+ {
+ "group": "READY",
+ "ids": [
+ "t1",
+ "t2"
+ ]
+ },
+ {
+ "group": "UNCOUNTABLE",
+ "ids": [
+ "t3"
+ ]
+ }
+ ],
+ "worst": {
+ "t1": 1,
+ "t2": 2,
+ "t3": 2
+ },
+ "tie": [],
+ "excluded": []
+ }
+ },
+ {
+ "id": "v3-no-decision-change",
+ "about": "With tables, a test whose every result leads to the same action is named apart.",
+ "hypotheses": [
+ {
+ "id": "h1",
+ "elimination": null
+ },
+ {
+ "id": "h2",
+ "elimination": null
+ }
+ ],
+ "actions": [
+ {
+ "id": "a1",
+ "hypothesisIds": [
+ "h1",
+ "h2"
+ ]
+ }
+ ],
+ "items": [
+ {
+ "id": "t1",
+ "kind": "TEST",
+ "hypothesisId": "h1",
+ "riskTier": "R0",
+ "cost": 1,
+ "reversibility": "FULL",
+ "executable": true,
+ "resultRecorded": false,
+ "predictions": null,
+ "labels": {
+ "h1": "A",
+ "h2": "B"
+ }
+ }
+ ],
+ "expected": {
+ "candidates": 2,
+ "groups": [
+ {
+ "group": "NO_DECISION_CHANGE",
+ "ids": [
+ "t1"
+ ]
+ }
+ ],
+ "worst": {
+ "t1": 1
+ },
+ "tie": [],
+ "excluded": []
+ }
+ }
+ ]
+}
diff --git a/tests/fixtures/memory_eval/families.example.jsonl b/tests/fixtures/memory_eval/families.example.jsonl
new file mode 100644
index 0000000..76cfaf8
--- /dev/null
+++ b/tests/fixtures/memory_eval/families.example.jsonl
@@ -0,0 +1,4 @@
+# Synthetic examples of the families file (one JSON object per line). They name no real project.
+{"family_id": "EX-DIRECT-01", "type": "DIRECT", "project": "project:example", "split": "dev", "phrasings": [{"id": "original", "text": "시료 개수가 적으면 결론을 어떻게 다루나?"}, {"id": "other", "text": "표본 수가 모자랄 때 판단은 어떻게 하나?"}, {"id": "elliptic", "text": "그럼 개수가 적을 땐?"}], "gold": {"needed": ["memory:example-sample"], "forbidden": [], "answerable": true, "correction_applied": false}}
+{"family_id": "EX-FOLLOWUP-01", "type": "ELLIPTIC_FOLLOWUP", "project": "project:example", "split": "dev", "phrasings": [{"id": "original", "text": "앞에서 세운 가설을 다시 정리해 줘"}, {"id": "other", "text": "아까 말한 가설 요약 부탁해"}, {"id": "elliptic", "text": "그 가설은?"}], "gold": {"needed": ["memory:example-hypothesis"], "forbidden": ["rev:example-hypothesis-old"], "answerable": true, "correction_applied": false}}
+{"family_id": "EX-IRRELEVANT-01", "type": "IRRELEVANT", "project": "project:example", "split": "dev", "phrasings": [{"id": "original", "text": "오늘 점심 메뉴를 추천해 줘"}, {"id": "other", "text": "비 오는 날 어디 가면 좋을까?"}, {"id": "elliptic", "text": "그건 어때?"}], "gold": {"needed": [], "forbidden": [], "answerable": false, "correction_applied": false}}
diff --git a/tests/fixtures/trace_csv/README.txt b/tests/fixtures/trace_csv/README.txt
new file mode 100644
index 0000000..8357277
--- /dev/null
+++ b/tests/fixtures/trace_csv/README.txt
@@ -0,0 +1,10 @@
+Exchange fixtures for the thoth-trace-csv/1 format (invented data, not measurements).
+
+SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE
+
+Each file stresses one habit of spreadsheet programs: IDs with leading zeros, long digit strings,
+date-like IDs, duplicate IDs, shuffled rows and columns with blank lines and CRLF, UTF-8 with and
+without a byte order mark, Korean text and quoting, cells that start with = + - @ tab or CR (raw,
+and written with the leading apostrophe an export adds), and the difference between 0, "0",
+blank, null and a quoted empty cell. They are stored as raw bytes (see .gitattributes) and are
+read by tests/integration/test_trace_csv.py.
diff --git a/tests/fixtures/trace_csv/bom_utf8.csv b/tests/fixtures/trace_csv/bom_utf8.csv
new file mode 100644
index 0000000..5c3a0e8
--- /dev/null
+++ b/tests/fixtures/trace_csv/bom_utf8.csv
@@ -0,0 +1,7 @@
+format,row_type,base_set_digest,item_id,link_id,rule_id,result_id,target_kind,target_id,item_key,kind,title,fields_json,source_span_refs,from_id,to_id,relation,criterion_id,rule_revision,measure,comparator,threshold,unit,condition,rounding_digits,rounding_mode,required,result_revision,value,raw_value,numerator,denominator,observed_at
+thoth-trace-csv/1,ITEM,,REQ-1,,,,,,k-REQ-1,REQUIREMENT,One,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,CRIT-1,,,,,,k-CRIT-1,CRITERION,Two,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,TC-1,,,,,,k-TC-1,TEST_CASE,Three,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,LINK,,,L-1,,,,,,,,,,CRIT-1,REQ-1,REFINES,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,LINK,,,L-2,,,,,,,,,,CRIT-1,TC-1,VERIFIED_BY,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,RULE,,,,R-1,,,,,,,,,,,,CRIT-1,1,detection_rate,>=,0.90,ratio,weather=dry,2,HALF_UP,true,,,,,,
diff --git a/tests/fixtures/trace_csv/formula_prefix_escaped.csv b/tests/fixtures/trace_csv/formula_prefix_escaped.csv
new file mode 100644
index 0000000..78da353
--- /dev/null
+++ b/tests/fixtures/trace_csv/formula_prefix_escaped.csv
@@ -0,0 +1,9 @@
+format,row_type,base_set_digest,item_id,link_id,rule_id,result_id,target_kind,target_id,item_key,kind,title,fields_json,source_span_refs,from_id,to_id,relation,criterion_id,rule_revision,measure,comparator,threshold,unit,condition,rounding_digits,rounding_mode,required,result_revision,value,raw_value,numerator,denominator,observed_at
+thoth-trace-csv/1,ITEM,,F-1,,,,,,k-F-1,REQUIREMENT,'=1+1,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,F-2,,,,,,k-F-2,REQUIREMENT,'+SUM(A1),,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,F-3,,,,,,k-F-3,REQUIREMENT,'-2+3,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,F-4,,,,,,k-F-4,REQUIREMENT,'@cmd,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,F-5,,,,,,k-F-5,REQUIREMENT,' Tab first,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,F-6,,,,,,k-F-6,REQUIREMENT,"'
CR first",,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,F-7,,,,,,k-F-7,REQUIREMENT,'quote that is real text,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,F-8,,,,,,k-F-8,REQUIREMENT,''=two apostrophes then formula,,,,,,,,,,,,,,,,,,,,,
diff --git a/tests/fixtures/trace_csv/formula_prefix_raw.csv b/tests/fixtures/trace_csv/formula_prefix_raw.csv
new file mode 100644
index 0000000..e715a4f
--- /dev/null
+++ b/tests/fixtures/trace_csv/formula_prefix_raw.csv
@@ -0,0 +1,7 @@
+format,row_type,base_set_digest,item_id,link_id,rule_id,result_id,target_kind,target_id,item_key,kind,title,fields_json,source_span_refs,from_id,to_id,relation,criterion_id,rule_revision,measure,comparator,threshold,unit,condition,rounding_digits,rounding_mode,required,result_revision,value,raw_value,numerator,denominator,observed_at
+thoth-trace-csv/1,ITEM,,F-1,,,,,,k-F-1,REQUIREMENT,=1+1,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,F-2,,,,,,k-F-2,REQUIREMENT,+SUM(A1),,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,F-3,,,,,,k-F-3,REQUIREMENT,-2+3,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,F-4,,,,,,k-F-4,REQUIREMENT,@cmd,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,F-5,,,,,,k-F-5,REQUIREMENT, Tab first,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,F-6,,,,,,k-F-6,REQUIREMENT,"
CR first",,,,,,,,,,,,,,,,,,,,,
diff --git a/tests/fixtures/trace_csv/ids_date_like.csv b/tests/fixtures/trace_csv/ids_date_like.csv
new file mode 100644
index 0000000..720028b
--- /dev/null
+++ b/tests/fixtures/trace_csv/ids_date_like.csv
@@ -0,0 +1,4 @@
+format,row_type,base_set_digest,item_id,link_id,rule_id,result_id,target_kind,target_id,item_key,kind,title,fields_json,source_span_refs,from_id,to_id,relation,criterion_id,rule_revision,measure,comparator,threshold,unit,condition,rounding_digits,rounding_mode,required,result_revision,value,raw_value,numerator,denominator,observed_at
+thoth-trace-csv/1,ITEM,,1-2,,,,,,k-1-2,REQUIREMENT,title 1-2,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,2026-10,,,,,,k-2026-10,CRITERION,title 2026-10,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,3/4,,,,,,k-3/4,TEST_CASE,title 3/4,,,,,,,,,,,,,,,,,,,,,
diff --git a/tests/fixtures/trace_csv/ids_duplicate.csv b/tests/fixtures/trace_csv/ids_duplicate.csv
new file mode 100644
index 0000000..51b09c1
--- /dev/null
+++ b/tests/fixtures/trace_csv/ids_duplicate.csv
@@ -0,0 +1,3 @@
+format,row_type,base_set_digest,item_id,link_id,rule_id,result_id,target_kind,target_id,item_key,kind,title,fields_json,source_span_refs,from_id,to_id,relation,criterion_id,rule_revision,measure,comparator,threshold,unit,condition,rounding_digits,rounding_mode,required,result_revision,value,raw_value,numerator,denominator,observed_at
+thoth-trace-csv/1,ITEM,,REQ-001,,,,,,k-REQ-001,REQUIREMENT,First,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,REQ-001,,,,,,k-REQ-001,REQUIREMENT,Second with the same id,,,,,,,,,,,,,,,,,,,,,
diff --git a/tests/fixtures/trace_csv/ids_leading_zero.csv b/tests/fixtures/trace_csv/ids_leading_zero.csv
new file mode 100644
index 0000000..08f8d83
--- /dev/null
+++ b/tests/fixtures/trace_csv/ids_leading_zero.csv
@@ -0,0 +1,4 @@
+format,row_type,base_set_digest,item_id,link_id,rule_id,result_id,target_kind,target_id,item_key,kind,title,fields_json,source_span_refs,from_id,to_id,relation,criterion_id,rule_revision,measure,comparator,threshold,unit,condition,rounding_digits,rounding_mode,required,result_revision,value,raw_value,numerator,denominator,observed_at
+thoth-trace-csv/1,ITEM,,007,,,,,,k-007,REQUIREMENT,title 007,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,0012,,,,,,k-0012,CRITERION,title 0012,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,00,,,,,,k-00,TEST_CASE,title 00,,,,,,,,,,,,,,,,,,,,,
diff --git a/tests/fixtures/trace_csv/ids_long_numeric.csv b/tests/fixtures/trace_csv/ids_long_numeric.csv
new file mode 100644
index 0000000..944c5b1
--- /dev/null
+++ b/tests/fixtures/trace_csv/ids_long_numeric.csv
@@ -0,0 +1,4 @@
+format,row_type,base_set_digest,item_id,link_id,rule_id,result_id,target_kind,target_id,item_key,kind,title,fields_json,source_span_refs,from_id,to_id,relation,criterion_id,rule_revision,measure,comparator,threshold,unit,condition,rounding_digits,rounding_mode,required,result_revision,value,raw_value,numerator,denominator,observed_at
+thoth-trace-csv/1,ITEM,,12345678901234567890,,,,,,k-12345678901234567890,REQUIREMENT,title 12345678901234567890,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,1234567890123456789012,,,,,,k-1234567890123456789012,CRITERION,title 1234567890123456789012,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,1.5E+3,,,,,,k-1.5E+3,TEST_CASE,title 1.5E+3,,,,,,,,,,,,,,,,,,,,,
diff --git a/tests/fixtures/trace_csv/korean_special.csv b/tests/fixtures/trace_csv/korean_special.csv
new file mode 100644
index 0000000..8f8a0f1
--- /dev/null
+++ b/tests/fixtures/trace_csv/korean_special.csv
@@ -0,0 +1,5 @@
+format,row_type,base_set_digest,item_id,link_id,rule_id,result_id,target_kind,target_id,item_key,kind,title,fields_json,source_span_refs,from_id,to_id,relation,criterion_id,rule_revision,measure,comparator,threshold,unit,condition,rounding_digits,rounding_mode,required,result_revision,value,raw_value,numerator,denominator,observed_at
+thoth-trace-csv/1,ITEM,,REQ-한글-1,,,,,,k-REQ-한글-1,REQUIREMENT,"탐지율은 0.90 이상, 비 오는 날 포함",,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,REQ-2,,,,,,k-REQ-2,REQUIREMENT,"따옴표 ""안의"" 글자와 쉼표, 그리고 줄바꿈
+두 번째 줄",,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,REQ-3,,,,,,k-REQ-3,REQUIREMENT,기호 ☂ ± ≥ ℃ 그리고 Ω,,,,,,,,,,,,,,,,,,,,,
diff --git a/tests/fixtures/trace_csv/no_bom_utf8.csv b/tests/fixtures/trace_csv/no_bom_utf8.csv
new file mode 100644
index 0000000..7c41f98
--- /dev/null
+++ b/tests/fixtures/trace_csv/no_bom_utf8.csv
@@ -0,0 +1,7 @@
+format,row_type,base_set_digest,item_id,link_id,rule_id,result_id,target_kind,target_id,item_key,kind,title,fields_json,source_span_refs,from_id,to_id,relation,criterion_id,rule_revision,measure,comparator,threshold,unit,condition,rounding_digits,rounding_mode,required,result_revision,value,raw_value,numerator,denominator,observed_at
+thoth-trace-csv/1,ITEM,,REQ-1,,,,,,k-REQ-1,REQUIREMENT,One,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,CRIT-1,,,,,,k-CRIT-1,CRITERION,Two,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,TC-1,,,,,,k-TC-1,TEST_CASE,Three,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,LINK,,,L-1,,,,,,,,,,CRIT-1,REQ-1,REFINES,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,LINK,,,L-2,,,,,,,,,,CRIT-1,TC-1,VERIFIED_BY,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,RULE,,,,R-1,,,,,,,,,,,,CRIT-1,1,detection_rate,>=,0.90,ratio,weather=dry,2,HALF_UP,true,,,,,,
diff --git a/tests/fixtures/trace_csv/order_baseline.csv b/tests/fixtures/trace_csv/order_baseline.csv
new file mode 100644
index 0000000..7c41f98
--- /dev/null
+++ b/tests/fixtures/trace_csv/order_baseline.csv
@@ -0,0 +1,7 @@
+format,row_type,base_set_digest,item_id,link_id,rule_id,result_id,target_kind,target_id,item_key,kind,title,fields_json,source_span_refs,from_id,to_id,relation,criterion_id,rule_revision,measure,comparator,threshold,unit,condition,rounding_digits,rounding_mode,required,result_revision,value,raw_value,numerator,denominator,observed_at
+thoth-trace-csv/1,ITEM,,REQ-1,,,,,,k-REQ-1,REQUIREMENT,One,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,CRIT-1,,,,,,k-CRIT-1,CRITERION,Two,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,TC-1,,,,,,k-TC-1,TEST_CASE,Three,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,LINK,,,L-1,,,,,,,,,,CRIT-1,REQ-1,REFINES,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,LINK,,,L-2,,,,,,,,,,CRIT-1,TC-1,VERIFIED_BY,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,RULE,,,,R-1,,,,,,,,,,,,CRIT-1,1,detection_rate,>=,0.90,ratio,weather=dry,2,HALF_UP,true,,,,,,
diff --git a/tests/fixtures/trace_csv/order_shuffled.csv b/tests/fixtures/trace_csv/order_shuffled.csv
new file mode 100644
index 0000000..a6197eb
--- /dev/null
+++ b/tests/fixtures/trace_csv/order_shuffled.csv
@@ -0,0 +1,14 @@
+
+observed_at,denominator,numerator,raw_value,value,result_revision,required,rounding_mode,rounding_digits,condition,unit,threshold,comparator,measure,rule_revision,criterion_id,relation,to_id,from_id,source_span_refs,fields_json,title,kind,item_key,target_id,target_kind,result_id,rule_id,link_id,item_id,base_set_digest,row_type,format
+,,,,,,true,HALF_UP,2,weather=dry,ratio,0.90,>=,detection_rate,1,CRIT-1,,,,,,,,,,,,R-1,,,,RULE,thoth-trace-csv/1
+
+,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
+,,,,,,,,,,,,,,,,,,,,,Three,TEST_CASE,k-TC-1,,,,,,TC-1,,ITEM,thoth-trace-csv/1
+,,,,,,,,,,,,,,,,,,,,,One,REQUIREMENT,k-REQ-1,,,,,,REQ-1,,ITEM,thoth-trace-csv/1
+
+,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
+,,,,,,,,,,,,,,,,VERIFIED_BY,TC-1,CRIT-1,,,,,,,,,,L-2,,,LINK,thoth-trace-csv/1
+,,,,,,,,,,,,,,,,,,,,,Two,CRITERION,k-CRIT-1,,,,,,CRIT-1,,ITEM,thoth-trace-csv/1
+
+,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
+,,,,,,,,,,,,,,,,REFINES,REQ-1,CRIT-1,,,,,,,,,,L-1,,,LINK,thoth-trace-csv/1
diff --git a/tests/fixtures/trace_csv/zero_empty_null.csv b/tests/fixtures/trace_csv/zero_empty_null.csv
new file mode 100644
index 0000000..716f23c
--- /dev/null
+++ b/tests/fixtures/trace_csv/zero_empty_null.csv
@@ -0,0 +1,12 @@
+format,row_type,base_set_digest,item_id,link_id,rule_id,result_id,target_kind,target_id,item_key,kind,title,fields_json,source_span_refs,from_id,to_id,relation,criterion_id,rule_revision,measure,comparator,threshold,unit,condition,rounding_digits,rounding_mode,required,result_revision,value,raw_value,numerator,denominator,observed_at
+thoth-trace-csv/1,ITEM,,C-1,,,,,,k-C-1,CRITERION,criterion,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,Z-1,,,,,,k-Z-1,RESULT,result Z-1,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,Z-2,,,,,,k-Z-2,RESULT,result Z-2,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,Z-3,,,,,,k-Z-3,RESULT,result Z-3,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,Z-4,,,,,,k-Z-4,RESULT,result Z-4,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,ITEM,,Z-5,,,,,,k-Z-5,RESULT,result Z-5,,,,,,,,,,,,,,,,,,,,,
+thoth-trace-csv/1,RESULT,,,,,Z-1,,,,,,,,,,,C-1,,,,,per_min,weather=dry,,,,1,0,,,,2026-01-01T00:00:00.000Z
+thoth-trace-csv/1,RESULT,,,,,Z-2,,,,,,,,,,,C-1,,,,,per_min,weather=dry,,,,1,"0",,,,2026-01-01T00:00:00.000Z
+thoth-trace-csv/1,RESULT,,,,,Z-3,,,,,,,,,,,C-1,,,,,per_min,weather=dry,,,,1,,,,,2026-01-01T00:00:00.000Z
+thoth-trace-csv/1,RESULT,,,,,Z-4,,,,,,,,,,,C-1,,,,,per_min,weather=dry,,,,1,null,,,,2026-01-01T00:00:00.000Z
+thoth-trace-csv/1,RESULT,,,,,Z-5,,,,,,,,,,,C-1,,,,,per_min,weather=dry,,,,1,"",,,,2026-01-01T00:00:00.000Z
diff --git a/tests/integration/test_action_effect_value_type.py b/tests/integration/test_action_effect_value_type.py
new file mode 100644
index 0000000..3cd7eff
--- /dev/null
+++ b/tests/integration/test_action_effect_value_type.py
@@ -0,0 +1,116 @@
+"""A badly typed effect flag is refused at the action entry and nothing is stored."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import cast
+
+import pytest
+from pydantic import JsonValue
+from tests.integration.scoped_runtime import create_runtime
+from tests.integration.source_time_fixture import confirm_synthetic_source_time
+from tests.integration.test_action_full_rpc import request, value
+
+
+def _specification(flag: str, flag_value: object) -> dict[str, object]:
+ return {
+ "description": "Change something",
+ "expected_observation_or_change": {"description": "something changes"},
+ "effect_completeness_confirmed": True,
+ "stop_conditions": ["always"],
+ "observability": "receipt",
+ "effect_vector": {"effect_completeness_confirmed": True, flag: flag_value},
+ }
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("bad", ["yes", 1, None])
+async def test_a_forbidden_effect_written_as_a_word_or_number_is_refused_and_nothing_is_stored(
+ tmp_path: Path, bad: object
+) -> None:
+ workspace = tmp_path / "workspace"
+ (workspace / "inbox").mkdir(parents=True)
+ (workspace / "inbox" / "evidence.md").write_text("# Evidence\n\nA result.\n", encoding="utf-8")
+ runtime = create_runtime(workspace)
+ project_id = "project:effect-type"
+ try:
+ value(
+ await runtime.bus.dispatch(
+ request(
+ "project/create",
+ "et-project",
+ {
+ "project_id": project_id,
+ "name": "Effect type",
+ "cutoff_at": "2026-08-31T00:00:00Z",
+ },
+ )
+ )
+ )
+ connected = value(
+ await runtime.bus.dispatch(
+ request(
+ "project/source/connect",
+ "et-source",
+ {
+ "project_id": project_id,
+ "relative_path": "evidence.md",
+ "media_type": "text/markdown",
+ "authority": "OFFICIAL",
+ "cutoff_state": "ELIGIBLE",
+ "security_class": "INTERNAL",
+ "expected_project_revision": 0,
+ },
+ )
+ )
+ )
+ await confirm_synthetic_source_time(runtime, project_id, connected, key="et-source")
+ evidence = value(
+ await runtime.bus.dispatch(
+ request("evidence/list", "et-evidence", {"project_id": project_id})
+ )
+ )
+ refs = [
+ str(item["span_id"]) for item in cast(list[dict[str, JsonValue]], evidence["spans"])
+ ]
+ started = value(
+ await runtime.bus.dispatch(
+ request(
+ "thread/start",
+ "et-thread",
+ {"project_id": project_id, "thread_id": "thread:et", "problem": "What now?"},
+ )
+ )
+ )
+ object_id = str(cast(list[str], started["current_object_ids"])[0])
+
+ def create(key: str, flag_value: object):
+ return runtime.bus.dispatch(
+ request(
+ "action/create",
+ key,
+ {
+ "project_id": project_id,
+ "object_id": object_id,
+ "portfolio_id": "action-portfolio:et",
+ "primary_purpose": "GOVERNANCE_ESCALATION",
+ "specification": _specification("grants_waiver", flag_value),
+ "evidence_refs": refs,
+ },
+ )
+ )
+
+ refused = await create("et-bad", bad)
+ assert refused.error is not None
+ assert "grants_waiver" in refused.error.message and "true or false" in refused.error.message
+ listed = value(
+ await runtime.bus.dispatch(
+ request("action/list", "et-list", {"project_id": project_id})
+ )
+ )
+ assert listed["actions"] == []
+ accepted = value(await create("et-good", True))
+ action = cast(dict[str, JsonValue], accepted["action"])
+ assert action["risk_tier"] == "R4"
+ finally:
+ runtime.close()
diff --git a/tests/integration/test_cutoff_precision_currentness.py b/tests/integration/test_cutoff_precision_currentness.py
new file mode 100644
index 0000000..751adc9
--- /dev/null
+++ b/tests/integration/test_cutoff_precision_currentness.py
@@ -0,0 +1,149 @@
+"""A project cutoff typed with sub-millisecond digits must not make a fresh answer look out of date.
+
+The project keeps the cutoff exactly as typed, a stored research result keeps it to the
+millisecond. Comparing the two as plain datetimes marked every such answer "review required"
+(POLICY_OR_CUTOFF_CHANGED) the moment it finished. A real change of a millisecond or more must
+still be seen."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+from uuid import uuid4
+
+import pytest
+from tests.integration.scoped_runtime import fixture_scope_policy
+from tests.integration.storage_coverage_helpers import request, value
+from tests.integration.test_research_request_v2 import ControlledResearchModel
+
+from thoth.apps.runtime import create_runtime
+
+
+async def finished_thread(tmp_path: Path, cutoff: str) -> tuple[Any, str]:
+ runtime = create_runtime(
+ tmp_path,
+ model_resolver=ControlledResearchModel(),
+ resource_scope_policy=fixture_scope_policy(),
+ )
+ value(
+ await runtime.bus.dispatch(
+ request(
+ "project/create",
+ "project",
+ {
+ "project_id": "p",
+ "name": "cutoff precision",
+ "cutoff_at": cutoff,
+ "overlay": "general-rnd",
+ },
+ )
+ )
+ )
+ (tmp_path / "inbox").mkdir(exist_ok=True)
+ (tmp_path / "inbox" / "records.html").write_text(
+ ' '
+ "Latency report 기록 ID LAB-42: 지연 12 ms
",
+ encoding="utf-8",
+ )
+ value(
+ await runtime.bus.dispatch(
+ request(
+ "project/source/connect",
+ "source",
+ {
+ "project_id": "p",
+ "relative_path": "records.html",
+ "media_type": "text/html",
+ "authority": "INFORMAL",
+ "cutoff_state": "ELIGIBLE",
+ "security_class": "INTERNAL",
+ },
+ )
+ )
+ )
+ accepted = value(
+ await runtime.bus.dispatch(
+ request(
+ "thread/start",
+ "start",
+ {"project_id": "p", "problem": "LAB-42의 지연은?", "contract_version": 2},
+ )
+ )
+ )
+ await runtime.bus.drain()
+ return runtime, str(accepted["thread_id"])
+
+
+async def currentness(runtime: Any, thread: str) -> dict[str, Any]:
+ status = value(
+ await runtime.bus.dispatch(
+ request(
+ "thread/read",
+ f"read-{uuid4()}",
+ {"project_id": "p", "thread_id": thread, "view": "FULL"},
+ )
+ )
+ )
+ assert status["current_result"] is not None or status["previous_result"] is not None
+ return {**status["basis_currentness"], "fresh": status["freshness"] == "CURRENT"}
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "cutoff",
+ [
+ "2026-09-13T00:00:00.123456Z", # typed to the microsecond
+ "2026-09-13T09:00:00.123456+09:00", # the same instant in another time zone
+ "2026-09-13T00:00:00.123Z", # already a millisecond
+ ],
+)
+async def test_a_finished_answer_stays_current_whatever_precision_the_cutoff_was_typed_in(
+ tmp_path: Path, cutoff: str
+) -> None:
+ runtime, thread = await finished_thread(tmp_path, cutoff)
+ try:
+ state = await currentness(runtime, thread)
+ assert state["state"] == "CURRENT" and state["reasons"] == [], state
+ assert state["fresh"] is True
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_a_cutoff_changed_by_a_millisecond_is_still_a_change(tmp_path: Path) -> None:
+ runtime, thread = await finished_thread(tmp_path, "2026-09-13T00:00:00.123456Z")
+ try:
+ assert (await currentness(runtime, thread))["state"] == "CURRENT"
+ proposed = "2026-09-13T00:00:00.124456Z" # one millisecond later
+ impact = value(
+ await runtime.bus.dispatch(
+ request(
+ "project/cutoff/impact",
+ "impact",
+ {"project_id": "p", "proposed_cutoff_at": proposed},
+ )
+ )
+ )["impact"]
+ project = value(
+ await runtime.bus.dispatch(request("project/read", "pr", {"project_id": "p"}))
+ )
+ value(
+ await runtime.bus.dispatch(
+ request(
+ "project/cutoff/update",
+ "update",
+ {
+ "project_id": "p",
+ "expected_revision": project["revision"],
+ "cutoff_at": proposed,
+ "expected_impact_digest": impact["impact_digest"],
+ },
+ )
+ )
+ )
+ state = await currentness(runtime, thread)
+ assert (
+ state["state"] == "REVIEW_REQUIRED" and "POLICY_OR_CUTOFF_CHANGED" in state["reasons"]
+ ), state
+ finally:
+ runtime.close()
diff --git a/tests/integration/test_hypothesis_action_guard.py b/tests/integration/test_hypothesis_action_guard.py
new file mode 100644
index 0000000..4dc1f14
--- /dev/null
+++ b/tests/integration/test_hypothesis_action_guard.py
@@ -0,0 +1,392 @@
+"""Actions and approvals on a hypothesis whose verdict changed, and an action draft from a test.
+
+A stale hypothesis is kept out of a new action and out of an approval decision (not only out of
+preparing one). A changed basis under the same verdict is told apart. An action draft made from a
+discriminating test takes its risk from the effect declaration and the rules, never from the
+tier the model wrote on the test, and calls no model. These tests go through the public methods.
+"""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any, cast
+
+import pytest
+from tests.integration.test_hypothesis_link import (
+ change_result,
+ investigate,
+ links,
+ verdict_state,
+)
+from tests.integration.test_research_request_v2 import ControlledResearchModel
+from tests.integration.test_trace_origin import DRY, RAIN, Rpc, opened
+
+from thoth.application.services.authorization_consumption import decide_authorization
+from thoth.apps.runtime import AppRuntime
+from thoth.domain.action_full import AuthorizationEnvelopeRecord
+
+CHANGED = "HYPOTHESIS_BASIS_CHANGED"
+
+
+async def hypothesis_ids(rpc: Rpc) -> list[str]:
+ found = (await rpc("hypothesis/list", project_id="p"))["hypotheses"]
+ return [item["hypothesis_id"] for item in found]
+
+
+async def create_on(rpc: Rpc, hypothesis_id: str, method: str = "action/create") -> dict[str, Any]:
+ """The call a person's screen makes to add an action on this hypothesis' object."""
+ read = (await rpc("hypothesis/read", project_id="p", hypothesis_id=hypothesis_id))["hypothesis"]
+ base: dict[str, Any] = dict(
+ project_id="p", object_id=read["object_id"], hypothesis_refs=[hypothesis_id]
+ )
+ if method == "action/generate":
+ return dict(**base, decision_need="Look again", evidence_scope=read["evidence_refs"])
+ return dict(
+ **base,
+ primary_purpose="INFORMATION_ACQUISITION",
+ evidence_refs=read["evidence_refs"],
+ specification={
+ "description": "Look again",
+ "expected_observation_or_change": {"description": "a reading", "status": "CANDIDATE"},
+ "effect_completeness_confirmed": True,
+ "effect_vector": {"effect_completeness_confirmed": True},
+ "stop_conditions": ["a reading is taken"],
+ "observability": "receipt",
+ },
+ )
+
+
+async def action_count(rpc: Rpc) -> int:
+ return len((await rpc("action/list", project_id="p"))["actions"])
+
+
+async def recheck(rpc: Rpc, hypothesis_id: str, reason: str) -> None:
+ item = (await links(rpc))[hypothesis_id]
+ await rpc(
+ "hypothesis/link/recheck",
+ project_id="p",
+ hypothesis_ids=[hypothesis_id],
+ reason_code=reason,
+ current_verdict_revision=item["current_verdict_revision"],
+ )
+
+
+@pytest.mark.asyncio
+async def test_a_new_action_on_a_stale_hypothesis_is_refused_and_not_otherwise(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel(one=True))
+ try:
+ await investigate(runtime, rpc, None) # its hypothesis has no link: never held back
+ await investigate(runtime, rpc, RAIN)
+ (linked,) = (await links(rpc)).keys()
+ (plain,) = [item for item in await hypothesis_ids(rpc) if item != linked]
+ before = await action_count(rpc)
+ await rpc("action/create", **await create_on(rpc, linked)) # the verdict has not changed
+ await rpc.rain_arrives()
+ for method in ("action/create", "action/generate"):
+ refused = await rpc.error(method, **await create_on(rpc, linked, method))
+ assert CHANGED in refused
+ assert await action_count(rpc) == before + 1 # the refused calls wrote nothing
+ await rpc("action/create", **await create_on(rpc, plain)) # no link: not a false rejection
+ await rpc("action/generate", **await create_on(rpc, plain, "action/generate"))
+ await recheck(rpc, linked, "NEEDS_RESEARCH")
+ assert CHANGED in await rpc.error("action/create", **await create_on(rpc, linked))
+ await recheck(rpc, linked, "STILL_MATCHES") # a person looked at this very change
+ await rpc("action/create", **await create_on(rpc, linked))
+ finally:
+ runtime.close()
+
+
+def envelope_for(plan: dict[str, Any]) -> AuthorizationEnvelopeRecord:
+ now = datetime.now(UTC)
+ return AuthorizationEnvelopeRecord(
+ authorization_revision_id="authorization-revision:1",
+ authorization_id="authorization:1",
+ project_id="p",
+ plan_id=plan["plan_id"],
+ step_id=plan["steps"][0]["step_id"],
+ plan_revision_digest=plan["revision_digest"],
+ predecessor_output_digests=(),
+ target_baseline_digests=(),
+ policy_version="policy:current",
+ exact_scope_digest="a" * 64,
+ required_roles=("project-owner",),
+ expires_at=now + timedelta(days=1),
+ revision_digest="b" * 64,
+ created_at=now,
+ )
+
+
+@pytest.mark.asyncio
+async def test_an_approval_that_was_prepared_in_time_is_refused_when_decided_on_a_changed_basis(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel(one=True))
+ try:
+ await investigate(runtime, rpc, RAIN)
+ (linked,) = (await links(rpc)).keys()
+ action = (await rpc("action/list", project_id="p"))["actions"][0]
+ plan = (
+ await rpc("action/plan/read", project_id="p", plan_id=f"plan:{action['object_id']}")
+ )["plan"]
+ pending = envelope_for(plan) # prepared while the verdict still stood
+
+ def decide(decision: str) -> None:
+ """Past the stale check the call reaches the role check, which has no roles here."""
+ decide_authorization(
+ pending,
+ decision=decision,
+ actor_ref="human:owner",
+ role_assignment_ref="role:owner",
+ approved_digest=pending.exact_scope_digest,
+ reason=None,
+ dissent=None,
+ store=cast(Any, SimpleNamespace(read_authorization=lambda *_: pending)),
+ governance=cast(Any, SimpleNamespace(list_roles=lambda _: [])),
+ ledger=runtime.ledger,
+ clock=cast(Any, SimpleNamespace(now=lambda: datetime.now(UTC))),
+ ids=cast(Any, None),
+ audit=lambda *_: cast(Any, None),
+ )
+
+ no_role = "non-fungible role"
+ with pytest.raises(ValueError, match=no_role):
+ decide("APPROVE") # nothing has changed: not a false rejection
+ await rpc.rain_arrives()
+ with pytest.raises(ValueError, match=CHANGED):
+ decide("APPROVE")
+ with pytest.raises(ValueError, match=no_role):
+ decide("REJECT") # saying no to a stale basis is always allowed
+ await recheck(rpc, linked, "NEEDS_RESEARCH")
+ with pytest.raises(ValueError, match=CHANGED):
+ decide("APPROVE")
+ await recheck(rpc, linked, "UNRELATED")
+ with pytest.raises(ValueError, match=no_role):
+ decide("APPROVE")
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_results_that_change_under_the_same_verdict_are_told_apart_but_still_stale(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel(one=True))
+ try:
+ await investigate(runtime, rpc, DRY)
+ assert await verdict_state(rpc, DRY) == "PASS_COMPUTED"
+ # 18 of 20 is 0.90: a new result revision, and the row is still met
+ await change_result(rpc, "SYN-RES-SYN-C-DET-DRY", value="0.90", numerator="18")
+ assert await verdict_state(rpc, DRY) == "PASS_COMPUTED"
+ (item,) = (await links(rpc)).values()
+ assert (item["state"], item["change"]) == ("STALE", "EVIDENCE_ONLY")
+ assert (item["link_state"], item["current_state"]) == ("PASS_COMPUTED", "PASS_COMPUTED")
+ # the rule for approval and for execution is the same as for any other change
+ assert CHANGED in await rpc.error(
+ "action/create", **await create_on(rpc, item["hypothesis_id"])
+ )
+ # a person's recheck covers it like any other change (no flip, so no words needed)
+ await recheck(rpc, item["hypothesis_id"], "STILL_MATCHES")
+ assert next(iter((await links(rpc)).values()))["state"] == "RECHECKED"
+ # a value that makes the row fail is a different kind of change
+ await change_result(rpc, "SYN-RES-SYN-C-DET-DRY", phase=1, value="0.50", numerator="10")
+ (flipped,) = (await links(rpc)).values()
+ assert (flipped["state"], flipped["change"]) == ("STALE", "FLIPPED")
+ finally:
+ runtime.close()
+
+
+async def drafted(runtime: AppRuntime, rpc: Rpc, **declaration: Any) -> tuple[dict[str, Any], str]:
+ """Press "make an action request from this test" for the one test the model proposed."""
+ (hypothesis_id,) = await hypothesis_ids(rpc)
+ params: dict[str, Any] = dict(project_id="p", hypothesis_id=hypothesis_id, test_id="test-1")
+ if declaration:
+ params["effect_declaration"] = declaration
+ done = await rpc("action/draft/fromTest", **params)
+ return done["action"], hypothesis_id
+
+
+@pytest.mark.asyncio
+async def test_a_draft_from_a_test_gets_its_risk_from_the_effect_declaration_not_from_the_model(
+ tmp_path: Path,
+) -> None:
+ model = ControlledResearchModel(one=True, tested=True)
+ runtime, rpc = await opened(tmp_path, model)
+ try:
+ await investigate(runtime, rpc, None)
+ calls = len(model.calls)
+ # the model called this test R0; nothing declares what it does, so the rules say R3
+ action, hypothesis_id = await drafted(runtime, rpc)
+ assert (action["risk_tier"], action["policy_state"]) == ("R3", "POLICY_UNDEFINED")
+ assert (
+ action["proposal_state"] == "DRAFT" and action["authorization_state"] == "NOT_PREPARED"
+ )
+ assert action["test_refs"] == [{"hypothesis_id": hypothesis_id, "test_id": "test-1"}]
+ assert action["hypothesis_refs"] == [hypothesis_id]
+ assert action["specification"]["description"] == "Compare the rain run with a dry run"
+ assert len(model.calls) == calls # a person pressed a button; no model was asked
+ read = (await rpc("action/read", project_id="p", action_id=action["action_id"]))["action"]
+ assert read["test_refs"] == action["test_refs"]
+ # a declaration that says nothing is written to the world and is complete: read only
+ read_only, _ = await drafted(
+ runtime, rpc, effect_completeness_confirmed=True, external_write=False
+ )
+ assert read_only["risk_tier"] == "R0"
+ # a declaration that writes outside is a protected action, whatever the model said
+ written, _ = await drafted(
+ runtime, rpc, effect_completeness_confirmed=True, external_write=True
+ )
+ assert (written["risk_tier"], written["policy_state"]) == ("R3", "APPROVAL_REQUIRED")
+ # a forbidden effect stays forbidden
+ banned, _ = await drafted(
+ runtime, rpc, effect_completeness_confirmed=True, grants_waiver=True
+ )
+ assert (banned["risk_tier"], banned["policy_state"]) == ("R4", "PROHIBITED")
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_a_draft_names_the_test_it_came_from_and_refuses_what_it_cannot_find(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel(one=True, tested=True))
+ try:
+ await investigate(runtime, rpc, None)
+ (hypothesis_id,) = await hypothesis_ids(rpc)
+ before = await action_count(rpc)
+ assert "TEST_NOT_FOUND" in await rpc.error(
+ "action/draft/fromTest", project_id="p", hypothesis_id=hypothesis_id, test_id="nope"
+ )
+ assert "HYPOTHESIS_NOT_FOUND" in await rpc.error(
+ "action/draft/fromTest",
+ project_id="p",
+ hypothesis_id="hypothesis:none",
+ test_id="test-1",
+ )
+ assert await action_count(rpc) == before
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_a_draft_from_a_test_of_a_stale_hypothesis_is_refused(tmp_path: Path) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel(one=True, tested=True))
+ try:
+ await investigate(runtime, rpc, RAIN)
+ (hypothesis_id,) = await hypothesis_ids(rpc)
+ params = dict(project_id="p", hypothesis_id=hypothesis_id, test_id="test-1")
+ await rpc("action/draft/fromTest", **params)
+ await rpc.rain_arrives()
+ assert CHANGED in await rpc.error("action/draft/fromTest", **params)
+ await recheck(rpc, hypothesis_id, "STILL_MATCHES")
+ await rpc("action/draft/fromTest", **params)
+ finally:
+ runtime.close()
+
+
+SCREEN_KEYS = (
+ "changes_official_kpi",
+ "grants_waiver",
+ "changes_safety_threshold",
+ "finalizes_model_weights",
+ "external_write",
+ "physical_action",
+ "changes_official_baseline",
+ "operational_equipment_change",
+ "runs_untrusted_code",
+ "sandbox_required",
+ "changes_local_draft",
+)
+
+
+@pytest.mark.asyncio
+async def test_a_declaration_as_the_screen_sends_it_is_stored_as_written_and_ruled_by_the_server(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel(one=True, tested=True))
+ try:
+ await investigate(runtime, rpc, None)
+
+ def complete(**chosen: bool) -> dict[str, bool]:
+ named = {key: chosen.get(key, False) for key in SCREEN_KEYS}
+ return {**named, "effect_completeness_confirmed": True}
+
+ # nothing ticked and the list not called complete: the rule's own protected answer
+ empty, _ = await drafted(runtime, rpc, effect_completeness_confirmed=False)
+ assert (empty["risk_tier"], empty["policy_state"]) == ("R3", "POLICY_UNDEFINED")
+ assert empty["required_roles"] == ["effect-owner"]
+ # every effect named false and the list complete: it only reads
+ none = complete()
+ read_only, _ = await drafted(runtime, rpc, **none)
+ assert (read_only["risk_tier"], read_only["policy_state"]) == ("R0", "AUTO_ALLOWED")
+ assert read_only["effect_vector"] == none
+ # the same list with a local draft, a sandbox or a physical action
+ local, _ = await drafted(runtime, rpc, **complete(changes_local_draft=True))
+ assert (local["risk_tier"], local["policy_state"]) == ("R1", "PREAUTHORIZED")
+ boxed, _ = await drafted(runtime, rpc, **complete(sandbox_required=True))
+ assert (boxed["risk_tier"], boxed["required_roles"]) == ("R2", ["sandbox-owner"])
+ physical, _ = await drafted(runtime, rpc, **complete(physical_action=True))
+ assert (physical["risk_tier"], physical["policy_state"]) == ("R3", "APPROVAL_REQUIRED")
+ assert physical["required_roles"] == ["project-owner", "safety-owner"]
+ # a forbidden effect alone, with the list not called complete, is still forbidden
+ banned, _ = await drafted(
+ runtime, rpc, changes_official_kpi=True, effect_completeness_confirmed=False
+ )
+ assert (banned["risk_tier"], banned["policy_state"]) == ("R4", "PROHIBITED")
+ assert banned["effect_vector"]["changes_official_kpi"] is True
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_test_refs_are_an_input_field_covered_by_the_digest_and_outside_the_specification(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel(one=True, tested=True))
+ try:
+ await investigate(runtime, rpc, None)
+ (hypothesis_id,) = await hypothesis_ids(rpc)
+ base = await create_on(rpc, hypothesis_id)
+ ref = {"hypothesis_id": hypothesis_id, "test_id": "test-1"}
+ other = {"hypothesis_id": hypothesis_id, "test_id": "test-2"}
+ plain = (await rpc("action/create", **base))["action"]
+ one = (await rpc("action/create", **base, test_refs=[ref]))["action"]
+ two = (await rpc("action/create", **base, test_refs=[other]))["action"]
+ assert plain["test_refs"] == [] and one["test_refs"] == [ref]
+ assert (
+ "test_refs" not in one["specification"]
+ ) # the new way keeps it out of the specification
+ # the same specification with only test_refs different is a different revision
+ assert len({plain["revision_digest"], one["revision_digest"], two["revision_digest"]}) == 3
+ read = (await rpc("action/read", project_id="p", action_id=one["action_id"]))["action"]
+ assert read["test_refs"] == [ref] and read["revision_digest"] == one["revision_digest"]
+ # a revision keeps the field and its digest still follows it
+ revised = (
+ await rpc(
+ "action/revise",
+ project_id="p",
+ action_id=one["action_id"],
+ expected_revision_digest=one["revision_digest"],
+ patch={"specification": {**base["specification"], "description": "Look once more"}},
+ evidence_refs=base["evidence_refs"],
+ reason="look again",
+ )
+ )["action"]
+ assert (
+ revised["test_refs"] == [ref] and revised["revision_digest"] != one["revision_digest"]
+ )
+ # a test_refs that is not a pair of ids is refused
+ bad = await rpc.error("action/create", **base, test_refs=[{"test_id": "x"}])
+ assert bad == "invalid method parameters"
+ # a record made the old way, with the refs inside its specification, still reads as before
+ old = await rpc(
+ "action/create",
+ **{**base, "specification": {**base["specification"], "test_refs": [ref]}},
+ )
+ assert old["action"]["test_refs"] == [ref]
+ finally:
+ runtime.close()
diff --git a/tests/integration/test_hypothesis_contract_v3_run.py b/tests/integration/test_hypothesis_contract_v3_run.py
new file mode 100644
index 0000000..a37ccb1
--- /dev/null
+++ b/tests/integration/test_hypothesis_contract_v3_run.py
@@ -0,0 +1,235 @@
+"""Hypothesis generation contract v3: off by default, the same calls when on, and a settled record.
+
+A project turns it on in its model-call settings. When on, the one generator call is shown the v3
+form and contract text, what it says about expected results and refutation conditions is stored
+beside the hypothesis (items that name an unknown hypothesis or span are dropped alone and
+recorded), and the number of model calls is the same. A controlled model plays the generator; no
+real model is called.
+"""
+
+from __future__ import annotations
+
+from dataclasses import replace
+from pathlib import Path
+from typing import Any
+
+import pytest
+from tests.integration.test_hypothesis_link import investigate
+from tests.integration.test_research_request_v2 import ControlledResearchModel
+from tests.integration.test_trace_origin import RAIN, Rpc, opened
+
+from thoth.application.services.hypothesis_projection import hypothesis_view
+from thoth.domain.enums import ModelRole
+from thoth.domain.hypothesis import HypothesisPortfolioV3, HypothesisV3
+from thoth.domain.hypothesis_full import HypothesisRecord
+
+CONTRACT_V2 = "hypothesis_portfolio.v2"
+CONTRACT_V3 = "hypothesis_portfolio.v3"
+SAID = "if both runs match, drop it"
+
+
+class GeneratorModel(ControlledResearchModel):
+ """The controlled model; when it is shown the v3 form it fills it in, with some bad ids."""
+
+ def __init__(self) -> None:
+ super().__init__(one=True, tested=True)
+
+ async def structured(self, request: Any) -> Any:
+ result = await super().structured(request)
+ if request.role != ModelRole.HYPOTHESIS_GENERATOR:
+ return result
+ if request.output_model is not HypothesisPortfolioV3:
+ return result
+ first = result.output.hypotheses[0]
+ second_id = first.hypothesis_id + ":other"
+ span = tuple(item.span_id for item in request.context_pack.evidence)[:1]
+
+ def rows(own: str, *items: tuple[str, str, tuple[str, ...]]) -> list[dict[str, Any]]:
+ return [{"hypothesis_id": h, "expected": e, "basis": list(b)} for h, e, b in items]
+
+ def v3(hypothesis_id: str, tests: list[list[dict[str, Any]]], conditions: tuple[str, ...]):
+ shaped = [
+ {**test.model_dump(), "expected_by_hypothesis": rows_for}
+ for test, rows_for in zip(first.discriminating_tests, tests, strict=True)
+ ]
+ return HypothesisV3.model_validate(
+ {
+ **first.model_dump(),
+ "hypothesis_id": hypothesis_id,
+ "discriminating_tests": shaped,
+ "refutation_conditions": conditions,
+ }
+ )
+
+ one = [
+ rows("a", (first.hypothesis_id, "worse", span), (second_id, "same", ()))
+ + rows(
+ "a", ("hypothesis:nobody", "same", ()), (second_id, "again", ())
+ ) # two bad items
+ + rows("a", (second_id + "2", "worse", ("span:made-up",))),
+ rows("a", (first.hypothesis_id, "worse", ()), (second_id, "모름", ())),
+ ]
+ portfolio = result.output.model_copy(
+ update={
+ "hypotheses": (
+ v3(first.hypothesis_id, one, (SAID,)),
+ v3(second_id, [[], []], ()),
+ )
+ }
+ )
+ return replace(result, output=portfolio)
+
+
+async def settings(rpc: Rpc) -> dict[str, Any]:
+ return await rpc("model/callSettings/read", project_id="p")
+
+
+async def turn_on(rpc: Rpc) -> None:
+ current = await settings(rpc)
+ await rpc(
+ "model/callSettings/update",
+ project_id="p",
+ hypothesis_contract_v3=True,
+ expected_digest=current["settings_digest"],
+ )
+
+
+def generator_calls(model: ControlledResearchModel) -> list[Any]:
+ return [call for call in model.calls if call.role == ModelRole.HYPOTHESIS_GENERATOR]
+
+
+async def run(tmp_path: Path, *, on: bool) -> tuple[Any, Any, Rpc, str, GeneratorModel]:
+ model = GeneratorModel()
+ runtime, rpc = await opened(tmp_path, model)
+ if on:
+ await turn_on(rpc)
+ thread_id = await investigate(runtime, rpc, RAIN)
+ return runtime, model, rpc, thread_id, model
+
+
+async def portfolio_of(rpc: Rpc, thread_id: str) -> dict[str, Any]:
+ view = await rpc("thread/read", project_id="p", thread_id=thread_id, view="FULL")
+ return view["current_result"]["result"]["portfolio"]
+
+
+@pytest.mark.asyncio
+async def test_the_switch_is_off_by_default_and_each_switch_keeps_the_other(tmp_path: Path) -> None:
+ runtime, rpc = await opened(tmp_path, GeneratorModel())
+ try:
+ first = await settings(rpc)
+ assert (first["hypothesis_contract_v3"], first["auto_retry_interrupted_model_call"]) == (
+ False,
+ False,
+ )
+ await turn_on(rpc)
+ second = await settings(rpc)
+ assert second["hypothesis_contract_v3"] is True
+ assert second["auto_retry_interrupted_model_call"] is False # left out: kept
+ await rpc(
+ "model/callSettings/update",
+ project_id="p",
+ auto_retry_interrupted_model_call=True,
+ expected_digest=second["settings_digest"],
+ )
+ third = await settings(rpc)
+ assert (third["hypothesis_contract_v3"], third["auto_retry_interrupted_model_call"]) == (
+ True,
+ True,
+ )
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_off_the_generator_sees_v2_and_nothing_of_v3_is_stored_or_shown(
+ tmp_path: Path,
+) -> None:
+ runtime, model, rpc, thread_id, _ = await run(tmp_path, on=False)
+ try:
+ (call,) = generator_calls(model)
+ assert (
+ call.prompt_version == CONTRACT_V2
+ and call.output_model.__name__ == "HypothesisPortfolio"
+ )
+ portfolio = await portfolio_of(rpc, thread_id)
+ dumped = str(portfolio)
+ assert "expected_by_hypothesis" not in dumped and "refutation_conditions" not in dumped
+ (item,) = (await rpc("hypothesis/list", project_id="p"))["hypotheses"]
+ record = (
+ await rpc("hypothesis/read", project_id="p", hypothesis_id=item["hypothesis_id"])
+ )["hypothesis"]
+ assert record["generation_details"]["contract_version"] is None
+ assert record["generation_details"]["expected_results"] == []
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_on_the_generator_sees_v3_and_what_it_says_is_stored_and_shown_with_bad_items_dropped(
+ tmp_path: Path,
+) -> None:
+ runtime, model, rpc, thread_id, _ = await run(tmp_path, on=True)
+ try:
+ (call,) = generator_calls(model)
+ assert call.prompt_version == CONTRACT_V3 and call.output_model is HypothesisPortfolioV3
+ portfolio = await portfolio_of(rpc, thread_id)
+ first, second = portfolio["hypotheses"]
+ assert first["refutation_conditions"] == [SAID] and second["refutation_conditions"] == []
+ tests = {t["test_id"]: t["expected_by_hypothesis"] for t in first["discriminating_tests"]}
+ assert [(r["hypothesis_id"], r["expected"]) for r in tests["test-1"]] == [
+ (first["hypothesis_id"], "worse"),
+ (second["hypothesis_id"], "same"),
+ ] # the two bad items are gone, the rest of the table stands
+ assert [r["expected"] for r in tests["test-2"]] == ["worse", "모름"]
+ read = (await rpc("hypothesis/read", project_id="p", hypothesis_id=first["hypothesis_id"]))[
+ "hypothesis"
+ ]
+ stored = read["generation_details"]
+ assert stored["contract_version"] == CONTRACT_V3
+ # read back from the record, the hypothesis shows what the generator said (the projection
+ # a later step, such as a counter-search, hands on)
+ viewed = hypothesis_view(HypothesisRecord.model_validate(read))
+ assert isinstance(viewed, HypothesisV3) and viewed.refutation_conditions == (SAID,)
+ assert {t.test_id: len(t.expected_by_hypothesis) for t in viewed.discriminating_tests} == {
+ "test-1": 2,
+ "test-2": 2,
+ }
+ assert stored["refutation_conditions"] == [SAID]
+ assert {e["test_id"] for e in stored["expected_results"]} == {"test-1", "test-2"}
+ assert {(d["test_id"], d["reason"]) for d in stored["dropped_expected_results"]} == {
+ ("test-1", "UNKNOWN_HYPOTHESIS"),
+ ("test-1", "DUPLICATE_HYPOTHESIS"),
+ }
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_v3_makes_the_same_number_of_model_calls_and_the_value_it_ran_with_stays(
+ tmp_path: Path,
+) -> None:
+ off_runtime, off_model, _, _, _ = await run(tmp_path / "off", on=False)
+ on_runtime, on_model, on_rpc, thread_id, _ = await run(tmp_path / "on", on=True)
+ try:
+ assert len(on_model.calls) == len(off_model.calls)
+ assert [c.role for c in on_model.calls] == [c.role for c in off_model.calls]
+ # the project switches it off afterwards: what was made stays what it was made with
+ current = await settings(on_rpc)
+ await on_rpc(
+ "model/callSettings/update",
+ project_id="p",
+ hypothesis_contract_v3=False,
+ expected_digest=current["settings_digest"],
+ )
+ first = (await portfolio_of(on_rpc, thread_id))["hypotheses"][0]
+ assert first["refutation_conditions"] == [SAID]
+ (hypothesis, *_) = (await on_rpc("hypothesis/list", project_id="p"))["hypotheses"]
+ stored = (
+ await on_rpc(
+ "hypothesis/read", project_id="p", hypothesis_id=hypothesis["hypothesis_id"]
+ )
+ )["hypothesis"]["generation_details"]
+ assert stored["contract_version"] == CONTRACT_V3
+ finally:
+ off_runtime.close()
+ on_runtime.close()
diff --git a/tests/integration/test_hypothesis_link.py b/tests/integration/test_hypothesis_link.py
new file mode 100644
index 0000000..02dc1a7
--- /dev/null
+++ b/tests/integration/test_hypothesis_link.py
@@ -0,0 +1,375 @@
+"""A hypothesis from a trace row remembers its verdict; a changed verdict makes it stale.
+
+Stale hypotheses are not used for approval or execution until a person re-checks them. A person's
+confirmation of a verdict is not a change. These tests go through the public methods.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any, cast
+
+import pytest
+from tests.integration.test_research_request_v2 import ControlledResearchModel
+from tests.integration.test_trace_origin import DRY, RAIN, Rpc, opened, origin_for
+from tests.integration.trace_demo_helpers import demo_set, with_notice
+
+from thoth.application.services.hypothesis_link_recheck import (
+ HypothesisLinkRechecks,
+ RecheckRefused,
+)
+from thoth.application.services.hypothesis_link_view import HypothesisLinkReader
+from thoth.application.services.request_records import RequestRecords
+from thoth.application.services.research_freshness import ResearchFreshnessService
+from thoth.application.services.trace_csv import export_rows, write_csv
+from thoth.apps.runtime import AppRuntime
+
+FA_RAIN = "SYN-C-FA-RAIN"
+
+
+async def investigate(runtime: AppRuntime, rpc: Rpc, row: str | None) -> str:
+ """Start a research thread (from a trace row when given) and return its thread id."""
+ extra: dict[str, Any] = {} if row is None else {"origin": await origin_for(rpc, row)}
+ started = await rpc(
+ "thread/start",
+ project_id="p",
+ problem=f"{row or 'plain'} question",
+ contract_version=2,
+ **extra,
+ )
+ await runtime.bus.drain()
+ return str(started["thread_id"])
+
+
+async def links(rpc: Rpc) -> dict[str, dict[str, Any]]:
+ listed = await rpc("hypothesis/link/list", project_id="p")
+ return {item["hypothesis_id"]: item for item in listed["links"]}
+
+
+async def change_result(rpc: Rpc, result_id: str, *, phase: int = 1, **changes: str) -> None:
+ """Update one result row of the stored trace (a new result revision), as a person would."""
+ current = (await rpc("trace/read", project_id="p"))["set_digest"]
+ rows = []
+ for row in export_rows(with_notice(demo_set(phase))):
+ row = {**row, "base_set_digest": current}
+ if row["row_type"] == "RESULT" and row["result_id"] == result_id:
+ row = {**row, "result_revision": str(int(row["result_revision"]) + 1), **changes}
+ rows.append(row)
+ await rpc.load(write_csv(rows), "UPDATE")
+
+
+async def verdict_state(rpc: Rpc, subject_id: str) -> str:
+ view = await rpc("trace/read", project_id="p")
+ return next(item["state"] for item in view["verdicts"] if item["subject_id"] == subject_id)
+
+
+@pytest.mark.asyncio
+async def test_a_hypothesis_from_a_row_is_linked_and_only_a_content_change_makes_it_stale(
+ tmp_path: Path,
+) -> None:
+ model = ControlledResearchModel(one=True)
+ runtime, rpc = await opened(tmp_path, model)
+ try:
+ await investigate(runtime, rpc, None) # an ordinary question: its hypothesis has no link
+ assert await links(rpc) == {}
+ await investigate(runtime, rpc, RAIN)
+ (item,) = (await links(rpc)).values()
+ assert (item["subject_id"], item["state"], item["change"]) == (RAIN, "CURRENT", None)
+ assert item["link_state"] == "HOLD_NO_RESULT"
+ # a person's confirmation is stored apart from the verdict: it is not a change
+ view = await rpc("trace/read", project_id="p")
+ rain = next(v for v in view["verdicts"] if v["subject_id"] == RAIN)
+ await rpc(
+ "trace/confirm",
+ project_id="p",
+ verdict_revision_digest=rain["revision_digest"],
+ rationale="looked at it",
+ expected_digest=view["record_digest"],
+ )
+ assert next(iter((await links(rpc)).values()))["state"] == "CURRENT"
+ # the rain results arrive: what the verdict says changed
+ await rpc.rain_arrives()
+ (stale,) = (await links(rpc)).values()
+ assert (stale["state"], stale["change"]) == ("STALE", "CONTENT_CHANGED")
+ assert (stale["link_state"], stale["current_state"]) == ("HOLD_NO_RESULT", "FAIL_COMPUTED")
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_a_met_row_that_turns_failed_is_told_apart_as_a_flip(tmp_path: Path) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel(one=True))
+ try:
+ await investigate(runtime, rpc, DRY)
+ assert await verdict_state(rpc, DRY) == "PASS_COMPUTED"
+ await change_result(rpc, "SYN-RES-SYN-C-DET-DRY", value="0.50", numerator="10")
+ assert await verdict_state(rpc, DRY) == "FAIL_COMPUTED"
+ (item,) = (await links(rpc)).values()
+ assert (item["state"], item["change"]) == ("STALE", "FLIPPED")
+ assert (item["link_state"], item["current_state"]) == ("PASS_COMPUTED", "FAIL_COMPUTED")
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_a_recheck_is_a_person_a_reason_and_an_event_and_never_overwrites(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel(one=True))
+ try:
+ first, second = await investigate(runtime, rpc, RAIN), await investigate(runtime, rpc, RAIN)
+ other = await investigate(runtime, rpc, DRY)
+ await investigate(runtime, rpc, FA_RAIN)
+ await rpc.rain_arrives()
+ found = await links(rpc)
+ assert len(found) == 4
+ rain_ids = [key for key, item in found.items() if item["subject_id"] == RAIN]
+ dry_id = next(key for key, item in found.items() if item["subject_id"] == DRY)
+ fa_id = next(key for key, item in found.items() if item["subject_id"] == FA_RAIN)
+ assert first != second != other
+ seen = found[rain_ids[0]]["current_verdict_revision"]
+ refused = {
+ "HYPOTHESIS_LINK_NOT_STALE": dict(
+ hypothesis_ids=[dry_id],
+ current_verdict_revision=found[dry_id]["current_verdict_revision"],
+ ),
+ "HYPOTHESIS_LINK_MISSING": dict(
+ hypothesis_ids=["hypothesis:nothing"], current_verdict_revision=seen
+ ),
+ "HYPOTHESIS_LINKS_DIFFER": dict(
+ hypothesis_ids=[*rain_ids, fa_id], current_verdict_revision=seen
+ ),
+ "LINK_VERDICT_CHANGED_AGAIN": dict(
+ hypothesis_ids=rain_ids, current_verdict_revision="0" * 64
+ ),
+ }
+ for reason, params in refused.items():
+ assert reason in await rpc.error(
+ "hypothesis/link/recheck", project_id="p", reason_code="UNRELATED", **params
+ )
+ assert all(
+ item["state"] == "STALE" for key, item in (await links(rpc)).items() if key in rain_ids
+ )
+ # "other" needs words
+ assert "RECHECK_NOTE_REQUIRED" in await rpc.error(
+ "hypothesis/link/recheck",
+ project_id="p",
+ hypothesis_ids=rain_ids,
+ reason_code="OTHER",
+ current_verdict_revision=seen,
+ )
+ # a reason asking for more research is recorded and leaves the hypotheses stale
+ done = await rpc(
+ "hypothesis/link/recheck",
+ project_id="p",
+ hypothesis_ids=rain_ids,
+ reason_code="NEEDS_RESEARCH",
+ current_verdict_revision=seen,
+ )
+ assert (
+ len(done["events"]) == 2 and len({event["batch_id"] for event in done["events"]}) == 1
+ )
+ assert all(
+ item["state"] == "STALE" and item["recheck"]["reason_code"] == "NEEDS_RESEARCH"
+ for item in done["links"]
+ )
+ # both are kept in one call, for one change
+ kept = await rpc(
+ "hypothesis/link/recheck",
+ project_id="p",
+ hypothesis_ids=rain_ids,
+ reason_code="STILL_MATCHES",
+ current_verdict_revision=seen,
+ )
+ assert all(
+ item["state"] == "RECHECKED" and item["recheck"]["reason_code"] == "STILL_MATCHES"
+ for item in kept["links"]
+ )
+ assert all(event["actor_id"] == "human:local-user" for event in kept["events"])
+ assert kept["reason_distribution"] == {
+ "total": 4,
+ "flipped": 0,
+ "by_reason": {"NEEDS_RESEARCH": 2, "STILL_MATCHES": 2},
+ }
+ # nothing was overwritten: all four events are still there, oldest first
+ _, events = HypothesisLinkReader(runtime.ledger).rechecks("p")
+ assert [event.reason_code for event in events] == ["NEEDS_RESEARCH"] * 2 + [
+ "STILL_MATCHES"
+ ] * 2
+ # the verdict changes again: the kept link is stale again until someone looks at that change
+ await change_result(rpc, "SYN-RES-SYN-C-DET-RAIN", phase=2, value="0.40", numerator="8")
+ again = await links(rpc)
+ assert all(again[key]["state"] == "STALE" for key in rain_ids)
+ assert again[rain_ids[0]]["current_verdict_revision"] != seen
+ # extra fields (an actor the caller names) are not accepted
+ message = await rpc.error(
+ "hypothesis/link/recheck",
+ project_id="p",
+ hypothesis_ids=rain_ids,
+ reason_code="UNRELATED",
+ current_verdict_revision=again[rain_ids[0]]["current_verdict_revision"],
+ actor_id="model:x",
+ )
+ assert message == "invalid method parameters"
+ _, events = HypothesisLinkReader(runtime.ledger).rechecks("p")
+ assert len(events) == 4 # the refused call wrote nothing
+ assert dry_id in again
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_a_flipped_link_is_kept_only_with_words_and_only_a_person_can_recheck(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel(one=True))
+ try:
+ await investigate(runtime, rpc, DRY)
+ await change_result(rpc, "SYN-RES-SYN-C-DET-DRY", value="0.50", numerator="10")
+ (item,) = (await links(rpc)).values()
+ params = dict(
+ project_id="p",
+ hypothesis_ids=[item["hypothesis_id"]],
+ current_verdict_revision=item["current_verdict_revision"],
+ )
+ for reason in ("UNRELATED", "STILL_MATCHES", "OTHER"):
+ assert "RECHECK_NOTE_REQUIRED" in await rpc.error(
+ "hypothesis/link/recheck", reason_code=reason, **params
+ )
+ # asking for more research on a flip needs no words
+ await rpc("hypothesis/link/recheck", reason_code="NEEDS_RESEARCH", **params)
+ done = await rpc(
+ "hypothesis/link/recheck",
+ reason_code="UNRELATED",
+ note="the dry run is a different rig",
+ **params,
+ )
+ assert done["links"][0]["state"] == "RECHECKED"
+ assert (
+ done["events"][0]["flipped"] is True
+ and done["events"][0]["note"] == "the dry run is a different rig"
+ )
+ assert done["reason_distribution"]["flipped"] == 2
+ # a model, an agent or a system is not a person
+ # the actor is checked before anything is read or written
+ checker = HypothesisLinkRechecks(
+ cast(RequestRecords, SimpleNamespace(ledger=runtime.ledger))
+ )
+ for actor in ("model:gpt", "agent:research", "system:verification-trace", ""):
+ with pytest.raises(RecheckRefused, match="RECHECK_HUMAN_ONLY"):
+ checker.recheck(
+ project_id="p",
+ hypothesis_ids=(item["hypothesis_id"],),
+ reason="UNRELATED",
+ note="x",
+ actor_id=actor,
+ current_verdict_revision=item["current_verdict_revision"],
+ )
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_a_stale_hypothesis_keeps_its_actions_out_of_approval_and_execution_until_rechecked(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel(one=True))
+ try:
+ await investigate(
+ runtime, rpc, None
+ ) # an action whose hypothesis has no link: never held back
+ linked_thread = await investigate(runtime, rpc, RAIN)
+ actions = (await rpc("action/list", project_id="p"))["actions"]
+ assert len(actions) == 4
+ plans = {}
+ for action in actions:
+ read = await rpc("action/read", project_id="p", action_id=action["action_id"])
+ plans[action["action_id"]] = read["action"]
+ (hypothesis_id,) = (await links(rpc)).keys()
+ by_link = {
+ key: value for key, value in plans.items() if hypothesis_id in value["hypothesis_refs"]
+ }
+ free = {key: value for key, value in plans.items() if key not in by_link}
+ assert by_link and free and linked_thread
+
+ freshness = ResearchFreshnessService(runtime.ledger)
+
+ def eligible(action: dict[str, Any]) -> bool:
+ try:
+ freshness.require_action_eligible(
+ "p", f"ACTION:{action['action_id']}", action["revision_digest"]
+ )
+ except ValueError as exc:
+ assert str(exc) == "HYPOTHESIS_BASIS_CHANGED"
+ return False
+ return True
+
+ assert all(eligible(action) for action in plans.values()) # the verdict has not changed
+ await rpc.rain_arrives()
+ assert not any(eligible(action) for action in by_link.values()) # stale: held back
+ assert all(eligible(action) for action in free.values()) # no link: not a false rejection
+ item = next(iter((await links(rpc)).values()))
+ params = dict(
+ project_id="p",
+ hypothesis_ids=[hypothesis_id],
+ current_verdict_revision=item["current_verdict_revision"],
+ )
+ await rpc("hypothesis/link/recheck", reason_code="NEEDS_RESEARCH", **params)
+ assert not any(
+ eligible(action) for action in by_link.values()
+ ) # asking for research does not release
+ await rpc("hypothesis/link/recheck", reason_code="STILL_MATCHES", **params)
+ assert all(eligible(action) for action in plans.values()) # a person looked at this change
+ await change_result(rpc, "SYN-RES-SYN-C-DET-RAIN", phase=2, value="0.40", numerator="8")
+ assert not any(eligible(action) for action in by_link.values()) # and it changed again
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_preparing_an_approval_is_refused_for_a_plan_on_a_changed_verdict_and_not_otherwise(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel(one=True))
+ try:
+ await investigate(runtime, rpc, RAIN)
+ (hypothesis,) = (await links(rpc)).values()
+ action = (await rpc("action/list", project_id="p"))["actions"][0]
+ plan = (
+ await rpc("action/plan/read", project_id="p", plan_id=f"plan:{action['object_id']}")
+ )["plan"]
+
+ async def prepare() -> str:
+ params = dict(
+ project_id="p",
+ plan_id=plan["plan_id"],
+ step_id=plan["steps"][0]["step_id"],
+ plan_revision_digest=plan["revision_digest"],
+ predecessor_output_digests=[],
+ target_baseline_digests=[],
+ policy_version="project-policy:current",
+ )
+ response = await rpc.raw("action/authorization/prepare", **params)
+ return (
+ response.error.message if response.error else str(response.result["value"]["state"])
+ )
+
+ assert (
+ await prepare() == "NOT_REQUIRED"
+ ) # the verdict has not changed: nothing is held back
+ await rpc.rain_arrives()
+ assert await prepare() == "HYPOTHESIS_BASIS_CHANGED"
+ params = dict(
+ project_id="p",
+ hypothesis_ids=[hypothesis["hypothesis_id"]],
+ current_verdict_revision=(await links(rpc))[hypothesis["hypothesis_id"]][
+ "current_verdict_revision"
+ ],
+ )
+ await rpc("hypothesis/link/recheck", reason_code="NEEDS_RESEARCH", **params)
+ assert await prepare() == "HYPOTHESIS_BASIS_CHANGED"
+ await rpc("hypothesis/link/recheck", reason_code="UNRELATED", **params)
+ assert await prepare() == "NOT_REQUIRED" # a person looked at this very change
+ finally:
+ runtime.close()
diff --git a/tests/integration/test_hypothesis_same.py b/tests/integration/test_hypothesis_same.py
new file mode 100644
index 0000000..2cad482
--- /dev/null
+++ b/tests/integration/test_hypothesis_same.py
@@ -0,0 +1,201 @@
+"""A person marks hypotheses of different investigations as the same, and takes the mark back.
+
+Asking about the same trace row again makes new hypothesis ids. A mark is only a reference: it
+widens when a lesson counts as refuted and shows the earlier result beside the new hypothesis, and
+it never merges the elimination, the standing or the order of tests. No model is called.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any, cast
+
+import pytest
+from tests.integration.test_hypothesis_link import investigate
+from tests.integration.test_judgment_records import listed, record, stored
+from tests.integration.test_research_request_v2 import ControlledResearchModel
+from tests.integration.test_trace_origin import RAIN, Rpc, opened
+
+from thoth.application.services.hypothesis_same import KEY as SAME_KEY
+from thoth.application.services.hypothesis_same import HypothesisSame
+from thoth.application.services.request_records import RequestRecords
+
+
+async def ids(rpc: Rpc) -> list[str]:
+ found = (await rpc("hypothesis/list", project_id="p"))["hypotheses"]
+ return [item["hypothesis_id"] for item in found]
+
+
+async def new_one(runtime: Any, rpc: Rpc, row: str | None) -> str:
+ before = set(await ids(rpc))
+ await investigate(runtime, rpc, row)
+ (fresh,) = set(await ids(rpc)) - before
+ return fresh
+
+
+async def mark(rpc: Rpc, first: str, second: str, action: str = "LINK", **extra: Any) -> Any:
+ return await rpc(
+ "hypothesis/same/record",
+ project_id="p",
+ hypothesis_ids=[first, second],
+ action=action,
+ **extra,
+ )
+
+
+async def lessons(rpc: Rpc) -> list[dict[str, Any]]:
+ return (await rpc("trace/lesson/list", project_id="p"))["lessons"]
+
+
+def state_of(found: list[dict[str, Any]], hypothesis_id: str, outcome: str) -> str:
+ (item,) = [
+ x
+ for x in found
+ if x["hypothesis_id"] == hypothesis_id
+ and x["kind"] == "TEST_RESULT"
+ and x["outcome"] == outcome
+ ]
+ return str(item["state"])
+
+
+@pytest.mark.asyncio
+async def test_two_investigations_of_one_row_are_marked_the_same_and_the_mark_can_be_taken_back(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel(one=True, tested=True))
+ try:
+ first = await new_one(runtime, rpc, RAIN)
+ second = await new_one(runtime, rpc, RAIN)
+ assert first != second
+ assert (await rpc("hypothesis/same/list", project_id="p"))["groups"] == []
+ done = await mark(rpc, second, first, note="같은 줄을 다시 조사함") # order does not matter
+ assert done["groups"] == [sorted([first, second])]
+ assert done["event"]["hypothesis_ids"] == sorted([first, second])
+ assert done["event"]["actor_id"] == "human:local-user" and done["event"]["note"]
+ member = done["members"][first]
+ assert member["subject_id"] == RAIN and member["statement"] and member["investigated_at"]
+ read = await rpc("hypothesis/same/list", project_id="p")
+ assert read["groups"] == done["groups"] and len(read["events"]) == 1
+ # taking it back is another event; nothing is removed
+ ended = await mark(rpc, first, second, "UNLINK")
+ assert ended["groups"] == [] and len(ended["events"]) == 2
+ assert [e["action"] for e in stored(runtime, SAME_KEY)["events"]] == ["LINK", "UNLINK"]
+ again = await mark(rpc, first, second)
+ assert again["groups"] == [sorted([first, second])]
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_a_mark_is_refused_with_its_reason_and_a_refusal_writes_nothing(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel(one=True, tested=True))
+ try:
+ first = await new_one(runtime, rpc, RAIN)
+ second = await new_one(runtime, rpc, RAIN)
+ plain = await new_one(runtime, rpc, None) # an ordinary question: no trace row behind it
+ refused = {
+ "SAME_IDENTICAL": (first, first, "LINK"),
+ "SAME_NOT_FOUND": (first, "hypothesis:none", "LINK"),
+ "SAME_NOT_INVESTIGATION": (first, plain, "LINK"),
+ "SAME_NOT_LINKED": (first, second, "UNLINK"),
+ }
+ for reason, (a, b, action) in refused.items():
+ message = await rpc.error(
+ "hypothesis/same/record", project_id="p", hypothesis_ids=[a, b], action=action
+ )
+ assert reason in message, (reason, message)
+ await mark(rpc, first, second)
+ message = await rpc.error(
+ "hypothesis/same/record", project_id="p", hypothesis_ids=[second, first], action="LINK"
+ )
+ assert "SAME_ALREADY_LINKED" in message
+ assert (
+ await rpc.error(
+ "hypothesis/same/record", project_id="p", hypothesis_ids=[first], action="LINK"
+ )
+ == "invalid method parameters"
+ )
+ assert len((await rpc("hypothesis/same/list", project_id="p"))["events"]) == 1
+ service = HypothesisSame(cast(RequestRecords, SimpleNamespace(ledger=runtime.ledger)))
+ for actor in ("model:gpt", "agent:research", "system:verification-trace", ""):
+ with pytest.raises(PermissionError, match="RECORD_HUMAN_ONLY"):
+ service.record(
+ project_id="p",
+ hypothesis_ids=(first, second),
+ action="UNLINK",
+ note="",
+ actor_id=actor,
+ )
+ assert len((await rpc("hypothesis/same/list", project_id="p"))["events"]) == 1
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_three_investigations_make_one_group_and_one_unlink_can_split_it(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel(one=True, tested=True))
+ try:
+ a = await new_one(runtime, rpc, RAIN)
+ b = await new_one(runtime, rpc, RAIN)
+ c = await new_one(runtime, rpc, RAIN)
+ await mark(rpc, a, b)
+ grouped = await mark(rpc, b, c)
+ assert grouped["groups"] == [sorted([a, b, c])]
+ # the marks themselves are the pairs; a and c are in one group without a mark between them
+ assert sorted(map(sorted, grouped["pairs"])) == sorted([sorted([a, b]), sorted([b, c])])
+ split = await mark(rpc, b, c, "UNLINK")
+ assert split["groups"] == [sorted([a, b])]
+ assert split["pairs"] == [sorted([a, b])]
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_a_lesson_is_refuted_across_a_marked_group_only_and_counts_stay_per_hypothesis(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel(one=True, tested=True))
+ try:
+ old = await new_one(runtime, rpc, RAIN)
+ new = await new_one(runtime, rpc, RAIN)
+ other = await new_one(runtime, rpc, RAIN) # never marked
+ await record(rpc, old, "test-1", "THIS_HYPOTHESIS")
+ await record(rpc, other, "test-1", "THIS_HYPOTHESIS")
+ await record(rpc, new, "test-1", "ALTERNATIVE") # a later, opposite result of the new one
+ # no mark yet: three separate hypotheses, so nothing is refuted
+ found = await lessons(rpc)
+ assert state_of(found, old, "THIS_HYPOTHESIS") == "SAME_CONDITION"
+ assert not any("same_hypothesis_ids" in item for item in found)
+ await mark(rpc, old, new)
+ found = await lessons(rpc)
+ assert state_of(found, old, "THIS_HYPOTHESIS") == "REFUTED" # the group says the opposite
+ assert state_of(found, other, "THIS_HYPOTHESIS") == "SAME_CONDITION" # outside the group
+ assert state_of(found, new, "ALTERNATIVE") == "SAME_CONDITION"
+ marked = {
+ item["hypothesis_id"]: item["same_hypothesis_ids"]
+ for item in found
+ if "same_hypothesis_ids" in item
+ }
+ assert marked == {old: [new], new: [old]}
+ # counts, elimination and standing are read per hypothesis id, never added across the group
+ items = {item["hypothesis_id"]: item for item in await listed(rpc)}
+ assert (items[old]["elimination"], items[old]["standing"]) == (None, "FITS")
+ assert (items[new]["elimination"], items[new]["standing"]) == ("SINGLE", "AGAINST_ONCE")
+ # taking the mark back puts the lesson as it was
+ await mark(rpc, old, new, "UNLINK")
+ found = await lessons(rpc)
+ assert state_of(found, old, "THIS_HYPOTHESIS") == "SAME_CONDITION"
+ assert not any("same_hypothesis_ids" in item for item in found)
+ # with the mark again and a second result against the old one, each keeps its own count
+ await mark(rpc, old, new)
+ await record(rpc, old, "test-2", "ALTERNATIVE")
+ items = {item["hypothesis_id"]: item for item in await listed(rpc)}
+ assert items[old]["elimination"] == "SINGLE" and items[new]["elimination"] == "SINGLE"
+ assert (items[old]["standing"], items[new]["standing"]) == ("MIXED", "AGAINST_ONCE")
+ finally:
+ runtime.close()
diff --git a/tests/integration/test_judgment_records.py b/tests/integration/test_judgment_records.py
new file mode 100644
index 0000000..58ee7fa
--- /dev/null
+++ b/tests/integration/test_judgment_records.py
@@ -0,0 +1,408 @@
+"""A person's records about test results, refutation conditions and closed trace rows.
+
+They are events added beside what the model and the rules produced: the hypothesis is not edited,
+a verdict is never overwritten, nothing is deleted, and no model is called. These tests go through
+the public methods; the human-only rule is checked on the services, which take the actor.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any, cast
+
+import pytest
+from tests.integration.test_hypothesis_link import change_result, investigate, verdict_state
+from tests.integration.test_research_request_v2 import ControlledResearchModel
+from tests.integration.test_trace_origin import DRY, FOG, RAIN, Rpc, opened
+
+from thoth.application.services.discrimination_ledger import KEY as DISCRIMINATION_KEY
+from thoth.application.services.discrimination_ledger import DiscriminationLedger
+from thoth.application.services.hypothesis_link_view import HypothesisLinkReader
+from thoth.application.services.request_records import RequestRecords
+from thoth.application.services.trace_closure import KEY as CLOSURE_KEY
+from thoth.application.services.trace_closure import TraceClosures
+from thoth.domain.enums import EntityType
+from thoth.domain.verification_trace import SubjectKind
+
+OBSERVED = "the rain run matched the dry run"
+NOT_PEOPLE = ("model:gpt", "agent:research", "system:verification-trace", "")
+
+
+async def only_hypothesis(rpc: Rpc) -> str:
+ (found,) = (await rpc("hypothesis/list", project_id="p"))["hypotheses"]
+ return found["hypothesis_id"]
+
+
+async def record(
+ rpc: Rpc, hypothesis_id: str, test_id: str, matched: str, **extra: Any
+) -> dict[str, Any]:
+ return await rpc(
+ "hypothesis/test/result/record",
+ project_id="p",
+ hypothesis_id=hypothesis_id,
+ test_id=test_id,
+ observation=extra.pop("observation", OBSERVED),
+ matched=matched,
+ **extra,
+ )
+
+
+async def listed(rpc: Rpc) -> list[dict[str, Any]]:
+ return (await rpc("hypothesis/test/result/list", project_id="p"))["items"]
+
+
+def stored(runtime: Any, key: str) -> dict[str, Any]:
+ content = HypothesisLinkReader(runtime.ledger).content("p", EntityType.THREAD, key)
+ assert content is not None
+ return content
+
+
+@pytest.mark.asyncio
+async def test_a_result_that_fits_the_alternative_eliminates_once_and_two_tests_make_it_repeated(
+ tmp_path: Path,
+) -> None:
+ model = ControlledResearchModel(one=True, tested=True)
+ runtime, rpc = await opened(tmp_path, model)
+ try:
+ await investigate(runtime, rpc, None)
+ hypothesis_id = await only_hypothesis(rpc)
+ digest = (await rpc("hypothesis/read", project_id="p", hypothesis_id=hypothesis_id))[
+ "hypothesis"
+ ]["revision_digest"]
+ calls = len(model.calls)
+ assert await listed(rpc) == [] # nothing recorded yet: nothing to show
+ first = await record(
+ rpc, hypothesis_id, "test-1", "ALTERNATIVE", evidence_refs=["doc://run-7", " "]
+ )
+ (item,) = first["items"]
+ assert (item["elimination"], item["result_history_count"]) == ("SINGLE", 1)
+ assert item["results"][0]["evidence_refs"] == ["doc://run-7"]
+ assert first["result"]["actor_id"] == "human:local-user"
+ # a corrected result replaces the earlier one in what counts, and the earlier one stays
+ (item,) = (await record(rpc, hypothesis_id, "test-1", "THIS_HYPOTHESIS"))["items"]
+ assert (item["elimination"], item["result_history_count"]) == (None, 2)
+ assert [r["matched"] for r in item["results"]] == ["THIS_HYPOTHESIS"]
+ await record(rpc, hypothesis_id, "test-1", "ALTERNATIVE")
+ (item,) = (await record(rpc, hypothesis_id, "test-2", "ALTERNATIVE"))["items"]
+ assert (item["elimination"], item["result_history_count"]) == ("REPEATED", 4)
+ assert [e["matched"] for e in stored(runtime, DISCRIMINATION_KEY)["results"]] == [
+ "ALTERNATIVE",
+ "THIS_HYPOTHESIS",
+ "ALTERNATIVE",
+ "ALTERNATIVE",
+ ]
+ assert len(model.calls) == calls # a person wrote this; no model was asked
+ after = await rpc("hypothesis/read", project_id="p", hypothesis_id=hypothesis_id)
+ assert after["hypothesis"]["revision_digest"] == digest # the hypothesis is not edited
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_a_result_needs_a_real_hypothesis_test_and_words_and_a_refused_one_writes_nothing(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel(one=True, tested=True))
+ try:
+ await investigate(runtime, rpc, None)
+ hypothesis_id = await only_hypothesis(rpc)
+ refused = {
+ "TEST_NOT_FOUND": (hypothesis_id, "nope", OBSERVED, "NEITHER"),
+ "HYPOTHESIS_NOT_FOUND": ("hypothesis:none", "test-1", OBSERVED, "NEITHER"),
+ "OBSERVATION_REQUIRED": (hypothesis_id, "test-1", " ", "NEITHER"),
+ }
+ for reason, (hid, test, words, matched) in refused.items():
+ message = await rpc.error(
+ "hypothesis/test/result/record",
+ project_id="p",
+ hypothesis_id=hid,
+ test_id=test,
+ observation=words,
+ matched=matched,
+ )
+ assert reason in message
+ assert (
+ await rpc.error(
+ "hypothesis/test/result/record",
+ project_id="p",
+ hypothesis_id=hypothesis_id,
+ test_id="test-1",
+ observation=OBSERVED,
+ matched="MAYBE",
+ )
+ == "invalid method parameters"
+ )
+ assert await listed(rpc) == []
+ ledger = DiscriminationLedger(cast(RequestRecords, SimpleNamespace(ledger=runtime.ledger)))
+ for actor in NOT_PEOPLE:
+ with pytest.raises(PermissionError, match="RECORD_HUMAN_ONLY"):
+ ledger.record_result(
+ project_id="p",
+ hypothesis_id=hypothesis_id,
+ test_id="test-1",
+ observation=OBSERVED,
+ matched="NEITHER",
+ evidence_refs=(),
+ actor_id=actor,
+ )
+ with pytest.raises(PermissionError, match="RECORD_HUMAN_ONLY"):
+ ledger.record_conditions(
+ project_id="p", hypothesis_id=hypothesis_id, conditions=("x",), actor_id=actor
+ )
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_refutation_conditions_are_a_list_a_person_writes_and_each_write_is_kept(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel(one=True, tested=True))
+ try:
+ await investigate(runtime, rpc, None)
+ hypothesis_id = await only_hypothesis(rpc)
+
+ async def write(*conditions: str) -> dict[str, Any]:
+ done = await rpc(
+ "hypothesis/refutation/record",
+ project_id="p",
+ hypothesis_id=hypothesis_id,
+ conditions=list(conditions),
+ )
+ return done["items"][0]
+
+ item = await write("the rain run is no worse than the dry run", "the other sensor agrees")
+ assert item["refutation_conditions"] == [
+ "the rain run is no worse than the dry run",
+ "the other sensor agrees",
+ ]
+ item = await write("only the first")
+ assert (item["refutation_conditions"], item["conditions_history_count"]) == (
+ ["only the first"],
+ 2,
+ )
+ item = await write() # clearing is a record too
+ assert (item["refutation_conditions"], item["conditions_history_count"]) == ([], 3)
+ assert len(stored(runtime, DISCRIMINATION_KEY)["conditions"]) == 3
+ assert "CONDITION_BLANK" in await rpc.error(
+ "hypothesis/refutation/record",
+ project_id="p",
+ hypothesis_id=hypothesis_id,
+ conditions=["fine", " "],
+ )
+ assert "HYPOTHESIS_NOT_FOUND" in await rpc.error(
+ "hypothesis/refutation/record",
+ project_id="p",
+ hypothesis_id="hypothesis:none",
+ conditions=["x"],
+ )
+ assert (
+ await rpc.error(
+ "hypothesis/refutation/record",
+ project_id="p",
+ hypothesis_id=hypothesis_id,
+ conditions=[f"c{i}" for i in range(11)],
+ )
+ == "invalid method parameters"
+ )
+ assert len(stored(runtime, DISCRIMINATION_KEY)["conditions"]) == 3 # refusals wrote nothing
+ finally:
+ runtime.close()
+
+
+async def verdict_of(rpc: Rpc, subject_id: str) -> dict[str, Any]:
+ view = await rpc("trace/read", project_id="p")
+ return next(item for item in view["verdicts"] if item["subject_id"] == subject_id)
+
+
+async def close(rpc: Rpc, subject_id: str, kind: str, **extra: Any) -> dict[str, Any]:
+ params: dict[str, Any] = dict(
+ project_id="p",
+ subject_kind="CRITERION",
+ subject_id=subject_id,
+ kind=kind,
+ basis_ref="ECN-12",
+ current_verdict_revision=(await verdict_of(rpc, subject_id))["revision_digest"],
+ )
+ return await rpc("trace/closure/record", **{**params, **extra})
+
+
+async def closures(rpc: Rpc) -> dict[str, dict[str, Any]]:
+ found = (await rpc("trace/closure/list", project_id="p"))["closures"]
+ return {item["subject_id"]: item for item in found}
+
+
+@pytest.mark.asyncio
+async def test_a_person_records_a_closure_with_its_document_and_the_verdict_is_not_touched(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel())
+ try:
+ assert await closures(rpc) == {}
+ before = await verdict_of(rpc, FOG)
+ done = await close(rpc, FOG, "FIX_APPLIED", note="wiring replaced")
+ (row,) = done["closures"]
+ assert (row["subject_id"], row["current_state"]) == (FOG, "HOLD_NO_RESULT")
+ assert (row["effect_confirmed"], row["verdict_changed_since"]) == (False, False)
+ (event,) = row["events"]
+ assert (event["kind"], event["basis_ref"], event["verdict_state"]) == (
+ "FIX_APPLIED",
+ "ECN-12",
+ "HOLD_NO_RESULT",
+ )
+ assert event["actor_id"] == "human:local-user"
+ after = await verdict_of(rpc, FOG) # the rule's own word is exactly what it was
+ assert (after["revision_digest"], after["state"]) == (
+ before["revision_digest"],
+ before["state"],
+ )
+ # the other three kinds are recorded too, in order; only a changed condition has a scope
+ await close(rpc, FOG, "HUMAN_CLOSED", basis_ref="minutes-2026-10-06")
+ await close(rpc, FOG, "WAIVER_RECORDED", basis_ref="deviation DEV-4")
+ last = await close(rpc, FOG, "CONDITION_CHANGED", scope="fog below 50 m", basis_ref="OPS-9")
+ events = last["closures"][0]["events"]
+ assert [e["kind"] for e in events] == [
+ "FIX_APPLIED",
+ "HUMAN_CLOSED",
+ "WAIVER_RECORDED",
+ "CONDITION_CHANGED",
+ ]
+ assert [e["scope"] for e in events] == [None, None, None, "fog below 50 m"]
+ assert len(stored(runtime, CLOSURE_KEY)["events"]) == 4
+ assert (await verdict_of(rpc, FOG))["revision_digest"] == before["revision_digest"]
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_a_closure_needs_its_document_a_row_that_is_not_met_and_a_verdict_still_current(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel())
+ try:
+ revision = (await verdict_of(rpc, FOG))["revision_digest"]
+ base: dict[str, Any] = dict(
+ project_id="p",
+ subject_kind="CRITERION",
+ subject_id=FOG,
+ kind="FIX_APPLIED",
+ basis_ref="ECN-12",
+ current_verdict_revision=revision,
+ )
+ refused = {
+ "CLOSURE_BASIS_REQUIRED": dict(basis_ref=" "),
+ "CLOSURE_SCOPE_REQUIRED": dict(kind="CONDITION_CHANGED"),
+ "CLOSURE_ROW_NOT_FOUND": dict(subject_id="NO-SUCH-ROW"),
+ "CLOSURE_ROW_ALREADY_MET": dict(
+ subject_id=DRY,
+ current_verdict_revision=(await verdict_of(rpc, DRY))["revision_digest"],
+ ),
+ "CLOSURE_VERDICT_CHANGED_AGAIN": dict(current_verdict_revision="0" * 64),
+ }
+ for reason, patch in refused.items():
+ assert reason in await rpc.error("trace/closure/record", **{**base, **patch})
+ for patch in (dict(basis_ref=""), dict(kind="EFFECT_CONFIRMED"), dict(kind="WAIVED")):
+ assert (
+ await rpc.error("trace/closure/record", **{**base, **patch})
+ == "invalid method parameters"
+ )
+ assert await closures(rpc) == {} # nothing was written
+ checker = TraceClosures(cast(RequestRecords, SimpleNamespace(ledger=runtime.ledger)))
+ for actor in NOT_PEOPLE:
+ with pytest.raises(PermissionError, match="RECORD_HUMAN_ONLY"):
+ checker.record(
+ project_id="p",
+ subject_kind=SubjectKind.CRITERION,
+ subject_id=FOG,
+ kind="FIX_APPLIED",
+ basis_ref="ECN-12",
+ note="",
+ scope=None,
+ current_verdict_revision=revision,
+ actor_id=actor,
+ )
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_the_effect_is_confirmed_only_by_a_met_verdict_on_new_results_after_the_fix(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel())
+ try:
+ await rpc.rain_arrives() # the rain row goes from held to failed: a change before any fix
+ assert await verdict_state(rpc, RAIN) == "FAIL_COMPUTED"
+ await close(rpc, RAIN, "FIX_APPLIED")
+ (row,) = (await closures(rpc)).values()
+ assert row["effect_confirmed"] is False # that change was before the fix
+ # new results arrive after the fix and the row is met
+ await change_result(rpc, "SYN-RES-SYN-C-DET-RAIN", phase=2, value="0.95", numerator="19")
+ assert await verdict_state(rpc, RAIN) == "PASS_COMPUTED"
+ row = (await closures(rpc))[RAIN]
+ assert row["effect_confirmed"] is True
+ assert (row["current_state"], row["verdict_changed_since"]) == ("PASS_COMPUTED", True)
+ assert row["events"][0]["verdict_state"] == "FAIL_COMPUTED" # what it was when recorded
+ assert row["effect_verdict_revision"] == (await verdict_of(rpc, RAIN))["revision_digest"]
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_a_waiver_on_record_leaves_the_failed_verdict_as_it_was(tmp_path: Path) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel())
+ try:
+ await rpc.rain_arrives()
+ before = await verdict_of(rpc, RAIN)
+ await close(rpc, RAIN, "WAIVER_RECORDED", basis_ref="deviation DEV-4")
+ row = (await closures(rpc))[RAIN]
+ assert (row["effect_confirmed"], row["current_state"]) == (False, "FAIL_COMPUTED")
+ after = await verdict_of(rpc, RAIN)
+ assert (after["state"], after["revision_digest"]) == (
+ "FAIL_COMPUTED",
+ before["revision_digest"],
+ ) # the original not-met verdict is untouched
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_the_listed_standing_follows_the_results_and_the_sealed_appraisal_is_not_touched(
+ tmp_path: Path,
+) -> None:
+ model = ControlledResearchModel(one=True, tested=True)
+ runtime, rpc = await opened(tmp_path, model)
+ try:
+ await investigate(runtime, rpc, None)
+ hypothesis_id = await only_hypothesis(rpc)
+ before = (await rpc("hypothesis/read", project_id="p", hypothesis_id=hypothesis_id))[
+ "hypothesis"
+ ]
+ calls = len(model.calls)
+
+ async def standing() -> str:
+ (item,) = await listed(rpc)
+ return str(item["standing"])
+
+ await record(rpc, hypothesis_id, "test-1", "NEITHER")
+ assert await standing() == "NONE"
+ await record(rpc, hypothesis_id, "test-1", "ALTERNATIVE")
+ assert await standing() == "AGAINST_ONCE"
+ await record(rpc, hypothesis_id, "test-2", "THIS_HYPOTHESIS")
+ assert await standing() == "MIXED"
+ await record(rpc, hypothesis_id, "test-2", "ALTERNATIVE") # the latest result of test-2
+ assert await standing() == "AGAINST_REPEATED"
+ await record(rpc, hypothesis_id, "test-1", "THIS_HYPOTHESIS") # corrected
+ await record(rpc, hypothesis_id, "test-2", "THIS_HYPOTHESIS")
+ assert await standing() == "FITS"
+ # the sealed-test appraisal and the whole record are exactly what they were
+ after = (await rpc("hypothesis/read", project_id="p", hypothesis_id=hypothesis_id))[
+ "hypothesis"
+ ]
+ assert after["empirical_appraisal"] == before["empirical_appraisal"]
+ assert after["revision_digest"] == before["revision_digest"]
+ assert "standing" not in stored(runtime, DISCRIMINATION_KEY) # read, never stored
+ assert len(model.calls) == calls
+ finally:
+ runtime.close()
diff --git a/tests/integration/test_lesson_recall.py b/tests/integration/test_lesson_recall.py
new file mode 100644
index 0000000..0bc0202
--- /dev/null
+++ b/tests/integration/test_lesson_recall.py
@@ -0,0 +1,179 @@
+"""Lessons: what a rule computed or a person recorded, recalled only in the same condition.
+
+A lesson is added with the record it rests on and is a reference, never a sentence. It is recalled
+for a row only when the criterion, the unit, the condition and the rule revision all match; it goes
+stale when its record changed and is refuted by a later opposite result, and it is never removed.
+No model is called. These tests go through the public methods.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+import pytest
+from tests.integration.test_hypothesis_link import change_result, investigate
+from tests.integration.test_judgment_records import (
+ close,
+ only_hypothesis,
+ record,
+ stored,
+)
+from tests.integration.test_research_request_v2 import ControlledResearchModel
+from tests.integration.test_trace_origin import RAIN, Rpc, opened
+from tests.integration.trace_demo_helpers import demo_set, with_notice
+
+from thoth.application.services.lesson_ledger import KEY as LESSON_KEY
+from thoth.application.services.trace_csv import export_rows, write_csv
+
+OBSERVED = "the rain run matched the dry run"
+
+
+async def lessons(rpc: Rpc) -> dict[str, dict[str, Any]]:
+ found = (await rpc("trace/lesson/list", project_id="p"))["lessons"]
+ return {item["lesson_id"]: item for item in found}
+
+
+def by(
+ found: dict[str, dict[str, Any]], kind: str, outcome: str | None = None
+) -> list[dict[str, Any]]:
+ return [
+ item
+ for item in found.values()
+ if item["kind"] == kind and (outcome is None or item["outcome"] == outcome)
+ ]
+
+
+async def change_rule(rpc: Rpc, rule_id: str, threshold: str) -> None:
+ """A new revision of one rule, as a person would import it."""
+ current = (await rpc("trace/read", project_id="p"))["set_digest"]
+ rows = []
+ for row in export_rows(with_notice(demo_set(2))):
+ row = {**row, "base_set_digest": current}
+ if row["row_type"] == "RULE" and row["rule_id"] == rule_id:
+ row = {
+ **row,
+ "rule_revision": str(int(row["rule_revision"]) + 1),
+ "threshold": threshold,
+ }
+ rows.append(row)
+ await rpc.load(write_csv(rows), "UPDATE")
+
+
+@pytest.mark.asyncio
+async def test_a_recorded_result_is_a_lesson_of_its_rows_condition_and_a_reference_not_a_sentence(
+ tmp_path: Path,
+) -> None:
+ model = ControlledResearchModel(one=True, tested=True)
+ runtime, rpc = await opened(tmp_path, model)
+ try:
+ await investigate(runtime, rpc, RAIN)
+ hypothesis_id = await only_hypothesis(rpc)
+ calls = len(model.calls)
+ assert await lessons(rpc) == {}
+ await record(rpc, hypothesis_id, "test-1", "ALTERNATIVE", evidence_refs=["doc://run-7"])
+ found = await lessons(rpc)
+ (result,) = by(found, "TEST_RESULT")
+ (single,) = by(found, "ELIMINATION", "SINGLE")
+ assert (result["outcome"], result["state"], result["subject_id"]) == (
+ "ALTERNATIVE",
+ "SAME_CONDITION",
+ RAIN,
+ )
+ assert (
+ result["detail"]["observations"][0]["observation"] == OBSERVED
+ ) # read back, not copied
+ assert single["detail"]["observations"][0]["evidence_refs"] == ["doc://run-7"]
+ await record(rpc, hypothesis_id, "test-2", "ALTERNATIVE")
+ found = await lessons(rpc)
+ assert len(by(found, "TEST_RESULT")) == 2
+ assert {item["state"] for item in by(found, "ELIMINATION")} == {"STALE", "SAME_CONDITION"}
+ assert [item["outcome"] for item in by(found, "ELIMINATION", "REPEATED")] == ["REPEATED"]
+ # what is stored names records and a context; the sentence stays in the record it came from
+ refs = stored(runtime, LESSON_KEY)["refs"]
+ assert len(refs) == 4 and OBSERVED not in str(refs)
+ assert {r["context"]["condition"] for r in refs} == {"weather=rain"}
+ assert len(model.calls) == calls # no model was asked
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_a_result_that_is_replaced_makes_its_lesson_stale_and_an_opposite_one_refutes(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel(one=True, tested=True))
+ try:
+ await investigate(runtime, rpc, RAIN)
+ hypothesis_id = await only_hypothesis(rpc)
+ await record(rpc, hypothesis_id, "test-1", "ALTERNATIVE")
+ await record(rpc, hypothesis_id, "test-1", "THIS_HYPOTHESIS") # the same test, corrected
+ found = await lessons(rpc)
+ states = {(i["kind"], i["outcome"]): i["state"] for i in found.values()}
+ assert states[("TEST_RESULT", "ALTERNATIVE")] == "STALE" # its record was replaced
+ assert states[("ELIMINATION", "SINGLE")] == "STALE" # the elimination no longer holds
+ assert states[("TEST_RESULT", "THIS_HYPOTHESIS")] == "SAME_CONDITION"
+ # a later, different test says the opposite of an earlier one: the earlier is refuted
+ await record(rpc, hypothesis_id, "test-2", "ALTERNATIVE")
+ later = {
+ (i["test_id"], i["outcome"]): i["state"] for i in by(await lessons(rpc), "TEST_RESULT")
+ }
+ assert later[("test-1", "THIS_HYPOTHESIS")] == "REFUTED"
+ assert later[("test-2", "ALTERNATIVE")] == "SAME_CONDITION"
+ assert len(stored(runtime, LESSON_KEY)["refs"]) == 5 # nothing was removed
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_a_lesson_is_not_recalled_once_the_rule_revision_differs_but_stays_on_record(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel(one=True, tested=True))
+ try:
+ await investigate(runtime, rpc, RAIN)
+ hypothesis_id = await only_hypothesis(rpc)
+ await record(rpc, hypothesis_id, "test-1", "ALTERNATIVE")
+ assert len(await lessons(rpc)) == 2
+ await change_rule(rpc, "R-SYN-C-DET-RAIN", "0.80") # the same row under a new rule revision
+ assert await lessons(rpc) == {} # another condition: not recalled, not even as stale
+ assert len(stored(runtime, LESSON_KEY)["refs"]) == 2
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_a_result_with_no_trace_row_behind_it_makes_no_lesson(tmp_path: Path) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel(one=True, tested=True))
+ try:
+ await investigate(runtime, rpc, None) # an ordinary question: no row, no context
+ await record(rpc, await only_hypothesis(rpc), "test-1", "ALTERNATIVE")
+ assert await lessons(rpc) == {}
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_a_recorded_closure_is_a_lesson_and_a_fix_only_once_its_effect_is_confirmed(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel())
+ try:
+ await rpc.rain_arrives()
+ await close(rpc, RAIN, "HUMAN_CLOSED", basis_ref="minutes-2026-10-06")
+ await close(rpc, RAIN, "FIX_APPLIED") # a fix with no confirmed effect is not a lesson
+ found = await lessons(rpc)
+ assert [(i["kind"], i["outcome"], i["state"]) for i in found.values()] == [
+ ("HUMAN_CLOSURE", "HUMAN_CLOSED", "SAME_CONDITION")
+ ]
+ assert next(iter(found.values()))["detail"]["basis_ref"] == "minutes-2026-10-06"
+ # new results that meet the row confirm the effect: its lesson appears after the import
+ await change_result(rpc, "SYN-RES-SYN-C-DET-RAIN", phase=2, value="0.95", numerator="19")
+ found = await lessons(rpc)
+ (effect,) = by(found, "EFFECT_CONFIRMED")
+ assert effect["state"] == "SAME_CONDITION" and effect["actor_id"] == "system:lesson-rule"
+ (closure,) = by(found, "HUMAN_CLOSURE")
+ assert closure["state"] == "STALE" # the verdict it was recorded against has changed
+ assert len(stored(runtime, LESSON_KEY)["refs"]) == 2
+ finally:
+ runtime.close()
diff --git a/tests/integration/test_memory_query_expansion.py b/tests/integration/test_memory_query_expansion.py
new file mode 100644
index 0000000..cd75f07
--- /dev/null
+++ b/tests/integration/test_memory_query_expansion.py
@@ -0,0 +1,368 @@
+"""A model may add search words to a question once per investigation; recall stays bounded by rules.
+
+The expander here is a fake. These tests check what recall does with added words, that the call is
+made once and only when it can help, and that a failed call changes nothing.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import cast
+
+import pytest
+from pydantic import ValidationError
+from tests.integration.test_a02_autonomous_acquisition import prepare_thread, request, value
+from tests.integration.test_memory_edit import Json
+from tests.integration.test_memory_selection import (
+ CUTOFF,
+ Clock,
+ Ids,
+ Injection,
+ Ledger,
+ Store,
+ stored,
+)
+
+from thoth.application.services.full_project_memory import FullProjectMemoryService
+from thoth.application.services.memory_query_expansion import widen_query
+from thoth.application.services.research_usage import summarize_operation_usage
+from thoth.domain.enums import MemoryKind
+from thoth.domain.memory import FullMemoryContextPack, FullMemoryRevision
+from thoth.domain.memory_expansion import (
+ MEMORY_QUERY_EXPANSION_PURPOSE,
+ MemoryExpansionOutcome,
+ MemoryExpansionRecord,
+ QueryExpansion,
+)
+from thoth.domain.memory_terms import added_words, words
+from thoth.domain.model_dispatch import CONTROLLED_MODEL_CONTROL, ModelDispatchRecord
+from thoth.ports.model import ModelOutputContractHold
+
+QUESTION = "출처별 대조 분석(A1)을 지금 바로 해도 돼? 무엇이 더 필요해?"
+# The memory the question is about. It holds one question word ("대조") and none of "분석".
+A1_TEXT = "연결된 RFP PDF 원문과 표를 읽기 전용으로 대조한다."
+WIDENING = QueryExpansion(
+ synonyms=("원문 검토", "문서 비교"),
+ keywords=("source review",),
+ related=("근거 확인",),
+ note_line="원문 문서를 읽기 전용으로 비교하는 행동",
+)
+
+
+def memories() -> list[FullMemoryRevision]:
+ return [
+ stored("a1", kind=MemoryKind.ACTION, excerpt=f"ACTION:a1\n{A1_TEXT}", minutes=1),
+ stored(
+ "h1",
+ excerpt="HYPOTHESIS:h1\n센서별 가시성 라벨 설계는 항만 시험으로 검증한다",
+ minutes=2,
+ ),
+ stored("h2", excerpt="HYPOTHESIS:h2\n표본이 작으면 결론을 보류한다", minutes=3),
+ ]
+
+
+@dataclass
+class Switch:
+ on: bool = True
+
+ def expansion_enabled(self, project_id: str) -> bool:
+ return self.on
+
+
+@dataclass
+class FakeExpander:
+ answer: QueryExpansion | None = WIDENING
+ error: BaseException | None = None
+ delay: float = 0.0
+ calls: int = field(default=0)
+
+ async def expand(self, query: str) -> QueryExpansion:
+ self.calls += 1
+ if self.delay:
+ await asyncio.sleep(self.delay)
+ if self.error is not None:
+ raise self.error
+ assert self.answer is not None
+ return self.answer
+
+
+def service_for(
+ items: list[FullMemoryRevision], *, memory_on: bool = True, widen_on: bool = True
+) -> tuple[FullProjectMemoryService, Store]:
+ store = Store(items)
+ contents = {i.owner_revision_ref: {"statement": "내용"} for i in items}
+ svc = FullProjectMemoryService(
+ store=cast(object, store), # type: ignore[arg-type]
+ candidates=cast(object, None), # type: ignore[arg-type]
+ ledger=cast(object, Ledger(contents)), # type: ignore[arg-type]
+ clock=cast(object, Clock()), # type: ignore[arg-type]
+ ids=cast(object, Ids()), # type: ignore[arg-type]
+ injection=Injection(memory_on),
+ expansion_switch=Switch(widen_on),
+ )
+ return svc, store
+
+
+def recall(
+ svc: FullProjectMemoryService,
+ outcome: MemoryExpansionOutcome | None,
+ query: str = QUESTION,
+) -> FullMemoryContextPack:
+ return svc.build_context(
+ project_id="p",
+ thread_id="thread:q",
+ query=query,
+ target_use="WORKING_CONTEXT",
+ scope={},
+ cutoff_at=CUTOFF,
+ expansion=outcome,
+ )
+
+
+async def widen(
+ svc: FullProjectMemoryService, expander: FakeExpander, query: str = QUESTION, **more: float
+) -> MemoryExpansionOutcome:
+ return await widen_query(
+ memory=svc,
+ expander=expander,
+ work=None,
+ project_id="p",
+ query=query,
+ **more,
+ )
+
+
+@pytest.mark.asyncio
+async def test_an_added_word_lets_a_question_find_a_memory_it_shares_only_one_word_with() -> None:
+ svc, _ = service_for(memories())
+ plain = recall(svc, None)
+ assert plain.included == ()
+ assert plain.selection is not None and plain.selection.matches == {}
+ outcome = await widen(svc, FakeExpander())
+ pack = recall(svc, outcome)
+ assert [i.memory_id for i in pack.included] == ["memory:a1"]
+ selection = pack.selection
+ assert selection is not None and selection.expansion is not None
+ match = selection.matches["rev:a1"]
+ assert match.matched_by == "EXPANSION" and match.words == ("대조",)
+ assert "원문" in match.added_words
+ assert selection.expansion.status == "USED" and "원문" in selection.expansion.added_words
+ assert "대조" not in selection.expansion.added_words
+
+
+@pytest.mark.asyncio
+async def test_a_memory_the_questions_own_words_reach_comes_before_one_only_added_words_reach() -> (
+ None
+):
+ own = stored("own", kind=MemoryKind.ACTION, excerpt="ACTION:own\n출처별 분석 결과를 정리한다")
+ added = stored("added", kind=MemoryKind.ACTION, excerpt=f"ACTION:added\n{A1_TEXT}", minutes=9)
+ svc, _ = service_for([added, own, *memories()[1:]])
+ pack = recall(svc, await widen(svc, FakeExpander()))
+ assert [i.memory_id for i in pack.included][:2] == ["memory:own", "memory:added"]
+ assert pack.selection is not None
+ assert pack.selection.matches["rev:own"].matched_by == "QUERY"
+ assert pack.selection.matches["rev:added"].matched_by == "EXPANSION"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("error", "status"),
+ [
+ (RuntimeError("boom"), "EXPANSION_FAILED"),
+ (ModelOutputContractHold("MODEL_OUTPUT_INVALID"), "EXPANSION_INVALID"),
+ ],
+)
+async def test_a_failed_or_malformed_answer_leaves_recall_as_it_was_and_says_why(
+ error: BaseException, status: str
+) -> None:
+ svc, _ = service_for(memories())
+ outcome = await widen(svc, FakeExpander(error=error))
+ assert outcome.record.status == status and outcome.expansion is None
+ failed = recall(svc, outcome)
+ plain = recall(svc, None)
+ assert failed.included == plain.included and failed.excluded_reason_counts == (
+ plain.excluded_reason_counts
+ )
+ assert failed.selection is not None and failed.selection.expansion is not None
+ assert failed.selection.expansion.status == status
+ assert failed.selection.matches == {}
+
+
+@pytest.mark.asyncio
+async def test_an_answer_in_the_wrong_shape_is_invalid_and_a_slow_one_times_out() -> None:
+ svc, _ = service_for(memories())
+ with pytest.raises(ValidationError) as caught:
+ QueryExpansion.model_validate({"synonyms": "not a list"})
+ invalid = await widen(svc, FakeExpander(error=caught.value))
+ assert invalid.record.status == "EXPANSION_INVALID"
+ slow = await widen(svc, FakeExpander(delay=5), timeout_seconds=0.01)
+ assert slow.record.status == "EXPANSION_FAILED" and slow.record.reason == "TIMEOUT"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("kind", "reason"),
+ [
+ ("memory_off", "MEMORY_OFF"),
+ ("setting_off", "SETTING_OFF"),
+ ("no_candidates", "NO_CANDIDATES"),
+ ("follow_up", "FOLLOW_UP"),
+ ],
+)
+async def test_no_model_call_is_made_when_it_cannot_help(kind: str, reason: str) -> None:
+ items = [] if kind == "no_candidates" else memories()
+ svc, _ = service_for(items, memory_on=kind != "memory_off", widen_on=kind != "setting_off")
+ expander = FakeExpander()
+ query = "앞에서 세운 가설을 정리해 줘" if kind == "follow_up" else QUESTION
+ outcome = await widen(svc, expander, query)
+ assert expander.calls == 0
+ assert outcome.record.status == "NOT_ASKED" and outcome.record.reason == reason
+ assert recall(svc, outcome, query).included == recall(svc, None, query).included
+
+
+@pytest.mark.asyncio
+async def test_an_investigation_asks_once_however_many_steps_recall_memory() -> None:
+ from thoth.domain.research_execution import ResearchWork
+
+ svc, _ = service_for(memories())
+ work = ResearchWork.__new__(ResearchWork)
+ work.preprocessing_cache = {}
+ expander = FakeExpander()
+ first = await widen_query(
+ memory=svc, expander=expander, work=work, project_id="p", query=QUESTION
+ )
+ second = await widen_query(
+ memory=svc, expander=expander, work=work, project_id="p", query=QUESTION
+ )
+ assert expander.calls == 1 and first is second
+ # a different investigation (new work) asks again
+ other = ResearchWork.__new__(ResearchWork)
+ other.preprocessing_cache = {}
+ await widen_query(memory=svc, expander=expander, work=other, project_id="p", query=QUESTION)
+ assert expander.calls == 2
+
+
+def test_a_word_the_question_already_has_is_never_counted_a_second_time() -> None:
+ query = words("자료를 정리해 줘")
+ added = [w.run for w in added_words("자료 자료를 정리 분석 분석을 데이터", query)]
+ # 자료 / 자료를 / 정리 are the question's; 분석 and 분석을 are one word added once
+ assert added == ["분석", "데이터"]
+
+
+def test_the_models_words_are_limited_before_they_reach_matching() -> None:
+ many = QueryExpansion.model_validate(
+ {
+ "synonyms": [f"단어{n}" for n in range(12)],
+ "keywords": ["x" * 41, "ok"],
+ "related": [],
+ "note_line": "가" * 500,
+ }
+ )
+ assert len(many.synonyms) == 8 and many.keywords == ("ok",) and len(many.note_line) == 240
+
+
+def test_the_expansions_usage_is_shown_apart_inside_the_operations_total() -> None:
+ def record(dispatch_id: str, purpose: str | None, tokens: int) -> ModelDispatchRecord:
+ return ModelDispatchRecord(
+ dispatch_id=dispatch_id,
+ thread_id="thread:q",
+ operation_id="op:1",
+ capability=CONTROLLED_MODEL_CONTROL,
+ payload_bytes=10,
+ payload_digest="d",
+ output_reserved=100,
+ input_tokens=tokens,
+ output_tokens=tokens // 10,
+ purpose=purpose,
+ )
+
+ usage = summarize_operation_usage(
+ [
+ record("c:0", None, 1000),
+ record("c:1", MEMORY_QUERY_EXPANSION_PURPOSE, 200),
+ ],
+ "thread:q",
+ "op:1",
+ )
+ assert usage is not None
+ assert usage["calls"] == 2 and usage["input_tokens"] == 1200 and usage["output_tokens"] == 120
+ by_purpose = cast(dict[str, dict[str, object]], usage["by_purpose"])
+ entry = by_purpose[MEMORY_QUERY_EXPANSION_PURPOSE]
+ assert entry["label"] == "기억 검색어 넓히기"
+ assert (entry["calls"], entry["input_tokens"], entry["output_tokens"]) == (1, 200, 20)
+ plain = summarize_operation_usage([record("c:0", None, 1000)], "thread:q", "op:1")
+ assert plain is not None and "by_purpose" not in plain
+
+
+@pytest.mark.asyncio
+async def test_the_model_expander_asks_with_its_own_role_a_small_limit_and_marks_the_call() -> None:
+ from datetime import UTC, datetime
+ from types import SimpleNamespace
+
+ from thoth.application.services.memory_query_expansion import ModelMemoryQueryExpander
+ from thoth.domain.enums import ModelRole
+ from thoth.domain.research_execution import model_purpose
+
+ seen: dict[str, object] = {}
+
+ class Model:
+ async def structured(self, request: object) -> object:
+ seen["request"] = request
+ seen["purpose"] = model_purpose.get()
+ return SimpleNamespace(output=WIDENING, dispatch_ids=("call:0",))
+
+ expander = ModelMemoryQueryExpander(
+ cast(object, Model()), # type: ignore[arg-type]
+ project_id="p",
+ cutoff_at=datetime(2026, 9, 1, tzinfo=UTC),
+ model_policy_ref="policy:1",
+ head_set_digest="h" * 64,
+ )
+ assert await expander.expand(QUESTION) == WIDENING
+ request_seen = cast(SimpleNamespace, seen["request"])
+ assert request_seen.role == ModelRole.MEMORY_QUERY_EXPANDER
+ assert request_seen.max_output_tokens == 300 and request_seen.output_model is QueryExpansion
+ assert request_seen.context_pack.problem == QUESTION
+ assert seen["purpose"] == MEMORY_QUERY_EXPANSION_PURPOSE
+ assert model_purpose.get() is None and expander.dispatch_ids == ("call:0",)
+
+
+@pytest.mark.asyncio
+async def test_the_widening_switch_is_read_and_changed_apart_from_the_memory_switch(
+ tmp_path: Path,
+) -> None:
+ runtime, _connector, project = await prepare_thread(tmp_path, allow_connector=True)
+ try:
+
+ async def call(method: str, key: str, **more: object) -> Json:
+ payload = cast(dict[str, object], {"project_id": project, **more})
+ return value(await runtime.bus.dispatch(request(method, key, payload))) # type: ignore[arg-type]
+
+ first = await call("memory/settings/read", "qe-read-1")
+ assert first["query_expansion"] is True
+ off = await call(
+ "memory/settings/update",
+ "qe-off",
+ memory_injection=True,
+ query_expansion=False,
+ expected_digest=None,
+ )
+ assert off["query_expansion"] is False and off["memory_injection"] is True
+ # changing only the memory switch keeps the widening choice
+ digest = cast(str, off["settings_digest"])
+ kept = await call(
+ "memory/settings/update", "qe-keep", memory_injection=False, expected_digest=digest
+ )
+ assert kept["memory_injection"] is False and kept["query_expansion"] is False
+ finally:
+ runtime.close()
+
+
+def test_the_recorded_outcome_keeps_what_the_selection_record_needs() -> None:
+ outcome = MemoryExpansionOutcome(
+ record=MemoryExpansionRecord(status="USED", added_words=("원문",), dispatch_ids=("c:0",)),
+ expansion=WIDENING,
+ )
+ assert outcome.used and MemoryExpansionOutcome().used is False
diff --git a/tests/integration/test_memory_selection.py b/tests/integration/test_memory_selection.py
index 3128414..9533aad 100644
--- a/tests/integration/test_memory_selection.py
+++ b/tests/integration/test_memory_selection.py
@@ -32,9 +32,10 @@
"portfolio_id": "portfolio:o:1",
"hypothesis_refs": ["hypothesis:o:a", "hypothesis:o:b"],
}
-LONG_JSON = json.dumps({"statement": "표본이 작으면 결론을 보류한다", "filler": "가" * 8_000})[
- :8_000
-]
+# Stored the way a record is stored: readable text, not \uXXXX escapes (canonical_payload).
+LONG_JSON = json.dumps(
+ {"statement": "표본이 작으면 결론을 보류한다", "filler": "가" * 8_000}, ensure_ascii=False
+)[:8_000]
class Ledger:
@@ -288,7 +289,8 @@ async def call(method: str, key: str, **more: JsonValue) -> Json:
)
first = await call("memory/settings/read", "ms-read-1")
- assert first == {"memory_injection": True, "settings_digest": None}
+ # query_expansion joined the settings (memory_query_expansion); its default is on.
+ assert first == {"memory_injection": True, "query_expansion": True, "settings_digest": None}
off = await call(
"memory/settings/update", "ms-off", memory_injection=False, expected_digest=None
)
@@ -373,23 +375,23 @@ def listed_memories(runtime: Any, project: str) -> tuple[FullMemoryRevision, ...
return SqliteFullMemoryStore(runtime.ledger.engine).list_revisions(project)
-def _correction(tag: str, terms: tuple[str, ...], minutes: int = 0) -> FullMemoryRevision:
+def _correction(
+ tag: str, assertion: str = "표본이 작으면 다시 확인한다", minutes: int = 0
+) -> FullMemoryRevision:
return stored(tag, kind=MemoryKind.LESSON, minutes=minutes).model_copy(
update={
"payload_mode": MemoryPayloadMode.MEMORY_ASSERTION,
"source_ref": f"MEMORY:{tag}",
- "assertion": "표본이 작으면 다시 확인한다",
- "query_terms": terms,
+ "assertion": assertion,
"evidence_refs": (),
}
)
def _five(common: str = "프로젝트") -> list[FullMemoryRevision]:
- own = ("가설a", "가설b", "행동c", "행동d", "결과e")
+ own = ("사과", "포도", "수박", "참외", "딸기")
return [
- stored(f"w{n}", minutes=n).model_copy(update={"query_terms": (common, own[n])})
- for n in range(5)
+ stored(f"w{n}", minutes=n, excerpt=f"HYPOTHESIS:w{n}\n{own[n]} {common}") for n in range(5)
]
@@ -411,14 +413,14 @@ def test_an_automatic_memory_that_meets_the_question_on_a_project_wide_word_is_l
i.memory_revision_id for i in items
}
# two words of the memory's own bring it in; the project-wide word is not one of them
- both = stored("both").model_copy(update={"query_terms": ("프로젝트", "가설a", "가설b")})
+ both = stored("both", excerpt="HYPOTHESIS:both\n사과 포도 프로젝트")
svc, _ = _service_for([*items, both])
- pack = context(svc, "프로젝트 가설a 가설b 는")
+ pack = context(svc, "프로젝트 사과 포도 는")
assert [i.memory_id for i in pack.included] == ["memory:both"]
def test_a_users_correction_is_still_recalled_on_one_shared_word() -> None:
- fix = _correction("fix", ("프로젝트", "표본이"))
+ fix = _correction("fix", "프로젝트 표본이 작으면 다시 확인한다")
svc, _ = _service_for([*_five(), fix])
pack = context(svc, "프로젝트 일정은 어떻게 되나")
assert [i.memory_id for i in pack.included] == ["memory:fix"]
@@ -426,7 +428,7 @@ def test_a_users_correction_is_still_recalled_on_one_shared_word() -> None:
def test_a_follow_up_question_recalls_the_latest_memories_without_a_shared_word() -> None:
- fix = _correction("fix", ("표본이",), minutes=-30)
+ fix = _correction("fix", minutes=-30)
svc, _ = _service_for([*_five(), fix])
pack = context(svc, "앞에서 세운 가설과 다음 행동을 정리해 줘")
ids = [i.memory_id for i in pack.included]
diff --git a/tests/integration/test_model_call_settings.py b/tests/integration/test_model_call_settings.py
index f43621b..eaf65ef 100644
--- a/tests/integration/test_model_call_settings.py
+++ b/tests/integration/test_model_call_settings.py
@@ -41,7 +41,11 @@ async def call(method: str, key: str, **more: JsonValue) -> Json:
)
first = await call("model/callSettings/read", "r1")
- assert first == {"auto_retry_interrupted_model_call": False, "settings_digest": None}
+ assert first == {
+ "auto_retry_interrupted_model_call": False,
+ "hypothesis_contract_v3": False,
+ "settings_digest": None,
+ }
on = await call(
"model/callSettings/update",
"on",
diff --git a/tests/integration/test_prepare_synthetic_radar_demo.py b/tests/integration/test_prepare_synthetic_radar_demo.py
new file mode 100644
index 0000000..9e9018f
--- /dev/null
+++ b/tests/integration/test_prepare_synthetic_radar_demo.py
@@ -0,0 +1,174 @@
+"""The demo preparation script against a real app: it uses only the public methods and the file
+upload, and what it writes can be imported on the trace page and behaves as the demo is meant to."""
+
+from __future__ import annotations
+
+import importlib.util
+import json
+import sys
+from collections.abc import Iterator
+from pathlib import Path
+from typing import Any
+
+import pytest
+from starlette.testclient import TestClient
+
+from thoth.adapters.http.app import create_app
+
+REPO = Path(__file__).parents[2]
+SCRIPT = REPO / "scripts" / "prepare_synthetic_radar_demo.py"
+DEMO = REPO / "examples" / "synthetic-radar-demo-v1"
+
+
+def _load() -> Any:
+ spec = importlib.util.spec_from_file_location("prepare_synthetic_radar_demo", SCRIPT)
+ assert spec is not None and spec.loader is not None
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[spec.name] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+prepare = _load()
+
+
+@pytest.fixture
+def api(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[Any]:
+ monkeypatch.setenv("THOTH_WORKSPACE", str(tmp_path / "workspace"))
+ with TestClient(create_app()) as client:
+
+ def send(path: str, body: bytes, headers: dict[str, str]) -> dict[str, Any]:
+ response = client.post(path, content=body, headers=headers)
+ if response.status_code >= 400:
+ raise prepare.DemoError(
+ f"{path}: HTTP {response.status_code} {response.text[:200]}"
+ )
+ return response.json()
+
+ yield prepare.Api(send)
+
+
+def apply_import(api: Any, project: str, mode: str, path: Path) -> dict[str, Any]:
+ """What the trace page does: preview, then apply exactly what was previewed."""
+ text = path.read_bytes().decode("utf-8").removeprefix("\ufeff") # the browser drops the mark
+ preview = api.rpc(
+ "trace/importPreview",
+ {"project_id": project, "mode": mode, "csv_text": text},
+ query=True,
+ )
+ assert preview["applicable"], preview["conflicts"]
+ return api.rpc(
+ "trace/importApply",
+ {
+ "project_id": project,
+ "mode": mode,
+ "csv_text": text,
+ "preview_id": preview["preview_id"],
+ "input_sha256": preview["input_sha256"],
+ },
+ )
+
+
+def states(api: Any, project: str) -> dict[str, str]:
+ view = api.rpc("trace/read", {"project_id": project}, query=True)
+ return {verdict["subject_id"]: verdict["state"] for verdict in view["verdicts"]}
+
+
+def sentences(api: Any, project: str) -> dict[str, str]:
+ listed = api.rpc("evidence/list", {"project_id": project}, query=True)
+ return {span["span_id"]: span["exact_text"] for span in listed["evidence"]}
+
+
+def test_phase_1_connects_the_files_and_the_csv_points_at_real_sentences(
+ api: Any, tmp_path: Path
+) -> None:
+ done = prepare.prepare_phase1(api, DEMO, tmp_path / "files", "demo")
+ assert done.mode == "CREATE" and done.preview["applicable"]
+ sources = api.rpc("project/source/list", {"project_id": done.project_id}, query=True)
+ assert len(sources["artifacts"]) == 3 # requirements, test plan, dry result; no rain yet
+ listed = json.dumps(sources)
+ assert "10_REQUIREMENTS_SYNTHETIC.yaml" in listed and "30_RESULT_DRY_SYNTHETIC.yaml" in listed
+ assert ".txt" not in listed # the files keep their own names
+ apply_import(api, done.project_id, "CREATE", done.csv_path)
+ view = api.rpc("trace/read", {"project_id": done.project_id}, query=True)
+ found = sentences(api, done.project_id)
+ refs = {r["criterion_id"]: r["source_span_refs"] for r in view["results"]}
+ assert set(refs) == {"SYN-C-DET-DRY", "SYN-C-FA-DRY"}
+ assert [found[ref] for ref in refs["SYN-C-DET-DRY"]] == ['value: "0.95"']
+ assert [found[ref] for ref in refs["SYN-C-FA-DRY"]] == ['value: "0.30"']
+ assert all(item["fields"]["synthetic_notice"] == prepare.NOTICE for item in view["items"])
+ assert states(api, done.project_id) == {
+ "SYN-C-DET-DRY": "PASS_COMPUTED",
+ "SYN-C-FA-DRY": "PASS_COMPUTED",
+ "SYN-C-DET-RAIN": "HOLD_NO_RESULT",
+ "SYN-C-FA-RAIN": "HOLD_NO_RESULT",
+ "SYN-C-DET-FOG": "HOLD_NO_RESULT",
+ "SYN-C-FA-FOG": "HOLD_NO_RESULT",
+ "SYN-REQ-RAD-001": "HOLD_INCOMPLETE",
+ }
+
+
+def test_phase_2_adds_only_the_rain_rows_and_the_requirement_fails_with_incomplete_coverage(
+ api: Any, tmp_path: Path
+) -> None:
+ out = tmp_path / "files"
+ first = prepare.prepare_phase1(api, DEMO, out, "demo")
+ apply_import(api, first.project_id, "CREATE", first.csv_path)
+ second = prepare.prepare_phase2(api, DEMO, out)
+ assert second.project_id == first.project_id and second.mode == "UPDATE"
+ changes = second.preview["changes"]
+ assert changes["updated"] == [] and changes["deleted"] == []
+ assert sorted(change["kind"] for change in changes["added"]) == sorted(
+ ["ITEM", "ITEM", "LINK", "LINK", "RESULT", "RESULT"]
+ )
+ applied = apply_import(api, first.project_id, "UPDATE", second.csv_path)
+ assert {change["subject_id"] for change in applied["verdict_changes"]} == {
+ "SYN-C-DET-RAIN",
+ "SYN-C-FA-RAIN",
+ "SYN-REQ-RAD-001",
+ }
+ assert states(api, first.project_id) == {
+ "SYN-C-DET-DRY": "PASS_COMPUTED",
+ "SYN-C-FA-DRY": "PASS_COMPUTED",
+ "SYN-C-DET-RAIN": "FAIL_COMPUTED",
+ "SYN-C-FA-RAIN": "FAIL_COMPUTED",
+ "SYN-C-DET-FOG": "HOLD_NO_RESULT",
+ "SYN-C-FA-FOG": "HOLD_NO_RESULT",
+ "SYN-REQ-RAD-001": "FAIL_WITH_INCOMPLETE_COVERAGE",
+ }
+ view = api.rpc("trace/read", {"project_id": first.project_id}, query=True)
+ found = sentences(api, first.project_id)
+ rain = {r["criterion_id"]: found[r["source_span_refs"][0]] for r in view["results"]}
+ assert rain["SYN-C-DET-RAIN"] == 'value: "0.80"' and rain["SYN-C-FA-RAIN"] == 'value: "0.70"'
+ exported = api.rpc("trace/export", {"project_id": first.project_id}, query=True)
+ header = exported["csv_text"].removeprefix("\ufeff").splitlines()[0].split(",")
+ assert header[-1] == "synthetic_notice"
+ again = api.rpc(
+ "trace/importPreview",
+ {"project_id": first.project_id, "mode": "UPDATE", "csv_text": exported["csv_text"]},
+ query=True,
+ )
+ assert again["applicable"] and not any(
+ again["changes"][k] for k in ("added", "updated", "deleted")
+ )
+
+
+def test_phase_2_refuses_a_project_whose_trace_is_not_the_untouched_phase_1(
+ api: Any, tmp_path: Path
+) -> None:
+ out = tmp_path / "files"
+ prepare.prepare_phase1(api, DEMO, out, "demo") # prepared but never imported
+ with pytest.raises(prepare.DemoError, match="phase 1"):
+ prepare.prepare_phase2(api, DEMO, out)
+
+
+def test_the_files_are_sent_unchanged_and_the_state_names_only_ids(
+ api: Any, tmp_path: Path
+) -> None:
+ out = tmp_path / "files"
+ done = prepare.prepare_phase1(api, DEMO, out, "demo")
+ state = json.loads((out / "state.json").read_text(encoding="utf-8"))
+ assert state["project_id"] == done.project_id
+ assert set(state["spans"]) == {"30_RESULT_DRY_SYNTHETIC.yaml"}
+ sent = {text for text in sentences(api, done.project_id).values()}
+ assert f"notice: {prepare.NOTICE}" in sent # the file's own notice line arrived as written
diff --git a/tests/integration/test_project_list_reads.py b/tests/integration/test_project_list_reads.py
new file mode 100644
index 0000000..53e8958
--- /dev/null
+++ b/tests/integration/test_project_list_reads.py
@@ -0,0 +1,204 @@
+"""The project list reads do not repeat the same read for every item, and answer what they did.
+
+The lesson list used to read the whole trace again for each lesson that rests on a row (so its time
+grew with lessons times rows). It now reads the verdicts once per request. These tests count the
+reads and compare the answer with the one the per-lesson path gives. No model is called.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any, cast
+
+import pytest
+from tests.integration.test_hypothesis_link import change_result, investigate
+from tests.integration.test_judgment_records import close, only_hypothesis, record
+from tests.integration.test_research_request_v2 import ControlledResearchModel
+from tests.integration.test_trace_origin import FOG, RAIN, opened
+
+from thoth.adapters.runtime import SystemClock, UuidIdGenerator
+from thoth.application.services.hypothesis_link_view import HypothesisLinkReader
+from thoth.application.services.hypothesis_same import read_same
+from thoth.application.services.lesson_ledger import LessonLedger
+from thoth.application.services.request_records import RequestRecords
+from thoth.application.services.trace_closure import TraceClosures
+from thoth.application.services.verification_trace import REVISION_PREFIX
+from thoth.domain.hypothesis_same import groups
+from thoth.domain.lesson_ref import recall_lessons
+
+
+def reference_recall(ledger: LessonLedger, project_id: str, rows: Any) -> list[dict[str, object]]:
+ """The answer the per-lesson path gives: each lesson looks the trace up for itself."""
+ refs = ledger.read(project_id).refs
+ results = ledger._results(project_id) # pyright: ignore[reportPrivateUsage]
+ events = {item.event_id: item for item in ledger._closure_events(project_id)} # pyright: ignore[reportPrivateUsage]
+ by_row = {(str(item["subject_kind"]), str(item["subject_id"])): item for item in rows}
+ prints = {
+ item.lesson_id: ledger._fingerprint_now(project_id, item, results, events, by_row) # pyright: ignore[reportPrivateUsage]
+ for item in refs
+ }
+ reader = ledger._reader # pyright: ignore[reportPrivateUsage]
+ siblings = {
+ member: group for group in groups(read_same(reader, project_id)) for member in group
+ }
+ found: list[dict[str, object]] = []
+ for kind, subject_id in dict.fromkeys(
+ (r.context.subject_kind, r.context.subject_id) for r in refs
+ ):
+ now = ledger.row_now(project_id, kind, subject_id)
+ if now is None:
+ continue
+ for ref, state in recall_lessons(refs, now[0], prints, siblings):
+ if (ref.context.subject_kind, ref.context.subject_id) == (kind, subject_id):
+ item = ledger._item(ref, state, {i.event_id: i for i in results}, events) # pyright: ignore[reportPrivateUsage]
+ others = siblings.get(ref.hypothesis_id or "", frozenset()) - {ref.hypothesis_id}
+ found.append({**item, "same_hypothesis_ids": sorted(others)} if others else item)
+ return found
+
+
+@pytest.mark.asyncio
+async def test_the_lesson_list_reads_the_verdicts_once_and_answers_what_the_per_lesson_path_answers(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel(one=True, tested=True))
+ try:
+ await rpc.rain_arrives()
+ await investigate(runtime, rpc, RAIN)
+ hypothesis_id = await only_hypothesis(rpc)
+ await record(rpc, hypothesis_id, "test-1", "ALTERNATIVE")
+ await record(rpc, hypothesis_id, "test-2", "THIS_HYPOTHESIS")
+ await record(rpc, hypothesis_id, "test-2", "ALTERNATIVE") # the earlier result is replaced
+ await close(rpc, RAIN, "HUMAN_CLOSED", basis_ref="minutes-1")
+ await close(rpc, RAIN, "WAIVER_RECORDED", basis_ref="deviation-4")
+ await close(rpc, FOG, "HUMAN_CLOSED", basis_ref="minutes-2")
+ await close(rpc, FOG, "FIX_APPLIED", basis_ref="ECN-1")
+ await change_result(rpc, "SYN-RES-SYN-C-DET-RAIN", phase=2, value="0.95", numerator="19")
+ records = RequestRecords(runtime.ledger, cast(Any, None), SystemClock(), UuidIdGenerator())
+ ledger, closures = LessonLedger(records), TraceClosures(records)
+ rows = closures.view("p")
+ reads: list[str] = []
+ original = HypothesisLinkReader.current_verdicts
+
+ def counted(self: HypothesisLinkReader, project_id: str, trace: Any, *rest: Any) -> Any:
+ reads.append(project_id)
+ return original(self, project_id, trace, *rest)
+
+ monkeypatch.setattr(HypothesisLinkReader, "current_verdicts", counted)
+ answer = ledger.recall("p", rows)
+ assert len(reads) == 1 # not once per lesson
+ monkeypatch.setattr(HypothesisLinkReader, "current_verdicts", original)
+ assert len(answer) >= 6 # results, eliminations, closures and an effect, in several states
+ assert {item["state"] for item in answer} >= {"SAME_CONDITION", "STALE"}
+ assert answer == reference_recall(ledger, "p", rows)
+ assert (await rpc("trace/lesson/list", project_id="p"))["lessons"] == answer
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_the_csv_export_notes_closures_without_reading_every_rows_verdict(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel())
+ try:
+ await close(rpc, FOG, "HUMAN_CLOSED", basis_ref="minutes-2")
+ await close(rpc, FOG, "FIX_APPLIED", basis_ref="ECN-1")
+ listed = (await rpc("trace/closure/list", project_id="p"))["closures"]
+ reads: list[str] = []
+ original = HypothesisLinkReader.current_verdicts
+
+ def counted(self: HypothesisLinkReader, project_id: str, trace: Any, *rest: Any) -> Any:
+ reads.append(project_id)
+ return original(self, project_id, trace, *rest)
+
+ monkeypatch.setattr(HypothesisLinkReader, "current_verdicts", counted)
+ text = (await rpc("trace/export", project_id="p"))["csv_text"]
+ # the notes need the records and the confirmed effect, not each row's verdict
+ assert reads == []
+ monkeypatch.setattr(HypothesisLinkReader, "current_verdicts", original)
+ assert "이전 기록 1건(THOTH에서 확인)" in text and "수정 반영됨(효과 미확인)" in text
+ # the note is the same as the one the full view gives
+ from thoth.application.services.trace_closure_text import closure_status_cells
+
+ records = RequestRecords(runtime.ledger, cast(Any, None), SystemClock(), UuidIdGenerator())
+ closures = TraceClosures(records)
+ full = closure_status_cells(closures.view("p"))
+ assert full == closure_status_cells(closures.view("p", with_current=False))
+ assert all(note in text for note in full.values()) and len(listed) == 1
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_a_list_reads_the_heads_a_fixed_number_of_times_however_many_records_it_shows(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel(one=True, tested=True))
+ try:
+ await rpc.rain_arrives()
+ await investigate(runtime, rpc, RAIN)
+ hypothesis_id = await only_hypothesis(rpc)
+ await record(rpc, hypothesis_id, "test-1", "ALTERNATIVE")
+ await close(rpc, RAIN, "HUMAN_CLOSED", basis_ref="minutes-rain")
+ records = RequestRecords(runtime.ledger, cast(Any, None), SystemClock(), UuidIdGenerator())
+ ledger, closures = LessonLedger(records), TraceClosures(records)
+ calls: list[str] = []
+ original = type(runtime.ledger).read_heads
+
+ def counted(self: Any, project_id: str) -> Any:
+ calls.append(project_id)
+ return original(self, project_id)
+
+ monkeypatch.setattr(type(runtime.ledger), "read_heads", counted)
+
+ def reads() -> tuple[int, int, int]:
+ """Reads of the heads for the closure list, the lesson list and the link list."""
+ calls.clear()
+ rows = closures.view("p")
+ first = len(calls)
+ calls.clear()
+ ledger.recall("p", rows)
+ second = len(calls)
+ calls.clear()
+ HypothesisLinkReader(runtime.ledger).items("p")
+ return first, second, len(calls)
+
+ few = reads()
+ # more results, more closures and more lessons on more rows change nothing about the reads
+ await record(rpc, hypothesis_id, "test-2", "THIS_HYPOTHESIS")
+ await record(rpc, hypothesis_id, "test-1", "NEITHER")
+ for number in range(3):
+ await close(rpc, FOG, "HUMAN_CLOSED", basis_ref=f"minutes-fog-{number}")
+ many = reads()
+ assert many == few, (few, many)
+ assert few[0] <= 3 and few[1] <= 8, few
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_a_list_reads_the_verdicts_of_the_rows_it_shows_not_of_every_row(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel())
+ try:
+ await close(rpc, FOG, "HUMAN_CLOSED", basis_ref="minutes-fog") # one closed row of several
+ records = RequestRecords(runtime.ledger, cast(Any, None), SystemClock(), UuidIdGenerator())
+ closures = TraceClosures(records)
+ every_row = len(closures._reader.trace("p")[1].current_digests()) # pyright: ignore[reportPrivateUsage, reportOptionalMemberAccess]
+ read: list[str] = []
+ original = HypothesisLinkReader.content
+
+ def counted(
+ self: HypothesisLinkReader, project_id: str, kind: Any, entity_id: str, *rest: Any
+ ) -> Any:
+ if entity_id.startswith(REVISION_PREFIX):
+ read.append(entity_id) # the stored verdict of one row
+ return original(self, project_id, kind, entity_id, *rest)
+
+ monkeypatch.setattr(HypothesisLinkReader, "content", counted)
+ listed = closures.view("p")
+ assert [item["subject_id"] for item in listed] == [FOG]
+ assert every_row > 2 and len(read) == 1, (len(read), every_row)
+ finally:
+ runtime.close()
diff --git a/tests/integration/test_r2_replay_guard.py b/tests/integration/test_r2_replay_guard.py
new file mode 100644
index 0000000..14ebbbb
--- /dev/null
+++ b/tests/integration/test_r2_replay_guard.py
@@ -0,0 +1,342 @@
+"""A sandbox test whose result was not settled is never run again by accident (R2 replay guard).
+
+The same work is the project, the research object and the action. After a run whose finalization
+failed, asking again with other input files is held for reconciliation, and the person can clear
+the unsettled result (with a reason) so that exactly one new run is allowed.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any, cast
+
+import pytest
+from tests.integration.test_hypothesis_prediction_outcome_cycle import (
+ prepare_measurement,
+ request,
+ value,
+)
+
+from thoth.adapters.storage.execution import SqliteExecutionStore
+from thoth.adapters.storage.test_validity import SqliteTestValidityStore
+from thoth.application.services.execution_pending import PENDING_RESULT_CLEARED_EVENT
+from thoth.apps.runtime import AppRuntime, create_runtime
+from thoth.domain.test_validity import TestValidityAssessment as ValidityAssessment
+
+Json = dict[str, Any]
+
+
+async def held_after_restart(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> tuple[AppRuntime, Any, str, str, Json]:
+ """One real sandbox run whose finalization fails; then the same question after a restart."""
+ from tests.integration.test_a02_autonomous_acquisition import StaticModelResolver
+ from tests.integration.test_a04_r2_closed_loop import A04R2Model
+
+ from thoth.ports.model import ModelPort
+
+ runtime, sandbox, project, thread, _contract = await prepare_measurement(tmp_path, monkeypatch)
+ add = SqliteTestValidityStore.add_assessment
+ calls = 0
+
+ def fail_second(self: SqliteTestValidityStore, assessment: ValidityAssessment) -> None:
+ nonlocal calls
+ calls += 1
+ add(self, assessment)
+ if calls == 2:
+ raise RuntimeError("injected assessment finalization failure")
+
+ try:
+ with monkeypatch.context() as patch:
+ patch.setattr(SqliteTestValidityStore, "add_assessment", fail_second)
+ first = value(
+ await runtime.bus.dispatch(
+ request(
+ "thread/input", "rg-first", {"project_id": project, "thread_id": thread}
+ )
+ )
+ )
+ assert first["r2_closed_loop"]["reason_code"] == "RESEARCH_TEST_FINALIZATION_FAILED"
+ finally:
+ runtime.close()
+ reopened = create_runtime(
+ tmp_path / "allowed",
+ sandbox_adapter=sandbox,
+ model_resolver=StaticModelResolver(cast(ModelPort, A04R2Model())),
+ )
+ again = value(
+ await reopened.bus.dispatch(
+ request("thread/input", "rg-again", {"project_id": project, "thread_id": thread})
+ )
+ )
+ return reopened, sandbox, project, thread, again["r2_closed_loop"]
+
+
+@pytest.mark.asyncio
+async def test_the_hold_names_the_unsettled_run_the_input_difference_and_the_exits(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ runtime, sandbox, project, _thread, held = await held_after_restart(tmp_path, monkeypatch)
+ try:
+ assert held["terminal_state"] == "HOLD"
+ assert held["reason_code"] == "RESEARCH_TEST_RESULT_RECONCILIATION_REQUIRED"
+ assert len(sandbox.seen_specs) == 1
+ pending = held["pending_result"]
+ assert pending["attempt_id"] == sandbox.seen_specs[0].attempt_id
+ assert pending["attempt_state"] == "SUCCEEDED"
+ assert pending["observation_completeness"] == "NOT_ADMITTED"
+ difference = pending["input_difference"]
+ assert {"same", "previous_count", "current_count", "removed", "added"} <= set(difference)
+ # The one way out is to clear and run again (decision 20261003-02); nothing handles a
+ # "finish with the existing result" exit, so it is not offered.
+ assert pending["exits"] == ["CLEAR_AND_RERUN"]
+ assert "finish_possible" not in pending
+ # the earlier run and its result are kept as they were
+ store = SqliteExecutionStore(runtime.ledger.engine)
+ attempt = store.read_attempt(project, pending["attempt_id"])
+ assert attempt is not None and attempt.observation_completeness == "NOT_ADMITTED"
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_clearing_records_who_why_and_the_difference_and_allows_exactly_one_new_run(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ runtime, sandbox, project, thread, held = await held_after_restart(tmp_path, monkeypatch)
+ try:
+ pending = held["pending_result"]
+ execution_id = pending["plan_execution_id"]
+ base: Json = {
+ "project_id": project,
+ "plan_execution_id": execution_id,
+ "cause_revision_ref": "r2-replay-guard",
+ "impact_refs": [],
+ "clear_pending_attempt_id": pending["attempt_id"],
+ "reason": "The input files were changed on purpose before running again",
+ "input_difference": pending["input_difference"],
+ }
+ # without the revision that was read, or with an old one, nothing is cleared
+ for key, extra in (("rg-no-rev", {}), ("rg-old-rev", {"expected_execution_revision": 0})):
+ response = await runtime.bus.dispatch(
+ request("execution/invalidate", key, {**base, **extra})
+ )
+ assert response.error is not None
+ no_reason = await runtime.bus.dispatch(
+ request(
+ "execution/invalidate",
+ "rg-no-reason",
+ {
+ **{k: v for k, v in base.items() if k != "reason"},
+ "expected_execution_revision": pending["execution_revision"],
+ },
+ )
+ )
+ assert no_reason.error is not None
+ assert len(sandbox.seen_specs) == 1
+ cleared = value(
+ await runtime.bus.dispatch(
+ request(
+ "execution/invalidate",
+ "rg-clear",
+ {**base, "expected_execution_revision": pending["execution_revision"]},
+ )
+ )
+ )
+ assert cleared["execution"]["state"] == "INVALIDATED"
+ store = SqliteExecutionStore(runtime.ledger.engine)
+ records = [
+ item
+ for item in store.list_audit(project, execution_id)
+ if item.event_type == PENDING_RESULT_CLEARED_EVENT
+ ]
+ assert len(records) == 1
+ payload = records[0].payload
+ assert payload["attempt_id"] == pending["attempt_id"]
+ assert payload["reason"] == base["reason"] and payload["cleared_by"]
+ assert payload["input_difference"] == pending["input_difference"]
+ assert records[0].created_at is not None
+ # the earlier attempt and its result are still there, unchanged
+ old = store.read_attempt(project, pending["attempt_id"])
+ assert old is not None and old.observation_completeness == "NOT_ADMITTED"
+ rerun = value(
+ await runtime.bus.dispatch(
+ request("thread/input", "rg-rerun", {"project_id": project, "thread_id": thread})
+ )
+ )
+ assert len(sandbox.seen_specs) == 2
+ assert rerun["r2_closed_loop"]["terminal_state"] == "COMPLETED", rerun["r2_closed_loop"]
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_a_settled_attempt_cannot_be_cleared(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ runtime, sandbox, project, thread, _contract = await prepare_measurement(tmp_path, monkeypatch)
+ try:
+ done = value(
+ await runtime.bus.dispatch(
+ request("thread/input", "rg-settled", {"project_id": project, "thread_id": thread})
+ )
+ )
+ assert done["r2_closed_loop"]["terminal_state"] == "COMPLETED"
+ store = SqliteExecutionStore(runtime.ledger.engine)
+ attempt = store.read_attempt(project, sandbox.seen_specs[0].attempt_id)
+ assert attempt is not None and attempt.observation_completeness != "NOT_ADMITTED"
+ execution = store.read_execution(project, attempt.plan_execution_id)
+ assert execution is not None
+ response = await runtime.bus.dispatch(
+ request(
+ "execution/invalidate",
+ "rg-settled-clear",
+ {
+ "project_id": project,
+ "plan_execution_id": execution.plan_execution_id,
+ "cause_revision_ref": "r2-replay-guard",
+ "impact_refs": [],
+ "clear_pending_attempt_id": attempt.attempt_id,
+ "expected_execution_revision": execution.revision,
+ "reason": "should not be needed",
+ },
+ )
+ )
+ assert response.error is not None and "settled" in response.error.message
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_the_held_result_cannot_be_admitted_again_so_finishing_from_it_needs_a_new_rule(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Why "finish from the leftover result" is not built on the existing admission.
+
+ A sandbox observation is admitted only while the project HeadSet is the one it ran under. The
+ hold itself revises the execution record, which is part of the HeadSet, so the held result's
+ basis is already out of date and the same admission would refuse it.
+ """
+ import json
+
+ from tests.integration.storage_coverage_helpers import domain_snapshot
+
+ from thoth.domain.canonical import head_set_digest
+ from thoth.domain.control_record import ControlRecord
+
+ runtime, sandbox, project, _thread, held = await held_after_restart(tmp_path, monkeypatch)
+ try:
+ attempt_id = held["pending_result"]["attempt_id"]
+ rows = domain_snapshot(runtime.ledger.engine)["control_records"]
+ held_admission = [
+ record
+ for record in (
+ ControlRecord.model_validate_json(json.loads(row)["content_json"]) for row in rows
+ )
+ if record.record_id == f"sandbox-admission:{attempt_id}"
+ ]
+ assert held_admission and held_admission[-1].state == "HELD"
+ basis = held_admission[-1].payload["basis"]
+ assert isinstance(basis, dict)
+ assert basis["head_set_digest"] != head_set_digest(runtime.ledger.read_heads(project))
+ assert len(sandbox.seen_specs) == 1
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_the_hold_reaches_the_stored_result_that_the_screen_reads(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """The normal (v2) input path publishes the hold; thread/read returns it as execution_hold."""
+ from pydantic import BaseModel
+ from tests.integration.test_a02_autonomous_acquisition import StaticModelResolver
+ from tests.integration.test_a04_r2_closed_loop import A04R2Model
+ from tests.integration.test_research_request_v2 import ControlledResearchModel
+
+ from thoth.domain.enums import ModelRole
+ from thoth.domain.model import ModelRequest, ModelResult
+ from thoth.domain.model_dispatch import CONTROLLED_MODEL_CONTROL
+ from thoth.ports.model import ModelPort
+
+ runtime, sandbox, project, thread, _contract = await prepare_measurement(
+ tmp_path, monkeypatch, choose_read_after_test=True
+ )
+ auxiliary = ControlledResearchModel()
+ existing = A04R2Model.structured
+
+ async def combined(self: A04R2Model, call: ModelRequest[BaseModel]) -> ModelResult[BaseModel]:
+ if call.role in {
+ ModelRole.RESEARCH_PLANNER,
+ ModelRole.EVIDENCE_RERANKER,
+ ModelRole.SEMANTIC_REVIEWER,
+ ModelRole.REVIEW_ADJUDICATOR,
+ ModelRole.SOURCE_PLANNER,
+ ModelRole.HYPOTHESIS_REVIEWER,
+ }:
+ return await auxiliary.structured(call)
+ return await existing(self, call)
+
+ monkeypatch.setattr(A04R2Model, "structured", combined)
+ monkeypatch.setattr(A04R2Model, "control_capability", CONTROLLED_MODEL_CONTROL, raising=False)
+ add = SqliteTestValidityStore.add_assessment
+ calls = 0
+
+ def fail_second(self: SqliteTestValidityStore, assessment: ValidityAssessment) -> None:
+ nonlocal calls
+ calls += 1
+ add(self, assessment)
+ if calls == 2:
+ raise RuntimeError("injected assessment finalization failure")
+
+ async def ask(active: AppRuntime, key: str) -> None:
+ accepted = value(
+ await active.bus.dispatch(
+ request(
+ "thread/input",
+ key,
+ {
+ "project_id": project,
+ "thread_id": thread,
+ "contract_version": 2,
+ "instruction": "Use the sealed measurement protocol.",
+ },
+ )
+ )
+ )
+ await active.bus.drain()
+ assert active.bus.read_operation(accepted["operation_id"]) is not None
+
+ try:
+ with monkeypatch.context() as patch:
+ patch.setattr(SqliteTestValidityStore, "add_assessment", fail_second)
+ await ask(runtime, "rgv2-first")
+ finally:
+ runtime.close()
+ reopened = create_runtime(
+ tmp_path / "allowed",
+ sandbox_adapter=sandbox,
+ model_resolver=StaticModelResolver(cast(ModelPort, A04R2Model())),
+ )
+ try:
+ await ask(reopened, "rgv2-again")
+ assert len(sandbox.seen_specs) == 1
+ read = value(
+ await reopened.bus.dispatch(
+ request(
+ "thread/read",
+ "rgv2-read",
+ {"project_id": project, "thread_id": thread, "contract_version": 2},
+ )
+ )
+ )
+ assert read["answer_outcome"]["state"] == "HOLD", read.get("answer_outcome")
+ shown = read["execution_hold"]
+ assert shown["exits"] == ["CLEAR_AND_RERUN"]
+ assert shown["attempt_state"] == "SUCCEEDED"
+ store = SqliteExecutionStore(reopened.ledger.engine)
+ assert shown["attempt_id"] == sandbox.seen_specs[0].attempt_id
+ execution = store.read_execution(project, shown["plan_execution_id"])
+ assert execution is not None and execution.revision == shown["execution_revision"]
+ finally:
+ reopened.close()
diff --git a/tests/integration/test_research_cycle_restore.py b/tests/integration/test_research_cycle_restore.py
index 351d05a..43e0996 100644
--- a/tests/integration/test_research_cycle_restore.py
+++ b/tests/integration/test_research_cycle_restore.py
@@ -266,14 +266,6 @@ def fail_second(self: SqliteHypothesisStore, prediction: PredictionRecord) -> No
reopened.close()
-@pytest.mark.xfail(
- strict=True,
- reason=(
- "Known baseline recovery issue (decision record 2026-10-03): when finalization fails and "
- "the input bundle shrinks on reopen, the exact-input guard admits a second sandbox run. "
- "The fix needs a resolve-or-rerun path and is deferred."
- ),
-)
@pytest.mark.asyncio
async def test_finalization_fault_keeps_real_execution_but_never_replays_it_after_restart(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
diff --git a/tests/integration/test_research_request_v2.py b/tests/integration/test_research_request_v2.py
index 92e4e71..f7b40c0 100644
--- a/tests/integration/test_research_request_v2.py
+++ b/tests/integration/test_research_request_v2.py
@@ -30,6 +30,7 @@
ModelRole,
PortfolioStatus,
Reversibility,
+ RiskTier,
)
from thoth.domain.evidence_requirements import (
EvidenceRanking,
@@ -45,7 +46,7 @@
SemanticReviewDecision,
SourceSelector,
)
-from thoth.domain.hypothesis import Hypothesis, HypothesisPortfolio
+from thoth.domain.hypothesis import DiscriminatingTest, Hypothesis, HypothesisPortfolio
from thoth.domain.model import ModelRequest, ModelResult
from thoth.domain.model_dispatch import CONTROLLED_MODEL_CONTROL
@@ -61,6 +62,7 @@ def __init__(
one: bool = False,
na: bool = False,
discover: bool = False,
+ tested: bool = False,
) -> None:
self.started = asyncio.Event()
self.release = asyncio.Event()
@@ -68,6 +70,7 @@ def __init__(
self.release.set()
self.missing = missing
self.one, self.na, self.discover = one, na, discover
+ self.tested = tested
self.calls: list[ModelRequest[BaseModel]] = []
def resolve(self, *, provider: str, model: str | None = None):
@@ -178,7 +181,28 @@ async def structured[T: BaseModel](self, request: ModelRequest[T]) -> ModelResul
assumptions=("Conditions remain stable",),
uncertainty="Not validated",
predicted_observations=(),
- discriminating_tests=(),
+ discriminating_tests=(
+ (
+ DiscriminatingTest(
+ test_id="test-1",
+ procedure_candidate="Compare the rain run with a dry run",
+ expected_if_true="The rain run is worse",
+ expected_if_alternative="Both runs match",
+ risk_tier=RiskTier.R0, # the model's own word; never used
+ reversibility=Reversibility.FULL,
+ ),
+ DiscriminatingTest(
+ test_id="test-2",
+ procedure_candidate="Repeat the rain run with the other sensor",
+ expected_if_true="The rain run is worse again",
+ expected_if_alternative="Both runs match",
+ risk_tier=RiskTier.R0,
+ reversibility=Reversibility.FULL,
+ ),
+ )
+ if self.tested
+ else ()
+ ),
status=HypothesisStatus.DRAFT,
),
),
diff --git a/tests/integration/test_source_list_cost.py b/tests/integration/test_source_list_cost.py
new file mode 100644
index 0000000..1a4edf9
--- /dev/null
+++ b/tests/integration/test_source_list_cost.py
@@ -0,0 +1,77 @@
+"""The source list does not read every document's nodes to show each source's time.
+
+A source's time assessment is kept in the version's structure metadata, so the list reads only
+that. Counting statements (not seconds) keeps this from depending on the machine.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import cast
+
+import pytest
+from pydantic import JsonValue
+from sqlalchemy import event
+from tests.integration.storage_coverage_helpers import prepare_project, request, value
+
+from thoth.adapters.storage.artifacts import SqliteArtifactLedger
+
+
+@pytest.mark.asyncio
+async def test_listing_sources_reads_no_document_nodes_and_matches_the_full_read(
+ tmp_path: Path,
+) -> None:
+ inbox = tmp_path / "inbox"
+ inbox.mkdir(parents=True)
+ for n in range(3):
+ (inbox / f"evidence{n}.md").write_text(
+ "# Result\n\n" + "\n\n".join(f"Paragraph {n}-{k} about a clock." for k in range(40)),
+ encoding="utf-8",
+ )
+ runtime, project = await prepare_project(tmp_path)
+ try:
+ for n in range(3):
+ value(
+ await runtime.bus.dispatch(
+ request(
+ "project/source/connect",
+ f"sl-connect-{n}",
+ {
+ "project_id": project,
+ "relative_path": f"evidence{n}.md",
+ "media_type": "text/markdown",
+ "authority": "OFFICIAL",
+ "cutoff_state": "ELIGIBLE",
+ "security_class": "INTERNAL",
+ },
+ )
+ )
+ )
+ statements: list[str] = []
+
+ def record(conn: object, cursor: object, statement: str, *rest: object) -> None:
+ statements.append(statement)
+
+ engine = runtime.ledger.engine
+ event.listen(engine, "before_cursor_execute", record)
+ try:
+ listed = value(
+ await runtime.bus.query(
+ request("project/source/list", "sl-list", {"project_id": project})
+ )
+ )
+ finally:
+ event.remove(engine, "before_cursor_execute", record)
+ times = cast(list[dict[str, JsonValue]], listed["source_times"])
+ assert len(times) == 3
+ assert not [s for s in statements if "structural_nodes" in s]
+ raw = SqliteArtifactLedger(engine)
+ for item in times:
+ full = raw.read_source_time(
+ project, str(item["artifact_id"]), str(item["source_version_id"])
+ )
+ assert full is not None
+ expected = full.model_dump(mode="json")
+ assert {k: item[k] for k in expected} == expected
+ finally:
+ runtime.close()
diff --git a/tests/integration/test_trace_csv.py b/tests/integration/test_trace_csv.py
new file mode 100644
index 0000000..ee9cdd7
--- /dev/null
+++ b/tests/integration/test_trace_csv.py
@@ -0,0 +1,786 @@
+"""The trace CSV through the public methods: export, preview, apply, and what must not happen."""
+
+from __future__ import annotations
+
+import csv
+import io
+import json
+import random
+from itertools import pairwise
+from pathlib import Path
+from typing import Any
+
+import pytest
+from tests.integration.storage_coverage_helpers import request, value
+from tests.integration.test_research_request_v2 import ControlledResearchModel, setup
+from tests.integration.trace_demo_helpers import NOTICE, REQ, demo_set, with_notice
+
+from thoth.application.services import verification_trace as trace_module
+from thoth.application.services.trace_csv import (
+ BOM,
+ FORMAT,
+ export_csv,
+ export_rows,
+ write_csv,
+)
+from thoth.apps.runtime import AppRuntime
+from thoth.protocol.bus import READ_QUERY_METHODS
+from thoth.protocol.jsonrpc import JsonRpcResponse
+
+FIXTURES = Path(__file__).parents[1] / "fixtures" / "trace_csv"
+
+
+class Rpc:
+ """Calls the way a client does: reads are queries, changes are commands with a key."""
+
+ def __init__(self, runtime: AppRuntime) -> None:
+ self.runtime, self.count = runtime, 0
+
+ async def raw(self, method: str, **params: Any) -> JsonRpcResponse:
+ self.count += 1
+ message = request(method, f"k{self.count}", params)
+ if method in READ_QUERY_METHODS:
+ return await self.runtime.bus.query(message)
+ return await self.runtime.bus.dispatch(message)
+
+ async def __call__(self, method: str, **params: Any) -> dict[str, Any]:
+ return value(await self.raw(method, **params))
+
+ async def error(self, method: str, **params: Any) -> str:
+ response = await self.raw(method, **params)
+ assert response.error is not None, "expected an error"
+ return response.error.message
+
+ async def project(self, name: str) -> str:
+ await self(
+ "project/create",
+ project_id=name,
+ name=name,
+ cutoff_at="2026-09-13T00:00:00Z",
+ )
+ return name
+
+ async def load(self, project: str, text: str, mode: str = "CREATE") -> dict[str, Any]:
+ """Preview, then apply exactly what was previewed."""
+ preview = await self("trace/importPreview", project_id=project, mode=mode, csv_text=text)
+ assert preview["applicable"], preview["conflicts"]
+ return await self(
+ "trace/importApply",
+ project_id=project,
+ mode=mode,
+ csv_text=text,
+ preview_id=preview["preview_id"],
+ input_sha256=preview["input_sha256"],
+ )
+
+
+async def opened(tmp_path: Path) -> tuple[AppRuntime, Rpc]:
+ runtime = await setup(tmp_path, ControlledResearchModel(), source=False)
+ return runtime, Rpc(runtime)
+
+
+def fixture(name: str) -> str:
+ return (FIXTURES / name).read_bytes().decode("utf-8")
+
+
+def table(text: str) -> tuple[list[str], list[list[str]]]:
+ lines = list(csv.reader(io.StringIO(text.removeprefix(BOM), newline="")))
+ return lines[0], lines[1:]
+
+
+def rebuild(
+ header: list[str], rows: list[list[str]], *, eol: str = "\r\n", bom: bool = True
+) -> str:
+ buffer = io.StringIO()
+ writer = csv.writer(buffer, lineterminator=eol)
+ writer.writerow(header)
+ writer.writerows(rows)
+ return (BOM if bom else "") + buffer.getvalue()
+
+
+def changes(preview: dict[str, Any]) -> tuple[int, int, int]:
+ found = preview["changes"]
+ return len(found["added"]), len(found["updated"]), len(found["deleted"])
+
+
+def row_key(row: dict[str, str]) -> tuple[str, str]:
+ return (row["row_type"], row.get("item_id") or row.get("link_id") or row.get("result_id", ""))
+
+
+async def stored_trace_bytes(runtime: AppRuntime, project: str) -> int:
+ """Size of the trace record as the ledger stored it last (not of the whole ledger)."""
+ digest = runtime.ledger.read_heads(project)["THREAD:verification-trace:project"]
+ revision = runtime.ledger.read_revision_by_digest(project, digest)
+ assert revision is not None
+ snapshot = runtime.ledger.read_snapshot(revision.snapshot_id)
+ assert snapshot is not None
+ return len(json.dumps(dict(snapshot.content), default=str))
+
+
+def codes(preview: dict[str, Any]) -> set[str]:
+ return {item["code"] for item in preview["conflicts"]}
+
+
+# --- round trip ---
+
+
+@pytest.mark.asyncio
+async def test_an_exported_file_imports_back_with_no_change(tmp_path: Path) -> None:
+ runtime, rpc = await opened(tmp_path)
+ try:
+ await rpc.load(await rpc.project("a"), export_csv(demo_set(2)))
+ exported = await rpc("trace/export", project_id="a")
+ text = exported["csv_text"]
+ assert text.startswith(BOM + "format,row_type,base_set_digest")
+ assert exported["format"] == FORMAT and exported["loss_manifest"]
+ preview = await rpc("trace/importPreview", project_id="a", mode="UPDATE", csv_text=text)
+ assert preview["applicable"] and changes(preview) == (0, 0, 0)
+ assert preview["changes"]["unchanged"] > 0
+ applied = await rpc.load("a", text, "UPDATE")
+ assert applied["applied"] is False and applied["reason"] == "NO_CHANGES"
+ assert (await rpc("trace/read", project_id="a"))["set_digest"] == exported["set_digest"]
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_row_and_column_order_blank_lines_and_the_byte_order_mark_do_not_matter(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path)
+ try:
+ await rpc.load(await rpc.project("a"), export_csv(demo_set(2)))
+ header, rows = table((await rpc("trace/export", project_id="a"))["csv_text"])
+ generator = random.Random(7)
+ columns = list(range(len(header)))
+ generator.shuffle(columns)
+ shuffled_header = [header[at] for at in columns]
+ shuffled = [[row[at] for at in columns] for row in rows]
+ generator.shuffle(shuffled)
+ padded: list[list[str]] = []
+ for at, row in enumerate(shuffled):
+ padded.append(row)
+ if at % 3 == 0:
+ padded.append([])
+ padded.append([""] * len(header))
+ variants = {
+ "shuffled": rebuild(shuffled_header, padded),
+ "no mark": rebuild(shuffled_header, padded, bom=False),
+ "lf only": rebuild(header, rows, eol="\n"),
+ "ordinary": rebuild(header, rows),
+ }
+ for name, text in variants.items():
+ preview = await rpc("trace/importPreview", project_id="a", mode="UPDATE", csv_text=text)
+ assert preview["applicable"], (name, preview["conflicts"])
+ assert changes(preview) == (0, 0, 0), name
+ finally:
+ runtime.close()
+
+
+# --- exchange fixtures ---
+
+
+@pytest.mark.asyncio
+async def test_ids_with_leading_zeros_long_digits_and_date_shapes_are_kept_exactly(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path)
+ try:
+ expected = {
+ "ids_leading_zero.csv": {"007", "0012", "00"},
+ "ids_long_numeric.csv": {"12345678901234567890", "1234567890123456789012", "1.5E+3"},
+ "ids_date_like.csv": {"1-2", "2026-10", "3/4"},
+ }
+ for number, (name, ids) in enumerate(expected.items()):
+ project = await rpc.project(f"ids{number}")
+ await rpc.load(project, fixture(name))
+ first = await rpc("trace/read", project_id=project)
+ assert {item["item_id"] for item in first["items"]} == ids
+ again = await rpc("trace/export", project_id=project)
+ preview = await rpc(
+ "trace/importPreview", project_id=project, mode="UPDATE", csv_text=again["csv_text"]
+ )
+ assert changes(preview) == (0, 0, 0) and preview["applicable"], name
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_text_that_starts_like_a_formula_is_written_safely_and_read_back_exactly(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path)
+ try:
+ project = await rpc.project("formula")
+ await rpc.load(project, fixture("formula_prefix_escaped.csv"))
+ titles = {
+ item["item_id"]: item["title"]
+ for item in (await rpc("trace/read", project_id=project))["items"]
+ }
+ assert titles == {
+ "F-1": "=1+1",
+ "F-2": "+SUM(A1)",
+ "F-3": "-2+3",
+ "F-4": "@cmd",
+ "F-5": "\tTab first",
+ "F-6": "\rCR first",
+ "F-7": "'quote that is real text",
+ "F-8": "'=two apostrophes then formula",
+ }
+ text = (await rpc("trace/export", project_id=project))["csv_text"]
+ _, rows = table(text)
+ written = {row[3]: row[11] for row in rows}
+ assert all(
+ cell.startswith("'")
+ for key, cell in written.items()
+ if key in {"F-1", "F-2", "F-3", "F-4", "F-5", "F-6"}
+ )
+ assert written["F-7"] == "'quote that is real text" # not a formula, left as it was
+ assert written["F-8"] == "''=two apostrophes then formula"
+ preview = await rpc("trace/importPreview", project_id=project, mode="UPDATE", csv_text=text)
+ assert changes(preview) == (0, 0, 0)
+ raw = await rpc.project("rawformula")
+ await rpc.load(raw, fixture("formula_prefix_raw.csv")) # typed without the apostrophe
+ items = (await rpc("trace/read", project_id=raw))["items"]
+ assert {item["title"] for item in items} >= {"=1+1", "@cmd"}
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_korean_text_quotes_commas_and_line_breaks_survive(tmp_path: Path) -> None:
+ runtime, rpc = await opened(tmp_path)
+ try:
+ project = await rpc.project("korean")
+ await rpc.load(project, fixture("korean_special.csv"))
+ titles = {
+ item["item_id"]: item["title"]
+ for item in (await rpc("trace/read", project_id=project))["items"]
+ }
+ assert titles["REQ-한글-1"] == "탐지율은 0.90 이상, 비 오는 날 포함"
+ assert titles["REQ-2"] == '따옴표 "안의" 글자와 쉼표, 그리고 줄바꿈\n두 번째 줄'
+ again = (await rpc("trace/export", project_id=project))["csv_text"]
+ preview = await rpc(
+ "trace/importPreview", project_id=project, mode="UPDATE", csv_text=again
+ )
+ assert changes(preview) == (0, 0, 0)
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_the_same_content_with_or_without_a_mark_or_in_another_order_is_the_same_trace(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path)
+ try:
+ digests: set[str] = set()
+ for number, name in enumerate(
+ ("order_baseline.csv", "bom_utf8.csv", "no_bom_utf8.csv", "order_shuffled.csv")
+ ):
+ project = await rpc.project(f"same{number}")
+ await rpc.load(project, fixture(name))
+ digests.add((await rpc("trace/read", project_id=project))["set_digest"])
+ assert len(digests) == 1
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_zero_blank_null_and_quoted_values_are_told_apart(tmp_path: Path) -> None:
+ runtime, rpc = await opened(tmp_path)
+ try:
+ project = await rpc.project("zeros")
+ await rpc.load(project, fixture("zero_empty_null.csv"))
+ results = {
+ item["result_id"]: item
+ for item in (await rpc("trace/read", project_id=project))["results"]
+ }
+ assert (results["Z-1"]["value"], results["Z-1"]["raw_value"]) == ("0", None)
+ assert (results["Z-2"]["value"], results["Z-2"]["raw_value"]) == (
+ "0",
+ None,
+ ) # quotes are CSV syntax
+ assert (results["Z-3"]["value"], results["Z-3"]["raw_value"]) == (None, None)
+ assert (results["Z-4"]["value"], results["Z-4"]["raw_value"]) == (None, "null")
+ assert (results["Z-5"]["value"], results["Z-5"]["raw_value"]) == (None, None)
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_a_repeated_id_is_a_conflict_and_nothing_is_saved(tmp_path: Path) -> None:
+ runtime, rpc = await opened(tmp_path)
+ try:
+ project = await rpc.project("dup")
+ text = fixture("ids_duplicate.csv")
+ preview = await rpc("trace/importPreview", project_id=project, mode="CREATE", csv_text=text)
+ assert not preview["applicable"] and "DUPLICATE_ID_IN_FILE" in codes(preview)
+ message = await rpc.error(
+ "trace/importApply",
+ project_id=project,
+ mode="CREATE",
+ csv_text=text,
+ preview_id=preview["preview_id"],
+ input_sha256=preview["input_sha256"],
+ )
+ assert "TRACE_IMPORT_NOT_APPLICABLE" in message
+ assert (await rpc("trace/read", project_id=project))["record_digest"] is None
+ finally:
+ runtime.close()
+
+
+# --- when an import is refused ---
+
+
+@pytest.mark.asyncio
+async def test_a_file_made_from_another_revision_is_refused_as_stale_base(tmp_path: Path) -> None:
+ runtime, rpc = await opened(tmp_path)
+ try:
+ await rpc.load(await rpc.project("a"), export_csv(demo_set(1)))
+ old = (await rpc("trace/export", project_id="a"))["csv_text"]
+ header, rows = table(old)
+ extra = [*rows[0]]
+ extra[header.index("item_id")] = "SYN-NEW"
+ extra[header.index("item_key")] = "k-new"
+ extra[header.index("title")] = "added later"
+ await rpc.load("a", rebuild(header, [*rows, extra]), "UPDATE")
+ preview = await rpc("trace/importPreview", project_id="a", mode="UPDATE", csv_text=old)
+ assert not preview["applicable"] and codes(preview) == {"STALE_BASE"}
+ message = await rpc.error(
+ "trace/importApply",
+ project_id="a",
+ mode="UPDATE",
+ csv_text=old,
+ preview_id=preview["preview_id"],
+ input_sha256=preview["input_sha256"],
+ )
+ assert "STALE_BASE" in message
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_rows_that_are_missing_from_the_file_are_not_deletes(tmp_path: Path) -> None:
+ runtime, rpc = await opened(tmp_path)
+ try:
+ await rpc.load(await rpc.project("a"), export_csv(demo_set(2)))
+ header, rows = table((await rpc("trace/export", project_id="a"))["csv_text"])
+ before = await rpc("trace/read", project_id="a")
+ preview = await rpc(
+ "trace/importPreview", project_id="a", mode="UPDATE", csv_text=rebuild(header, rows[:2])
+ )
+ assert preview["applicable"] and changes(preview) == (0, 0, 0)
+ applied = await rpc.load("a", rebuild(header, rows[:2]), "UPDATE")
+ assert applied["applied"] is False
+ after = await rpc("trace/read", project_id="a")
+ assert (after["set_digest"], len(after["items"])) == (
+ before["set_digest"],
+ len(before["items"]),
+ )
+ finally:
+ runtime.close()
+
+
+def delete_row(header: list[str], digest: str, kind: str, target: str) -> list[str]:
+ row = [""] * len(header)
+ row[header.index("format")] = FORMAT
+ row[header.index("row_type")] = "DELETE"
+ row[header.index("base_set_digest")] = digest
+ row[header.index("target_kind")] = kind
+ row[header.index("target_id")] = target
+ return row
+
+
+@pytest.mark.asyncio
+async def test_an_item_that_is_still_linked_cannot_be_deleted_but_a_clean_delete_works(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path)
+ try:
+ await rpc.load(await rpc.project("a"), fixture("order_baseline.csv"))
+ exported = await rpc("trace/export", project_id="a")
+ header, _ = table(exported["csv_text"])
+ digest = exported["set_digest"]
+ blocked = rebuild(header, [delete_row(header, digest, "ITEM", "TC-1")])
+ preview = await rpc("trace/importPreview", project_id="a", mode="UPDATE", csv_text=blocked)
+ assert not preview["applicable"] and codes(preview) == {"DELETE_BLOCKED_BY_LINK"}
+ message = await rpc.error(
+ "trace/importApply",
+ project_id="a",
+ mode="UPDATE",
+ csv_text=blocked,
+ preview_id=preview["preview_id"],
+ input_sha256=preview["input_sha256"],
+ )
+ assert "DELETE_BLOCKED_BY_LINK" in message
+ assert (await rpc("trace/read", project_id="a"))["set_digest"] == digest
+ clean = rebuild(
+ header,
+ [
+ delete_row(header, digest, "LINK", "L-2"),
+ delete_row(header, digest, "ITEM", "TC-1"),
+ ],
+ )
+ applied = await rpc.load("a", clean, "UPDATE")
+ assert applied["counts"]["deleted"] == 2
+ left = await rpc("trace/read", project_id="a")
+ assert {item["item_id"] for item in left["items"]} == {"REQ-1", "CRIT-1"}
+ assert {item["link_id"] for item in left["links"]} == {"L-1"}
+ missing = rebuild(header, [delete_row(header, left["set_digest"], "ITEM", "NOPE")])
+ gone = await rpc("trace/importPreview", project_id="a", mode="UPDATE", csv_text=missing)
+ assert codes(gone) == {"DELETE_TARGET_NOT_FOUND"}
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_a_bad_row_or_a_failure_while_applying_saves_nothing(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ runtime, rpc = await opened(tmp_path)
+ try:
+ await rpc.load(await rpc.project("a"), fixture("order_baseline.csv"))
+ exported = await rpc("trace/export", project_id="a")
+ header, rows = table(exported["csv_text"])
+ before = await rpc("trace/read", project_id="a")
+ good = [*rows[0]]
+ good[header.index("item_id")], good[header.index("item_key")] = "NEW-1", "k-new-1"
+ bad = [*rows[5]] # the rule row, with a threshold that is not a number
+ bad[header.index("threshold")] = "abc"
+ text = rebuild(header, [good, bad])
+ preview = await rpc("trace/importPreview", project_id="a", mode="UPDATE", csv_text=text)
+ assert not preview["applicable"] and codes(preview) == {"ROW_INVALID"}
+ assert "TRACE_IMPORT_NOT_APPLICABLE" in await rpc.error(
+ "trace/importApply",
+ project_id="a",
+ mode="UPDATE",
+ csv_text=text,
+ preview_id=preview["preview_id"],
+ input_sha256=preview["input_sha256"],
+ )
+ assert (await rpc("trace/read", project_id="a"))["record_digest"] == before["record_digest"]
+
+ # A good file whose verdict step fails: the new set must not be stored either.
+ text = rebuild(header, [good])
+ preview = await rpc("trace/importPreview", project_id="a", mode="UPDATE", csv_text=text)
+ assert preview["applicable"] and changes(preview) == (1, 0, 0)
+
+ def fail(*args: object, **kwargs: object) -> None:
+ raise RuntimeError("verdict step failed")
+
+ monkeypatch.setattr(trace_module, "compute_verdicts", fail)
+ response = await rpc.raw(
+ "trace/importApply",
+ project_id="a",
+ mode="UPDATE",
+ csv_text=text,
+ preview_id=preview["preview_id"],
+ input_sha256=preview["input_sha256"],
+ )
+ assert response.error is not None
+ monkeypatch.undo()
+ after = await rpc("trace/read", project_id="a")
+ assert after["record_digest"] == before["record_digest"] and len(after["items"]) == 3
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_an_apply_is_refused_when_the_trace_changed_after_the_preview(tmp_path: Path) -> None:
+ runtime, rpc = await opened(tmp_path)
+ try:
+ await rpc.load(await rpc.project("a"), fixture("order_baseline.csv"))
+ exported = await rpc("trace/export", project_id="a")
+ header, rows = table(exported["csv_text"])
+
+ def added(name: str) -> str:
+ row = [*rows[0]]
+ row[header.index("item_id")], row[header.index("item_key")] = name, "k-" + name
+ return rebuild(header, [row])
+
+ first, second = added("ONE"), added("TWO")
+ preview = await rpc("trace/importPreview", project_id="a", mode="UPDATE", csv_text=first)
+ await rpc.load("a", second, "UPDATE") # somebody else changed the trace meanwhile
+ message = await rpc.error(
+ "trace/importApply",
+ project_id="a",
+ mode="UPDATE",
+ csv_text=first,
+ preview_id=preview["preview_id"],
+ input_sha256=preview["input_sha256"],
+ )
+ assert "TRACE_IMPORT_PREVIEW_STALE" in message
+ other = await rpc.error(
+ "trace/importApply",
+ project_id="a",
+ mode="UPDATE",
+ csv_text=second,
+ preview_id=preview["preview_id"],
+ input_sha256=preview["input_sha256"],
+ )
+ assert "TRACE_IMPORT_INPUT_CHANGED" in other
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_ids_that_look_rewritten_by_a_spreadsheet_are_conflicts(tmp_path: Path) -> None:
+ runtime, rpc = await opened(tmp_path)
+ try:
+ cases = {
+ "ids_leading_zero.csv": ("007", "7", "LEADING_ZEROS_LOST"),
+ "ids_date_like.csv": ("1-2", "2-Jan", "DATE_FORM"),
+ "ids_long_numeric.csv": ("12345678901234567890", "1.23457E+19", "EXPONENT_FORM"),
+ }
+ for number, (name, (original, mangled, reason)) in enumerate(cases.items()):
+ project = await rpc.project(f"x{number}")
+ await rpc.load(project, fixture(name))
+ exported = await rpc("trace/export", project_id=project)
+ header, rows = table(exported["csv_text"])
+ at = header.index("item_id")
+ for row in rows:
+ if row[at] == original:
+ row[at] = mangled
+ preview = await rpc(
+ "trace/importPreview",
+ project_id=project,
+ mode="UPDATE",
+ csv_text=rebuild(header, rows),
+ )
+ assert not preview["applicable"], name
+ suspect = preview["spreadsheet_suspects"][0]
+ assert (
+ suspect["ref"] == mangled
+ and original in suspect["detail"]
+ and reason in suspect["detail"]
+ )
+ # An id that is simply new is not a suspect while the original is still in the file.
+ project = await rpc.project("fine")
+ await rpc.load(project, fixture("ids_leading_zero.csv"))
+ exported = await rpc("trace/export", project_id=project)
+ header, rows = table(exported["csv_text"])
+ extra = [*rows[0]]
+ extra[header.index("item_id")], extra[header.index("item_key")] = "7", "k-seven"
+ preview = await rpc(
+ "trace/importPreview",
+ project_id=project,
+ mode="UPDATE",
+ csv_text=rebuild(header, [*rows, extra]),
+ )
+ assert preview["applicable"] and changes(preview) == (1, 0, 0)
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_create_needs_an_empty_project_and_update_needs_a_trace(tmp_path: Path) -> None:
+ runtime, rpc = await opened(tmp_path)
+ try:
+ text = fixture("order_baseline.csv")
+ empty = await rpc.project("empty")
+ update = await rpc("trace/importPreview", project_id=empty, mode="UPDATE", csv_text=text)
+ assert codes(update) == {"UPDATE_NEEDS_EXISTING_TRACE"}
+ await rpc.load(empty, text)
+ create = await rpc("trace/importPreview", project_id=empty, mode="CREATE", csv_text=text)
+ assert codes(create) == {"MODE_CREATE_NEEDS_EMPTY_PROJECT"}
+ missing = await rpc.raw("trace/read", project_id="missing")
+ assert missing.error is not None and "not found" in missing.error.message
+ columns = await rpc(
+ "trace/importPreview", project_id=empty, mode="UPDATE", csv_text="a,b\n1,2\n"
+ )
+ assert "HEADER_MISSING_COLUMNS" in codes(columns)
+ finally:
+ runtime.close()
+
+
+# --- the demo: one condition at a time ---
+
+
+@pytest.mark.asyncio
+async def test_demo_flow_create_dry_then_import_the_rain_results(tmp_path: Path) -> None:
+ runtime, rpc = await opened(tmp_path)
+ try:
+ first_set, second_set = demo_set(1), demo_set(2)
+ created = await rpc.load(await rpc.project("radar"), export_csv(first_set))
+ assert created["applied"] is True
+ view = await rpc("trace/read", project_id="radar")
+ state = {(v["subject_kind"], v["subject_id"]): v for v in view["verdicts"]}
+ assert state[("REQUIREMENT", REQ)]["state"] == "HOLD_INCOMPLETE"
+ assert state[("CRITERION", "SYN-C-DET-RAIN")]["state"] == "HOLD_NO_RESULT"
+ assert state[("CRITERION", "SYN-C-DET-DRY")]["state"] == "PASS_COMPUTED"
+ assert all(v["currentness"]["state"] == "CURRENT" for v in view["verdicts"])
+ dry_before = state[("CRITERION", "SYN-C-DET-DRY")]["revision_digest"]
+ rain_hold = state[("CRITERION", "SYN-C-DET-RAIN")]["revision_digest"]
+ requirement_hold = state[("REQUIREMENT", REQ)]["revision_digest"]
+ confirmed = await rpc(
+ "trace/confirm",
+ project_id="radar",
+ verdict_revision_digest=requirement_hold,
+ rationale="1단계 결과 확인",
+ expected_digest=view["record_digest"],
+ )
+ assert [
+ v["state"] for v in confirmed["verdicts"] if v["subject_kind"] == "REQUIREMENT"
+ ] == ["HOLD_INCOMPLETE"]
+
+ # The rain results as their own file: only the rows that are new, made from the dry trace.
+ old_keys = {row_key(row) for row in export_rows(first_set)}
+ keep = [row for row in export_rows(second_set) if row_key(row) not in old_keys]
+ for row in keep:
+ row["base_set_digest"] = first_set.set_digest
+ rain_file = write_csv(keep)
+ assert {row["row_type"] for row in keep} == {"ITEM", "LINK", "RESULT"} and len(keep) == 6
+ preview = await rpc(
+ "trace/importPreview", project_id="radar", mode="UPDATE", csv_text=rain_file
+ )
+ assert preview["applicable"] and changes(preview) == (6, 0, 0)
+ applied = await rpc.load("radar", rain_file, "UPDATE")
+ moved = {
+ item["subject_id"]: (item["before"], item["after"])
+ for item in applied["verdict_changes"]
+ }
+ assert moved == {
+ "SYN-C-DET-RAIN": ("HOLD_NO_RESULT", "FAIL_COMPUTED"),
+ "SYN-C-FA-RAIN": ("HOLD_NO_RESULT", "FAIL_COMPUTED"),
+ REQ: ("HOLD_INCOMPLETE", "FAIL_WITH_INCOMPLETE_COVERAGE"),
+ }
+
+ view = await rpc("trace/read", project_id="radar")
+ state = {(v["subject_kind"], v["subject_id"]): v for v in view["verdicts"]}
+ assert state[("CRITERION", "SYN-C-DET-DRY")]["revision_digest"] == dry_before
+ assert state[("CRITERION", "SYN-C-DET-FOG")]["state"] == "HOLD_NO_RESULT"
+ rain = state[("CRITERION", "SYN-C-DET-RAIN")]
+ assert rain["parent_digest"] == rain_hold and rain["cause"]["trigger"] == "CSV_IMPORT"
+ assert {item["ref_id"] for item in rain["cause"]["changed"]} == {"SYN-RES-SYN-C-DET-RAIN"}
+ assert [item["after"] for item in rain["cause"]["changed"]] == ["0.80 ratio @weather=rain"]
+ assert all(v["currentness"]["state"] == "CURRENT" for v in view["verdicts"])
+ requirement = state[("REQUIREMENT", REQ)]
+ assert (
+ requirement["confirmations"] == []
+ ) # the 1st-phase confirmation stayed on its revision
+ assert requirement["parent_digest"] == requirement_hold
+ assert "history" not in view # the full history is not part of trace/read any more
+ assert requirement["history_total"] == 2 and rain["history_total"] == 2
+ assert [h["revision_digest"] for h in rain["recent_history"]] == [
+ rain["revision_digest"],
+ rain_hold,
+ ]
+ assert any(c["verdict_revision_digest"] == requirement_hold for c in view["confirmations"])
+ older = await rpc(
+ "trace/history",
+ project_id="radar",
+ subject_kind="REQUIREMENT",
+ subject_id=REQ,
+ before_digest=requirement["revision_digest"],
+ )
+ assert [h["revision_digest"] for h in older["revisions"]] == [requirement_hold]
+ assert older["history_total"] == 2 and older["next_before_digest"] is None
+ assert view["set_digest"] == second_set.set_digest
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_a_long_history_comes_in_pages_and_the_stored_trace_does_not_grow_with_it(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path)
+ try:
+ first, second = demo_set(1), demo_set(2)
+ await rpc.load(await rpc.project("radar"), export_csv(first))
+ old = {row_key(row) for row in export_rows(first)}
+ keep = [row for row in export_rows(second) if row_key(row) not in old]
+ for row in keep:
+ row["base_set_digest"] = first.set_digest
+ await rpc.load("radar", write_csv(keep), "UPDATE")
+ sizes: list[int] = []
+ for number in range(5): # five more changes of the rain result: seven revisions in all
+ header, rows = table((await rpc("trace/export", project_id="radar"))["csv_text"])
+ kind, revision, amount, ident = (
+ header.index("row_type"),
+ header.index("result_revision"),
+ header.index("value"),
+ header.index("result_id"),
+ )
+ for row in rows:
+ if row[kind] == "RESULT" and row[ident] == "SYN-RES-SYN-C-DET-RAIN":
+ row[revision], row[amount] = str(int(row[revision]) + 1), f"0.{60 + number}"
+ await rpc.load("radar", rebuild(header, rows), "UPDATE")
+ sizes.append(await stored_trace_bytes(runtime, "radar"))
+ # The trace record lists digests only: each change adds a few dozen bytes, not a revision.
+ assert max(b - a for a, b in pairwise(sizes)) < 400
+
+ view = await rpc("trace/read", project_id="radar")
+ rain = next(v for v in view["verdicts"] if v["subject_id"] == "SYN-C-DET-RAIN")
+ assert rain["history_total"] == 7 and len(rain["recent_history"]) == 3
+ assert rain["recent_history"][0]["revision_digest"] == rain["revision_digest"]
+ ask = {"project_id": "radar", "subject_kind": "CRITERION", "subject_id": "SYN-C-DET-RAIN"}
+ cursor = rain["recent_history"][-1]["revision_digest"]
+ page = await rpc("trace/history", before_digest=cursor, limit=2, **ask)
+ assert len(page["revisions"]) == 2 and page["history_total"] == 7
+ assert page["next_before_digest"] == page["revisions"][-1]["revision_digest"]
+ rest = await rpc("trace/history", before_digest=page["next_before_digest"], **ask)
+ seen = [
+ r["revision_digest"]
+ for r in rain["recent_history"] + page["revisions"] + rest["revisions"]
+ ]
+ assert len(seen) == 7 == len(set(seen)) and rest["next_before_digest"] is None
+ # Every older revision still carries what changed and the verdict before it.
+ assert rest["revisions"][-1]["parent_digest"] is None
+ assert "TRACE_HISTORY_CURSOR_UNKNOWN" in await rpc.error(
+ "trace/history", before_digest="f" * 64, **ask
+ )
+ assert await rpc.error("trace/history", limit=51, **ask)
+ everything = await rpc("trace/history", limit=50, **ask)
+ assert len(everything["revisions"]) == 7
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_the_synthetic_notice_survives_import_verdict_and_export(tmp_path: Path) -> None:
+ runtime, rpc = await opened(tmp_path)
+ try:
+ await rpc.load(await rpc.project("a"), export_csv(with_notice(demo_set(1))))
+ first = await rpc("trace/export", project_id="a")
+ header, rows = table(first["csv_text"])
+ assert header[-1] == "synthetic_notice" and {row[-1] for row in rows} == {NOTICE}
+ assert all(
+ item["fields"]["synthetic_notice"] == NOTICE
+ for item in (await rpc("trace/read", project_id="a"))["items"]
+ )
+ # The rain results arrive in a file made by an older exporter: no notice column at all.
+ older = export_csv(demo_set(2))
+ older_header, older_rows = table(older)
+ digest = first["set_digest"]
+ at = {name: number for number, name in enumerate(older_header)}
+ update = rebuild(
+ older_header,
+ [
+ [
+ digest if number == at["base_set_digest"] else cell
+ for number, cell in enumerate(row)
+ ]
+ for row in older_rows
+ if row[at["row_type"]] != "ITEM" or row[at["item_id"]].startswith("SYN-RES-")
+ ],
+ )
+ await rpc.load("a", update, "UPDATE")
+ after = await rpc("trace/read", project_id="a")
+ assert {i["kind"] for i in after["items"] if "synthetic_notice" in i["fields"]} >= {
+ "REQUIREMENT"
+ }
+ assert all(
+ i["fields"].get("synthetic_notice") == NOTICE
+ for i in after["items"]
+ if i["item_id"] == REQ
+ )
+ again = await rpc("trace/export", project_id="a")
+ assert table(again["csv_text"])[0][-1] == "synthetic_notice"
+ preview = await rpc(
+ "trace/importPreview", project_id="a", mode="UPDATE", csv_text=again["csv_text"]
+ )
+ assert preview["applicable"] and changes(preview) == (0, 0, 0)
+ finally:
+ runtime.close()
diff --git a/tests/integration/test_trace_csv_closure_column.py b/tests/integration/test_trace_csv_closure_column.py
new file mode 100644
index 0000000..9f1d603
--- /dev/null
+++ b/tests/integration/test_trace_csv_closure_column.py
@@ -0,0 +1,106 @@
+"""The trace CSV carries a read-only note of each closed row; importing the file never reads it."""
+
+from __future__ import annotations
+
+import csv
+import io
+from pathlib import Path
+from typing import Any
+
+import pytest
+from tests.integration.test_judgment_records import close
+from tests.integration.test_research_request_v2 import ControlledResearchModel
+from tests.integration.test_trace_origin import FOG, Rpc, opened
+from tests.integration.trace_demo_helpers import demo_set, with_notice
+
+from thoth.application.services.trace_csv import BOM, CLOSURE_COLUMN, export_csv
+
+
+def rows_of(text: str) -> list[dict[str, str]]:
+ return list(csv.DictReader(io.StringIO(text.removeprefix(BOM), newline="")))
+
+
+async def exported(rpc: Rpc) -> dict[str, Any]:
+ return await rpc("trace/export", project_id="p")
+
+
+async def preview(rpc: Rpc, text: str) -> dict[str, Any]:
+ return await rpc("trace/importPreview", project_id="p", mode="UPDATE", csv_text=text)
+
+
+@pytest.mark.asyncio
+async def test_a_set_without_closures_exports_the_same_bytes_as_before_and_has_no_column(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel())
+ try:
+ done = await exported(rpc)
+ assert CLOSURE_COLUMN not in done["csv_text"].removeprefix(BOM).splitlines()[0]
+ current = (await rpc("trace/read", project_id="p"))["set_digest"]
+ assert (
+ done["csv_text"] == export_csv(with_notice(demo_set(1)))
+ and done["set_digest"] == current
+ )
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_a_closed_row_is_noted_on_its_own_item_row_only_and_the_file_imports_with_no_change(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel())
+ try:
+ await close(rpc, FOG, "FIX_APPLIED")
+ await close(rpc, FOG, "WAIVER_RECORDED", basis_ref="deviation DEV-4")
+ text = (await exported(rpc))["csv_text"]
+ assert text.removeprefix(BOM).splitlines()[0].endswith(CLOSURE_COLUMN)
+ marked = [row for row in rows_of(text) if row.get(CLOSURE_COLUMN)]
+ # the closure's subject id is the id of the CRITERION item, so the note lands on that row
+ assert [(row["row_type"], row["item_id"], row["kind"]) for row in marked] == [
+ ("ITEM", FOG, "CRITERION")
+ ]
+ assert marked[0][CLOSURE_COLUMN].startswith("편차·면제 승인(원 기준 미충족 유지)")
+ assert "근거 문서: deviation DEV-4" in marked[0][CLOSURE_COLUMN]
+ assert marked[0][CLOSURE_COLUMN].endswith("이전 기록 1건(THOTH에서 확인)")
+ # the same file read back changes nothing and the column is not reported as ignored
+ plan = await preview(rpc, text)
+ assert plan["applicable"] and plan["conflicts"] == []
+ assert [len(plan["changes"][name]) for name in ("added", "updated", "deleted")] == [0, 0, 0]
+ assert not any("column ignored" in line for line in plan["losses"])
+ assert any(line.startswith("closures:") for line in plan["losses"])
+ # editing the cell changes nothing either
+ edited = text.replace(marked[0][CLOSURE_COLUMN], "효과 확인됨")
+ assert edited != text
+ again = await preview(rpc, edited)
+ assert [len(again["changes"][name]) for name in ("added", "updated", "deleted")] == [
+ 0,
+ 0,
+ 0,
+ ]
+ assert again["changes"]["unchanged"] == plan["changes"]["unchanged"]
+ # the closure itself is untouched by all of this
+ found = (await rpc("trace/closure/list", project_id="p"))["closures"]
+ assert [len(item["events"]) for item in found] == [2]
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_a_document_that_looks_like_a_formula_cannot_run_in_a_spreadsheet(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel())
+ try:
+ await close(rpc, FOG, "HUMAN_CLOSED", basis_ref='=HYPERLINK("http://x","y")')
+ cell = next(
+ row[CLOSURE_COLUMN]
+ for row in rows_of((await exported(rpc))["csv_text"])
+ if row.get(CLOSURE_COLUMN)
+ )
+ assert not cell.startswith(("=", "+", "-", "@", "\t", "\r", "'"))
+ assert "HYPERLINK" in cell
+ plan = await preview(rpc, (await exported(rpc))["csv_text"])
+ assert plan["applicable"] and plan["changes"]["updated"] == []
+ finally:
+ runtime.close()
diff --git a/tests/integration/test_trace_origin.py b/tests/integration/test_trace_origin.py
new file mode 100644
index 0000000..45048dc
--- /dev/null
+++ b/tests/integration/test_trace_origin.py
@@ -0,0 +1,310 @@
+"""A trace row starts an investigation.
+
+The origin is checked against the stored trace, reaches the model's context and the result, and the
+thread list shows which row a thread belongs to.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+import pytest
+from pydantic import ValidationError
+from tests.integration.storage_coverage_helpers import request, value
+from tests.integration.test_research_request_v2 import ControlledResearchModel, setup
+from tests.integration.trace_demo_helpers import demo_set, with_notice
+
+from thoth.application.services.trace_csv import export_csv, export_rows, write_csv
+from thoth.apps.runtime import AppRuntime
+from thoth.domain.enums import ModelRole
+from thoth.protocol.bus import READ_QUERY_METHODS
+from thoth.protocol.jsonrpc import JsonRpcResponse
+
+DRY, FOG, RAIN = "SYN-C-DET-DRY", "SYN-C-DET-FOG", "SYN-C-DET-RAIN"
+
+
+class Rpc:
+ def __init__(self, runtime: AppRuntime) -> None:
+ self.runtime, self.count = runtime, 0
+
+ async def raw(self, method: str, **params: Any) -> JsonRpcResponse:
+ self.count += 1
+ message = request(method, f"o{self.count}", params)
+ if method in READ_QUERY_METHODS:
+ return await self.runtime.bus.query(message)
+ return await self.runtime.bus.dispatch(message)
+
+ async def __call__(self, method: str, **params: Any) -> dict[str, Any]:
+ return value(await self.raw(method, **params))
+
+ async def error(self, method: str, **params: Any) -> str:
+ response = await self.raw(method, **params)
+ assert response.error is not None, "expected an error"
+ return response.error.message
+
+ async def load(self, text: str, mode: str) -> None:
+ preview = await self("trace/importPreview", project_id="p", mode=mode, csv_text=text)
+ assert preview["applicable"], preview["conflicts"]
+ await self(
+ "trace/importApply",
+ project_id="p",
+ mode=mode,
+ csv_text=text,
+ preview_id=preview["preview_id"],
+ input_sha256=preview["input_sha256"],
+ )
+
+ async def rain_arrives(self) -> None:
+ """The rain results come in: the rain rows get a new verdict revision."""
+ current = (await self("trace/read", project_id="p"))["set_digest"]
+ rows = [
+ {**row, "base_set_digest": current} for row in export_rows(with_notice(demo_set(2)))
+ ]
+ await self.load(write_csv(rows), "UPDATE")
+
+
+async def opened(tmp_path: Path, model: ControlledResearchModel) -> tuple[AppRuntime, Rpc]:
+ tmp_path.mkdir(exist_ok=True)
+ runtime = await setup(tmp_path, model)
+ rpc = Rpc(runtime)
+ await rpc.load(export_csv(with_notice(demo_set(1))), "CREATE")
+ return runtime, rpc
+
+
+async def origin_for(rpc: Rpc, subject_id: str, kind: str = "CRITERION") -> dict[str, Any]:
+ view = await rpc("trace/read", project_id="p")
+ verdict = next(item for item in view["verdicts"] if item["subject_id"] == subject_id)
+ return {
+ "kind": "TRACE_VERDICT",
+ "project_id": "p",
+ "subject_kind": kind,
+ "subject_id": subject_id,
+ "verdict_revision": verdict["revision_digest"],
+ }
+
+
+def with_origin(model: ControlledResearchModel) -> list[dict[str, Any]]:
+ """The origin each model call saw, for the calls that had one."""
+ return [
+ call.context_pack.research_context["origin"]
+ for call in model.calls
+ if "origin" in call.context_pack.research_context
+ ]
+
+
+async def listed(rpc: Rpc) -> dict[str, Any]:
+ threads = (await rpc("thread/list", project_id="p"))["threads"]
+ return {item["thread_id"]: item.get("origin") for item in threads}
+
+
+@pytest.mark.asyncio
+async def test_a_wrong_origin_is_refused_with_a_reason_and_starts_nothing(tmp_path: Path) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel())
+ try:
+ good = await origin_for(rpc, DRY)
+ start = {"project_id": "p", "problem": "원인을 찾아 주세요.", "contract_version": 2}
+ cases = [
+ ("TRACE_ORIGIN_NOT_FOUND", {**good, "subject_id": "NO-SUCH-ROW"}),
+ ("TRACE_ORIGIN_NOT_FOUND", {**good, "verdict_revision": "0" * 64}),
+ ("TRACE_ORIGIN_PROJECT_MISMATCH", {**good, "project_id": "other"}),
+ ("TRACE_ORIGIN_INVALID", {**good, "unexpected": "field"}),
+ (
+ "TRACE_ORIGIN_INVALID",
+ {**good, "state": "PASS_COMPUTED"},
+ ), # the client cannot set facts
+ ]
+ for reason, origin in cases:
+ assert reason in await rpc.error("thread/start", **start, origin=origin)
+ assert await listed(rpc) == {}
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_a_good_origin_adds_context_and_a_result_field_but_no_model_call(
+ tmp_path: Path,
+) -> None:
+ plain_model, origin_model = ControlledResearchModel(), ControlledResearchModel()
+ plain_runtime, plain_rpc = await opened(tmp_path / "plain", plain_model)
+ runtime, rpc = await opened(tmp_path / "origin", origin_model)
+ try:
+ question = "지연을 알려 주세요."
+ await plain_rpc("thread/start", project_id="p", problem=question, contract_version=2)
+ await plain_runtime.bus.drain()
+ assert with_origin(plain_model) == []
+ origin = await origin_for(rpc, DRY)
+ started = await rpc(
+ "thread/start", project_id="p", problem=question, contract_version=2, origin=origin
+ )
+ await runtime.bus.drain()
+ assert [c.role for c in origin_model.calls] == [c.role for c in plain_model.calls]
+ seen = with_origin(origin_model)
+ assert {item["subject_id"] for item in seen} == {DRY}
+ # the cause-finding stages (hypotheses, their review, the action plan) see the row
+ carrying = {
+ c.role for c in origin_model.calls if "origin" in c.context_pack.research_context
+ }
+ assert carrying >= {
+ ModelRole.HYPOTHESIS_GENERATOR,
+ ModelRole.HYPOTHESIS_REVIEWER,
+ ModelRole.ACTION_PLANNER,
+ }
+ canonical = seen[0]
+ assert canonical["kind"] == "TRACE_VERDICT"
+ assert canonical["verdict_revision"] == origin["verdict_revision"]
+ assert "note" in canonical # the model is told the rule decided the verdict
+ read = await rpc("thread/read", project_id="p", thread_id=started["thread_id"], view="FULL")
+ assert read["current_result"]["result"]["origin"] == canonical
+ finally:
+ runtime.close()
+ plain_runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_the_server_writes_the_verdict_facts_from_the_stored_trace(tmp_path: Path) -> None:
+ model = ControlledResearchModel()
+ runtime, rpc = await opened(tmp_path, model)
+ try:
+ origin = await origin_for(rpc, FOG)
+ await rpc("thread/start", project_id="p", problem="q", contract_version=2, origin=origin)
+ await runtime.bus.drain()
+ view = await rpc("trace/read", project_id="p")
+ verdict = next(item for item in view["verdicts"] if item["subject_id"] == FOG)
+ canonical = with_origin(model)[0]
+ assert canonical["state"] == verdict["state"] == "HOLD_NO_RESULT"
+ assert canonical["reason_codes"] == list(verdict["reasons"]["computed"])
+ assert canonical["trace_set_digest"] == view["set_digest"]
+ assert canonical["subject_title"] == "Detection rate in fog"
+ assert canonical["rule_summary"]
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_the_thread_list_says_which_row_a_thread_belongs_to_and_follows_a_new_revision(
+ tmp_path: Path,
+) -> None:
+ model = ControlledResearchModel()
+ runtime, rpc = await opened(tmp_path, model)
+ try:
+ origin = await origin_for(rpc, RAIN)
+ started = await rpc(
+ "thread/start", project_id="p", problem="비 줄", contract_version=2, origin=origin
+ )
+ await runtime.bus.drain()
+ other = await rpc(
+ "thread/start",
+ project_id="p",
+ problem="안개 줄",
+ contract_version=2,
+ origin=await origin_for(rpc, FOG),
+ )
+ await runtime.bus.drain()
+ ordinary = await rpc(
+ "thread/start", project_id="p", problem="그냥 질문", contract_version=2
+ )
+ await runtime.bus.drain()
+ found = await listed(rpc)
+ assert found[started["thread_id"]] == {
+ "subject_kind": "CRITERION",
+ "subject_id": RAIN,
+ "verdict_revision": origin["verdict_revision"],
+ }
+ assert found[other["thread_id"]]["subject_id"] == FOG
+ assert found[ordinary["thread_id"]] is None # an ordinary (or old) thread has no origin
+ await rpc.rain_arrives()
+ newer = await origin_for(rpc, RAIN)
+ assert newer["verdict_revision"] != origin["verdict_revision"]
+ await rpc(
+ "thread/input",
+ project_id="p",
+ thread_id=started["thread_id"],
+ instruction="새 판정으로 다시",
+ contract_version=2,
+ origin=newer,
+ )
+ await runtime.bus.drain()
+ assert (await listed(rpc))[started["thread_id"]]["verdict_revision"] == (
+ newer["verdict_revision"]
+ )
+ assert with_origin(model)[-1]["verdict_revision"] == newer["verdict_revision"]
+ # the old revision is no longer this row's verdict
+ assert "TRACE_ORIGIN_REVISION_CHANGED" in await rpc.error(
+ "thread/input",
+ project_id="p",
+ thread_id=started["thread_id"],
+ instruction="옛 판정",
+ contract_version=2,
+ origin=origin,
+ )
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_an_origin_only_goes_to_the_thread_of_its_own_row_and_never_through_steer(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel())
+ try:
+ origin = await origin_for(rpc, RAIN)
+ started = await rpc(
+ "thread/start", project_id="p", problem="비 줄", contract_version=2, origin=origin
+ )
+ await runtime.bus.drain()
+ ordinary = await rpc(
+ "thread/start", project_id="p", problem="그냥 질문", contract_version=2
+ )
+ await runtime.bus.drain()
+ cases = [
+ (
+ "TRACE_ORIGIN_THREAD_MISMATCH",
+ "thread/input",
+ started,
+ await origin_for(rpc, FOG),
+ 2,
+ ),
+ ("TRACE_ORIGIN_THREAD_MISMATCH", "thread/input", ordinary, origin, 2),
+ ("TRACE_ORIGIN_NOT_ALLOWED", "thread/steer", started, origin, 2),
+ ]
+ for reason, method, thread, sent, version in cases:
+ message = await rpc.error(
+ method,
+ project_id="p",
+ thread_id=thread["thread_id"],
+ instruction="x",
+ contract_version=version,
+ origin=sent,
+ )
+ assert reason in message
+ # the older contract takes no origin at all: its own input check refuses the unknown key
+ with pytest.raises(ValidationError, match="origin"):
+ await rpc.raw(
+ "thread/input",
+ project_id="p",
+ thread_id=started["thread_id"],
+ instruction="x",
+ origin=origin,
+ )
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_a_resend_of_an_accepted_request_returns_the_same_work_after_the_verdict_moved_on(
+ tmp_path: Path,
+) -> None:
+ runtime, rpc = await opened(tmp_path, ControlledResearchModel())
+ try:
+ origin = await origin_for(rpc, RAIN)
+ params = {"project_id": "p", "problem": "q", "contract_version": 2, "origin": origin}
+ message = request("thread/start", "same-key", params)
+ first = value(await runtime.bus.dispatch(message))
+ await runtime.bus.drain()
+ await rpc.rain_arrives()
+ again = value(await runtime.bus.dispatch(message))
+ assert again["thread_id"] == first["thread_id"]
+ assert len(await listed(rpc)) == 1
+ finally:
+ runtime.close()
diff --git a/tests/integration/trace_demo_helpers.py b/tests/integration/trace_demo_helpers.py
new file mode 100644
index 0000000..5440a10
--- /dev/null
+++ b/tests/integration/trace_demo_helpers.py
@@ -0,0 +1,143 @@
+"""The synthetic radar demo as a trace set, shared by the trace tests."""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from decimal import Decimal
+from pathlib import Path
+from typing import Any
+
+import yaml
+
+from thoth.domain.verification_trace import (
+ Comparator,
+ CriterionRule,
+ ResultRecord,
+ SelectionPolicy,
+ TraceItem,
+ TraceKind,
+ TraceLink,
+ TraceRelation,
+ TraceSet,
+)
+
+DEMO = Path(__file__).parents[2] / "examples" / "synthetic-radar-demo-v1"
+SCORER = (
+ Path(__file__).parents[2]
+ / "examples"
+ / "synthetic-radar-demo-v1-scorer"
+ / "40_ASSESSMENT_DRAFT_SYNTHETIC.yaml"
+)
+T0 = datetime(2026, 10, 2, tzinfo=UTC)
+POLICY = SelectionPolicy()
+REQ = "SYN-REQ-RAD-001"
+NOTICE = "SYNTHETIC DEMO DATA - NOT MEASURED - NOT APPROVED - NOT FOR ENGINEERING USE"
+
+
+def read_yaml(path: Path) -> Any:
+ return yaml.safe_load(path.read_text(encoding="utf-8"))
+
+
+def with_notice(trace_set: TraceSet) -> TraceSet:
+ """The same set with the synthetic notice on every item, as the demo script sets it."""
+ items = tuple(
+ item.model_copy(update={"fields": {**item.fields, "synthetic_notice": NOTICE}})
+ for item in trace_set.items
+ )
+ return TraceSet(
+ items=items, links=trace_set.links, rules=trace_set.rules, results=trace_set.results
+ )
+
+
+def demo_set(phase: int) -> TraceSet:
+ """The synthetic radar demo as a trace set; phase 1 has the dry result, phase 2 adds rain."""
+ requirement = read_yaml(DEMO / "10_REQUIREMENTS_SYNTHETIC.yaml")["requirement"]
+ cases = read_yaml(DEMO / "20_TEST_PLAN_SYNTHETIC.yaml")["test_cases"]
+ items = [
+ TraceItem(
+ item_id=requirement["id"],
+ item_key="k-req",
+ kind=TraceKind.REQUIREMENT,
+ title=requirement["title"],
+ )
+ ]
+ links: list[TraceLink] = []
+ rules: list[CriterionRule] = []
+ for number, criterion in enumerate(requirement["criteria"]):
+ cid = criterion["id"]
+ items.append(
+ TraceItem(
+ item_id=cid,
+ item_key=f"k-c{number}",
+ kind=TraceKind.CRITERION,
+ title=criterion["title"],
+ )
+ )
+ links.append(
+ TraceLink(
+ link_id=f"L-ref-{cid}", from_id=cid, to_id=REQ, relation=TraceRelation.REFINES
+ )
+ )
+ rules.append(
+ CriterionRule(
+ rule_id=f"R-{cid}",
+ criterion_id=cid,
+ measure=criterion["measure"],
+ comparator=Comparator(criterion["comparator"]),
+ threshold=Decimal(criterion["threshold"]),
+ unit=criterion["unit"],
+ condition=criterion["condition"],
+ required=criterion["required"],
+ )
+ )
+ for number, case in enumerate(cases):
+ items.append(
+ TraceItem(
+ item_id=case["id"],
+ item_key=f"k-t{number}",
+ kind=TraceKind.TEST_CASE,
+ title=case["title"],
+ )
+ )
+ links.extend(
+ TraceLink(
+ link_id=f"L-ver-{cid}",
+ from_id=cid,
+ to_id=case["id"],
+ relation=TraceRelation.VERIFIED_BY,
+ )
+ for cid in case["covers"]
+ )
+ results: list[ResultRecord] = []
+ for path in sorted(DEMO.glob("3*_RESULT_*.yaml")):
+ data = read_yaml(path)
+ if data["available_from_phase"] > phase:
+ continue
+ for key in ("detection", "false_track"):
+ part = data[key]
+ rid = f"SYN-RES-{part['criterion']}"
+ items.append(TraceItem(item_id=rid, item_key=f"k-{rid}", kind=TraceKind.RESULT))
+ links.append(
+ TraceLink(
+ link_id=f"L-prod-{rid}",
+ from_id=data["test_case"],
+ to_id=rid,
+ relation=TraceRelation.PRODUCES,
+ )
+ )
+ results.append(
+ ResultRecord(
+ result_id=rid,
+ criterion_id=part["criterion"],
+ condition=data["condition"],
+ value=Decimal(part["value"]),
+ unit=part["unit"],
+ numerator=part["numerator"],
+ denominator=part["denominator"],
+ observed_at=datetime.fromisoformat(data["observed_at"]),
+ source_span_refs=(f"{path.name}#{key}",),
+ )
+ )
+ return TraceSet(
+ items=tuple(items), links=tuple(links), rules=tuple(rules), results=tuple(results)
+ )
diff --git a/tests/unit/domain/test_action_test_refs.py b/tests/unit/domain/test_action_test_refs.py
new file mode 100644
index 0000000..1b90ffa
--- /dev/null
+++ b/tests/unit/domain/test_action_test_refs.py
@@ -0,0 +1,49 @@
+"""An action record names the test it was drafted from, and older records load unchanged."""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from typing import Any
+
+from thoth.domain.action_full import ActionRecord, ActionTestRef
+
+REF = {"hypothesis_id": "hypothesis:a", "test_id": "test-1"}
+
+
+def record(**patch: Any) -> dict[str, Any]:
+ base: dict[str, Any] = {
+ "action_revision_id": "action-revision:1",
+ "action_id": "action:1",
+ "project_id": "p",
+ "object_id": "object:1",
+ "portfolio_id": "portfolio:1",
+ "primary_purpose": "HYPOTHESIS_DISCRIMINATION",
+ "specification": {"description": "compare"},
+ "evidence_refs": (),
+ "expected_observation_or_change": {"description": "a reading"},
+ "effect_vector": {},
+ "impact_set": {},
+ "risk_tier": "R0",
+ "required_processes": (),
+ "required_roles": (),
+ "revision_digest": "a" * 64,
+ "created_at": datetime(2026, 10, 6, tzinfo=UTC),
+ }
+ return {**base, **patch}
+
+
+def test_a_record_stored_before_the_field_existed_has_no_test_refs() -> None:
+ assert ActionRecord.model_validate(record()).test_refs == ()
+
+
+def test_the_test_a_draft_names_in_its_specification_becomes_the_field() -> None:
+ found = ActionRecord.model_validate(record(specification={"test_refs": [REF]}))
+ assert found.test_refs == (ActionTestRef(**REF),)
+
+
+def test_a_later_revision_keeps_the_refs_it_carries_and_survives_a_round_trip() -> None:
+ first = ActionRecord.model_validate(record(specification={"test_refs": [REF]}))
+ # a later revision may rewrite the specification; the field stays, it is not read again
+ later = ActionRecord.model_validate({**first.model_dump(mode="python"), "specification": {}})
+ assert later.test_refs == first.test_refs
+ assert ActionRecord.model_validate(later.model_dump(mode="json")).test_refs == first.test_refs
diff --git a/tests/unit/domain/test_canonical.py b/tests/unit/domain/test_canonical.py
index 9afe31b..2ed1907 100644
--- a/tests/unit/domain/test_canonical.py
+++ b/tests/unit/domain/test_canonical.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from datetime import UTC, datetime
+from datetime import UTC, datetime, timedelta, timezone
from decimal import Decimal
import pytest
@@ -10,6 +10,7 @@
domain_digest,
head_set_digest,
normalize_timestamp,
+ same_stored_instant,
)
from thoth.domain.errors import CanonicalizationError
@@ -56,3 +57,33 @@ def test_head_set_digest_ignores_mapping_insertion_order() -> None:
first = {"project:a": "a" * 64, "thread:b": "b" * 64}
second = {"thread:b": "b" * 64, "project:a": "a" * 64}
assert head_set_digest(first) == head_set_digest(second)
+
+
+def test_same_stored_instant_compares_at_the_stored_precision_across_time_zones() -> None:
+ stored = datetime(
+ 2026, 10, 3, 10, 46, 12, 123000, tzinfo=UTC
+ ) # what a result keeps: milliseconds
+ typed = datetime(2026, 10, 3, 10, 46, 12, 123456, tzinfo=UTC) # what a project can hold
+ assert stored != typed # the plain comparison is what raised the false alarm
+ assert same_stored_instant(stored, typed)
+ assert same_stored_instant(typed, stored)
+ local = datetime(2026, 10, 3, 19, 46, 12, 123456, tzinfo=timezone(timedelta(hours=9)))
+ assert same_stored_instant(stored, local) and same_stored_instant(typed, local)
+
+
+def test_same_stored_instant_still_sees_a_real_change() -> None:
+ base = datetime(2026, 10, 3, 10, 46, 12, 123456, tzinfo=UTC)
+ assert not same_stored_instant(base, base + timedelta(milliseconds=1))
+ assert not same_stored_instant(base, base - timedelta(milliseconds=1))
+ assert not same_stored_instant(base, base + timedelta(seconds=1))
+ assert not same_stored_instant(
+ base, base.astimezone(timezone(timedelta(hours=9))) + timedelta(hours=1)
+ )
+
+
+def test_same_stored_instant_never_raises_for_missing_or_naive_values() -> None:
+ value = datetime(2026, 10, 3, tzinfo=UTC)
+ assert same_stored_instant(None, None) and not same_stored_instant(None, value)
+ assert not same_stored_instant(value, None)
+ naive = datetime(2026, 10, 3)
+ assert same_stored_instant(naive, naive) and not same_stored_instant(naive, value)
diff --git a/tests/unit/domain/test_discrimination.py b/tests/unit/domain/test_discrimination.py
new file mode 100644
index 0000000..e3ad1b7
--- /dev/null
+++ b/tests/unit/domain/test_discrimination.py
@@ -0,0 +1,132 @@
+"""A test result a person recorded, and when it eliminates a hypothesis.
+
+An observation matching the alternative explanation eliminates the hypothesis within what was
+observed: once for one result, repeated for results of two different tests. Nothing is deleted, and
+the latest result of a test is the one that counts, so a corrected result changes the state.
+"""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+
+import pytest
+from pydantic import ValidationError
+
+from thoth.domain.discrimination import (
+ DiscriminationLedgerRecord,
+ DiscriminationResult,
+ MatchKind,
+ RefutationConditions,
+ elimination,
+ latest_results,
+ recorded_standing,
+)
+
+H = "hypothesis:one"
+
+
+def result(
+ test: str, matched: MatchKind, *, hypothesis: str = H, n: int = 1
+) -> DiscriminationResult:
+ return DiscriminationResult(
+ event_id=f"e{n}",
+ hypothesis_id=hypothesis,
+ hypothesis_revision_digest="h" * 64,
+ test_id=test,
+ observation="what was seen",
+ matched=matched,
+ actor_id="human:local-user",
+ created_at=datetime(2026, 10, 6, tzinfo=UTC),
+ )
+
+
+def test_one_result_that_matches_the_alternative_eliminates_once() -> None:
+ assert elimination((result("t1", "ALTERNATIVE"),), H) == "SINGLE"
+
+
+def test_results_of_two_different_tests_make_it_repeated_but_one_test_twice_does_not() -> None:
+ both = (result("t1", "ALTERNATIVE"), result("t2", "ALTERNATIVE", n=2))
+ assert elimination(both, H) == "REPEATED"
+ twice = (result("t1", "ALTERNATIVE"), result("t1", "ALTERNATIVE", n=2))
+ assert elimination(twice, H) == "SINGLE"
+
+
+def test_a_result_that_does_not_match_the_alternative_eliminates_nothing() -> None:
+ for kind in ("THIS_HYPOTHESIS", "NEITHER", "UNDETERMINED"):
+ assert elimination((result("t1", kind),), H) is None # type: ignore[arg-type]
+ assert elimination((), H) is None
+
+
+def test_the_latest_result_of_a_test_counts_and_the_earlier_one_stays_recorded() -> None:
+ events = (result("t1", "ALTERNATIVE"), result("t1", "THIS_HYPOTHESIS", n=2))
+ assert elimination(events, H) is None
+ assert latest_results(events, H)["t1"].matched == "THIS_HYPOTHESIS"
+ assert len(events) == 2 # the ledger is only ever appended to
+
+
+def test_results_of_another_hypothesis_do_not_count() -> None:
+ other = (result("t1", "ALTERNATIVE", hypothesis="hypothesis:two"),)
+ assert elimination(other, H) is None
+
+
+def test_a_result_needs_words_and_a_known_kind_and_conditions_are_few_and_not_blank() -> None:
+ with pytest.raises(ValidationError):
+ DiscriminationResult(**{**result("t1", "NEITHER").model_dump(), "observation": ""})
+ with pytest.raises(ValidationError):
+ DiscriminationResult(**{**result("t1", "NEITHER").model_dump(), "matched": "MAYBE"})
+ stamp = datetime(2026, 10, 6, tzinfo=UTC)
+ base = dict(event_id="c1", hypothesis_id=H, actor_id="human:a", created_at=stamp)
+ assert RefutationConditions(conditions=("one",), **base).conditions == ("one",)
+ assert RefutationConditions(conditions=(), **base).conditions == () # clearing is a record too
+ with pytest.raises(ValidationError):
+ RefutationConditions(conditions=("",), **base)
+ with pytest.raises(ValidationError):
+ RefutationConditions(conditions=tuple(f"c{i}" for i in range(11)), **base)
+
+
+def test_a_ledger_with_nothing_in_it_is_valid() -> None:
+ assert DiscriminationLedgerRecord(project_id="p").results == ()
+
+
+def test_the_recorded_standing_of_a_hypothesis_follows_its_latest_results_by_a_fixed_table() -> (
+ None
+):
+ this, alt = "THIS_HYPOTHESIS", "ALTERNATIVE"
+ table: list[tuple[tuple[tuple[str, str], ...], str]] = [
+ ((), "NONE"),
+ ((("t1", "NEITHER"),), "NONE"), # a result that fits neither counts for nothing
+ ((("t1", "UNDETERMINED"), ("t2", "NEITHER")), "NONE"),
+ ((("t1", this),), "FITS"),
+ ((("t1", this), ("t2", this)), "FITS"),
+ ((("t1", this), ("t2", "NEITHER")), "FITS"),
+ ((("t1", alt),), "AGAINST_ONCE"),
+ ((("t1", alt), ("t2", "UNDETERMINED")), "AGAINST_ONCE"),
+ ((("t1", alt), ("t2", alt)), "AGAINST_REPEATED"),
+ ((("t1", this), ("t2", alt)), "MIXED"),
+ ((("t1", alt), ("t2", alt), ("t3", this)), "MIXED"),
+ ]
+ for number, (results, expected) in enumerate(table):
+ events = tuple(result(test, kind, n=n) for n, (test, kind) in enumerate(results, 1)) # type: ignore[arg-type]
+ assert recorded_standing(events, H) == expected, (number, results)
+
+
+def test_a_test_recorded_again_counts_once_and_only_its_latest_result_decides() -> None:
+ events = (result("t1", "ALTERNATIVE"), result("t2", "ALTERNATIVE", n=2))
+ assert recorded_standing(events, H) == "AGAINST_REPEATED"
+ corrected = (*events, result("t2", "THIS_HYPOTHESIS", n=3))
+ assert recorded_standing(corrected, H) == "MIXED"
+ again = (*corrected, result("t1", "THIS_HYPOTHESIS", n=4))
+ assert recorded_standing(again, H) == "FITS"
+ twice = (result("t1", "ALTERNATIVE"), result("t1", "ALTERNATIVE", n=2))
+ assert recorded_standing(twice, H) == "AGAINST_ONCE"
+
+
+def test_the_standing_matches_the_elimination_it_is_read_beside_and_ignores_other_hypotheses() -> (
+ None
+):
+ events = (result("t1", "ALTERNATIVE"), result("t2", "ALTERNATIVE", n=2))
+ assert (
+ elimination(events, H) == "REPEATED" and recorded_standing(events, H) == "AGAINST_REPEATED"
+ )
+ other = (result("t1", "ALTERNATIVE", hypothesis="hypothesis:two"),)
+ assert recorded_standing(other, H) == "NONE"
diff --git a/tests/unit/domain/test_hypothesis_contract_v3.py b/tests/unit/domain/test_hypothesis_contract_v3.py
new file mode 100644
index 0000000..a5bdc4c
--- /dev/null
+++ b/tests/unit/domain/test_hypothesis_contract_v3.py
@@ -0,0 +1,223 @@
+"""Contract v3: the generator may also say what each hypothesis expects from a test.
+
+The new fields are optional and live only on v3 subclasses, so the v2 output form the model sees is
+exactly what it was. A v2 record still reads as v3, a v3 record dumps its extra fields wherever it
+is dumped as a portfolio, and what the model gets wrong about ids is dropped item by item.
+"""
+
+from __future__ import annotations
+
+import json
+from typing import Any
+
+import pytest
+from pydantic import ValidationError
+
+from thoth.application.services.hypothesis_contract_v3 import (
+ CONTRACT_V2,
+ CONTRACT_V3,
+ DroppedExpectation,
+ generator_contract,
+ settle_contract,
+)
+from thoth.domain.enums import (
+ CausalDepth,
+ HypothesisStatus,
+ PortfolioStatus,
+ Reversibility,
+ RiskTier,
+)
+from thoth.domain.evidence import EvidenceSpan
+from thoth.domain.hypothesis import (
+ DiscriminatingTest,
+ ExpectedResult,
+ Hypothesis,
+ HypothesisPortfolio,
+ HypothesisPortfolioV3,
+ is_v3,
+ portfolio_from_json,
+)
+
+HEAD = "a" * 64
+
+
+def a_test_dict(test_id: str = "t1", **extra: Any) -> dict[str, Any]:
+ return {
+ "test_id": test_id,
+ "procedure_candidate": "compare",
+ "expected_if_true": "worse",
+ "expected_if_alternative": "same",
+ "risk_tier": RiskTier.R0,
+ "reversibility": Reversibility.FULL,
+ **extra,
+ }
+
+
+def hypothesis_dict(hypothesis_id: str, **extra: Any) -> dict[str, Any]:
+ return {
+ "hypothesis_id": hypothesis_id,
+ "object_id": "object:o",
+ "statement": "a cause",
+ "observed_problem": "a problem",
+ "primary_locus": None,
+ "causal_depth": CausalDepth.UNDETERMINED,
+ "scope_conditions": {"condition": "alpha"},
+ "support_evidence_refs": ("span:a",),
+ "counterevidence_refs": (),
+ "counterevidence_queries": ("look for a counterexample",),
+ "assumptions": (),
+ "uncertainty": "unvalidated",
+ "predicted_observations": (),
+ "discriminating_tests": (a_test_dict(),),
+ "status": HypothesisStatus.DRAFT,
+ **extra,
+ }
+
+
+def portfolio_dict(*hypotheses: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "portfolio_id": "portfolio:o",
+ "object_id": "object:o",
+ "hypotheses": hypotheses,
+ "status": PortfolioStatus.DRAFT,
+ "generated_from_head_set": HEAD,
+ }
+
+
+def spans(*ids: str) -> tuple[EvidenceSpan, ...]:
+ class Span: # only the id is read
+ def __init__(self, span_id: str) -> None:
+ self.span_id = span_id
+
+ return tuple(Span(item) for item in ids) # type: ignore[misc]
+
+
+def test_a_v2_portfolio_reads_as_v3_with_nothing_filled_in() -> None:
+ plain = portfolio_dict(hypothesis_dict("h1"), hypothesis_dict("h2"))
+ v3 = HypothesisPortfolioV3.model_validate(plain)
+ assert all(h.refutation_conditions == () for h in v3.hypotheses)
+ assert all(
+ t.expected_by_hypothesis == () for h in v3.hypotheses for t in h.discriminating_tests
+ )
+
+
+def test_the_v2_form_does_not_accept_or_show_the_new_fields() -> None:
+ extra = portfolio_dict(
+ hypothesis_dict("h1", refutation_conditions=("x",)), hypothesis_dict("h2")
+ )
+ with pytest.raises(ValidationError):
+ HypothesisPortfolio.model_validate(extra)
+ # the form the model is shown for v2 is exactly what it was: no new name appears in it
+ schema = json.dumps(HypothesisPortfolio.model_json_schema())
+ assert "expected_by_hypothesis" not in schema and "refutation_conditions" not in schema
+ assert "expected_by_hypothesis" in json.dumps(HypothesisPortfolioV3.model_json_schema())
+
+
+def test_a_v3_portfolio_keeps_its_extra_fields_when_dumped_as_a_portfolio() -> None:
+ filled = a_test_dict(
+ expected_by_hypothesis=({"hypothesis_id": "h2", "expected": "same", "basis": ("span:a",)},)
+ )
+ v3 = HypothesisPortfolioV3.model_validate(
+ portfolio_dict(
+ hypothesis_dict(
+ "h1", discriminating_tests=(filled,), refutation_conditions=("if both runs match",)
+ ),
+ hypothesis_dict("h2"),
+ )
+ )
+ # a v2-typed holder still dumps what the object has
+ holder = HypothesisPortfolio.model_construct(
+ **{k: getattr(v3, k) for k in HypothesisPortfolio.model_fields}
+ )
+ dumped = holder.model_dump(mode="json")
+ first = dumped["hypotheses"][0]
+ assert first["refutation_conditions"] == ["if both runs match"]
+ assert first["discriminating_tests"][0]["expected_by_hypothesis"][0]["expected"] == "same"
+ assert portfolio_from_json(dumped, v3=True).hypotheses[0].refutation_conditions == (
+ "if both runs match",
+ )
+ assert portfolio_from_json(
+ json.loads(
+ HypothesisPortfolio.model_validate(
+ portfolio_dict(hypothesis_dict("h1"), hypothesis_dict("h2"))
+ ).model_dump_json()
+ ),
+ v3=False,
+ )
+
+
+def test_the_form_is_chosen_by_the_version_never_by_words_in_the_text() -> None:
+ # a v2 portfolio whose sentences happen to contain the names of the v3 fields stays v2
+ text = "the expected_by_hypothesis and refutation_conditions fields are discussed here"
+ plain = portfolio_dict(hypothesis_dict("h1", statement=text), hypothesis_dict("h2"))
+ read = portfolio_from_json(plain, v3=False)
+ assert type(read) is HypothesisPortfolio
+ assert read.hypotheses[0].statement == text
+ # a v3 portfolio reads as v3 even when nothing was filled in
+ assert type(portfolio_from_json(plain, v3=True)) is HypothesisPortfolioV3
+ # and the version follows the portfolio it replaces
+ assert is_v3(HypothesisPortfolioV3.model_validate(plain)) is True
+ assert is_v3(HypothesisPortfolio.model_validate(plain)) is False
+
+
+def test_too_many_conditions_or_a_probability_like_field_are_not_accepted() -> None:
+ with pytest.raises(ValidationError):
+ HypothesisPortfolioV3.model_validate(
+ portfolio_dict(
+ hypothesis_dict("h1", refutation_conditions=tuple(f"c{i}" for i in range(6))),
+ hypothesis_dict("h2"),
+ )
+ )
+ with pytest.raises(ValidationError):
+ ExpectedResult(hypothesis_id="h1", expected="x", basis=(), probability=0.8) # type: ignore[call-arg]
+
+
+def test_the_contract_is_v2_unless_the_work_asks_for_v3() -> None:
+ off = generator_contract(False)
+ on = generator_contract(True)
+ assert (off["prompt_version"], off["output_model"]) == (CONTRACT_V2, HypothesisPortfolio)
+ assert (on["prompt_version"], on["output_model"]) == (CONTRACT_V3, HypothesisPortfolioV3)
+ assert CONTRACT_V2 == "hypothesis_portfolio.v2" and CONTRACT_V3 == "hypothesis_portfolio.v3"
+
+
+def _filled(*rows: dict[str, Any]) -> dict[str, Any]:
+ return a_test_dict(expected_by_hypothesis=rows)
+
+
+def test_an_item_that_names_an_unknown_hypothesis_or_span_is_dropped_alone_and_recorded() -> None:
+ good = {"hypothesis_id": "h2", "expected": "same", "basis": ("span:a",)}
+ unknown_hypothesis = {"hypothesis_id": "h9", "expected": "same", "basis": ()}
+ unknown_span = {"hypothesis_id": "h3", "expected": "worse", "basis": ("span:made-up",)}
+ twice = {"hypothesis_id": "h2", "expected": "other", "basis": ()}
+ own = {"hypothesis_id": "h1", "expected": "worse", "basis": ()}
+ v3 = HypothesisPortfolioV3.model_validate(
+ portfolio_dict(
+ hypothesis_dict(
+ "h1",
+ discriminating_tests=(_filled(own, good, unknown_hypothesis, unknown_span, twice),),
+ ),
+ hypothesis_dict("h2"),
+ hypothesis_dict("h3"),
+ )
+ )
+ settled = settle_contract(v3, spans("span:a"))
+ assert isinstance(settled, HypothesisPortfolioV3)
+ rows = settled.hypotheses[0].discriminating_tests[0].expected_by_hypothesis
+ assert [(r.hypothesis_id, r.expected) for r in rows] == [("h1", "worse"), ("h2", "same")]
+ dropped = settled.hypotheses[0].dropped
+ assert {(d.hypothesis_id, d.reason) for d in dropped} == {
+ ("h9", "UNKNOWN_HYPOTHESIS"),
+ ("h3", "UNKNOWN_SPAN"),
+ ("h2", "DUPLICATE_HYPOTHESIS"),
+ }
+ assert all(isinstance(d, DroppedExpectation) and d.test_id == "t1" for d in dropped)
+ assert settled.hypotheses[1].discriminating_tests[0].expected_by_hypothesis == ()
+
+
+def test_a_v2_output_passes_through_untouched() -> None:
+ plain = HypothesisPortfolio.model_validate(
+ portfolio_dict(hypothesis_dict("h1"), hypothesis_dict("h2"))
+ )
+ assert settle_contract(plain, spans("span:a")) is plain
+ assert DiscriminatingTest.model_validate(a_test_dict()).test_id == "t1"
+ assert Hypothesis.model_validate(hypothesis_dict("h1")).hypothesis_id == "h1"
diff --git a/tests/unit/domain/test_hypothesis_same_group.py b/tests/unit/domain/test_hypothesis_same_group.py
new file mode 100644
index 0000000..7b27d9e
--- /dev/null
+++ b/tests/unit/domain/test_hypothesis_same_group.py
@@ -0,0 +1,81 @@
+"""Hypotheses a person marked as the same one across investigations, and the groups that makes.
+
+The marks are appended events (LINK, UNLINK). A current link is a pair that was linked and not
+unlinked since; a group is everything connected by current links. Nothing is guessed from words.
+"""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+
+import pytest
+from pydantic import ValidationError
+
+from thoth.domain.hypothesis_same import (
+ SameHypothesisEvent,
+ SameHypothesisRecord,
+ current_pairs,
+ group_of,
+ groups,
+)
+
+STAMP = datetime(2026, 10, 6, tzinfo=UTC)
+
+
+def event(a: str, b: str, action: str = "LINK", n: int = 1, note: str = "") -> SameHypothesisEvent:
+ return SameHypothesisEvent(
+ event_id=f"e{n}",
+ hypothesis_ids=(a, b),
+ action=action, # type: ignore[arg-type]
+ note=note,
+ actor_id="human:local-user",
+ created_at=STAMP,
+ )
+
+
+def record(*events: SameHypothesisEvent) -> SameHypothesisRecord:
+ return SameHypothesisRecord(project_id="p", events=events)
+
+
+def test_a_pair_is_two_different_hypotheses_stored_in_order() -> None:
+ assert event("h2", "h1").hypothesis_ids == ("h1", "h2")
+ with pytest.raises(ValidationError):
+ event("h1", "h1")
+ with pytest.raises(ValidationError):
+ SameHypothesisEvent(
+ event_id="e",
+ hypothesis_ids=("h1",), # type: ignore[arg-type]
+ action="LINK",
+ actor_id="human:a",
+ created_at=STAMP,
+ )
+ with pytest.raises(ValidationError):
+ event("h1", "h2", note="x" * 501)
+ with pytest.raises(ValidationError):
+ event("h1", "h2", action="MERGE")
+
+
+def test_two_linked_hypotheses_make_one_group_and_unlinking_ends_it() -> None:
+ linked = record(event("h1", "h2"))
+ assert groups(linked) == [frozenset({"h1", "h2"})]
+ assert current_pairs(linked) == {("h1", "h2")}
+ ended = record(event("h1", "h2"), event("h2", "h1", "UNLINK", n=2))
+ assert groups(ended) == [] and current_pairs(ended) == set()
+ again = record(*ended.events, event("h1", "h2", n=3))
+ assert groups(again) == [frozenset({"h1", "h2"})]
+ assert groups(record()) == []
+
+
+def test_three_or_more_are_one_group_by_their_links_and_an_unlink_can_split_it() -> None:
+ chain = record(event("h1", "h2"), event("h2", "h3", n=2), event("h4", "h5", n=3))
+ assert sorted(map(sorted, groups(chain))) == [["h1", "h2", "h3"], ["h4", "h5"]]
+ assert group_of(chain, "h3") == frozenset({"h1", "h2", "h3"})
+ assert group_of(chain, "h9") == frozenset({"h9"}) # alone: only itself
+ split = record(*chain.events, event("h2", "h3", "UNLINK", n=4))
+ assert sorted(map(sorted, groups(split))) == [["h1", "h2"], ["h4", "h5"]]
+ assert group_of(split, "h3") == frozenset({"h3"})
+ # a cycle stays one group until enough links are gone
+ loop = record(event("h1", "h2"), event("h2", "h3", n=2), event("h1", "h3", n=3))
+ assert groups(record(*loop.events, event("h1", "h2", "UNLINK", n=4))) == [
+ frozenset({"h1", "h2", "h3"})
+ ]
diff --git a/tests/unit/domain/test_trace_closure.py b/tests/unit/domain/test_trace_closure.py
new file mode 100644
index 0000000..a5dfe2d
--- /dev/null
+++ b/tests/unit/domain/test_trace_closure.py
@@ -0,0 +1,98 @@
+"""Closing a trace row: what a person records, and the one closure the rules compute.
+
+"Effect confirmed" is computed: after a fix was recorded for a row that was not met, a later
+verdict of that row is met and stands on results the earlier one did not. The other closures are
+what a person decided outside the system; they are only recorded and never change a verdict.
+"""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+
+import pytest
+from pydantic import ValidationError
+
+from thoth.domain.trace_closure import ClosureEvent, effect_confirmed_by
+from thoth.domain.verification_trace import (
+ AppliedConditions,
+ CauseOfChange,
+ CriterionVerdictState,
+ ResultCandidate,
+ ResultSelection,
+ SelectionPolicy,
+ SubjectKind,
+ VerdictActor,
+ VerdictBasis,
+ VerdictDraft,
+ VerdictReasons,
+ VerdictRevision,
+ seal_verdict,
+)
+
+
+def revision(
+ state: CriterionVerdictState, *results: int, parent: str | None = None
+) -> VerdictRevision:
+ chosen = tuple(ResultCandidate(result_id="RES-1", result_revision=n) for n in results)
+ draft = VerdictDraft(
+ subject_kind=SubjectKind.CRITERION,
+ subject_id="C-1",
+ state=state,
+ cause=CauseOfChange(trigger="CSV_IMPORT"),
+ basis=VerdictBasis(input_digest="in-" + "-".join(map(str, results))),
+ selection=ResultSelection(policy=SelectionPolicy(), candidates=chosen, chosen=chosen),
+ conditions=AppliedConditions(rule_id="R-1", rule_revision=1),
+ actor=VerdictActor(computed_at=datetime(2026, 10, 6, tzinfo=UTC)),
+ reasons=VerdictReasons(),
+ )
+ return seal_verdict(draft, parent)
+
+
+FAIL, PASS = CriterionVerdictState.FAIL_COMPUTED, CriterionVerdictState.PASS_COMPUTED
+
+
+def test_a_met_verdict_on_new_results_after_the_fix_confirms_the_effect() -> None:
+ before = revision(FAIL, 1)
+ assert effect_confirmed_by(before, [revision(PASS, 2)]) is not None
+
+
+def test_a_met_verdict_on_the_same_results_is_not_an_effect() -> None:
+ before = revision(FAIL, 1)
+ assert effect_confirmed_by(before, [revision(PASS, 1)]) is None
+
+
+def test_a_verdict_that_is_still_not_met_is_not_an_effect_however_the_value_moved() -> None:
+ before = revision(FAIL, 1)
+ held = revision(CriterionVerdictState.HOLD_INVALID_RESULT, 2)
+ assert effect_confirmed_by(before, [held, revision(FAIL, 3)]) is None
+
+
+def test_a_row_that_was_already_met_when_the_fix_was_recorded_has_no_effect_to_confirm() -> None:
+ assert effect_confirmed_by(revision(PASS, 1), [revision(PASS, 2)]) is None
+
+
+def test_no_later_verdict_means_nothing_is_confirmed() -> None:
+ assert effect_confirmed_by(revision(FAIL, 1), []) is None
+
+
+def event(**patch: object) -> dict[str, object]:
+ base: dict[str, object] = {
+ "event_id": "c1",
+ "subject_kind": "CRITERION",
+ "subject_id": "C-1",
+ "kind": "FIX_APPLIED",
+ "basis_ref": "ECN-12",
+ "verdict_revision": "r" * 64,
+ "verdict_digest": "d1",
+ "verdict_state": "FAIL_COMPUTED",
+ "actor_id": "human:local-user",
+ "created_at": datetime(2026, 10, 6, tzinfo=UTC),
+ }
+ return {**base, **patch}
+
+
+def test_a_closure_names_the_document_it_rests_on_and_only_the_four_recorded_kinds_exist() -> None:
+ assert ClosureEvent(**event()).basis_ref == "ECN-12" # type: ignore[arg-type]
+ for bad in ({"basis_ref": ""}, {"kind": "EFFECT_CONFIRMED"}, {"kind": "WAIVED"}):
+ with pytest.raises(ValidationError):
+ ClosureEvent(**event(**bad)) # type: ignore[arg-type]
diff --git a/tests/unit/domain/test_verdict_link.py b/tests/unit/domain/test_verdict_link.py
new file mode 100644
index 0000000..22be39f
--- /dev/null
+++ b/tests/unit/domain/test_verdict_link.py
@@ -0,0 +1,279 @@
+"""Verdict link: when a hypothesis stands on an older verdict, and what a re-check keeps."""
+
+from datetime import UTC, datetime
+from decimal import Decimal
+
+import pytest
+
+from thoth.domain.verdict_link import (
+ CurrentVerdict,
+ LinkRecheckEvent,
+ LinkState,
+ VerdictLink,
+ assess_link,
+ is_flip,
+ note_required,
+ verdict_core_digest,
+)
+from thoth.domain.verification_trace import (
+ AppliedConditions,
+ CauseOfChange,
+ CriterionVerdictState,
+ ResultCandidate,
+ ResultSelection,
+ SelectionPolicy,
+ SubjectKind,
+ VerdictActor,
+ VerdictBasis,
+ VerdictDraft,
+ VerdictReasons,
+ verdict_digest_of,
+)
+
+H = "hypothesis:one"
+
+
+def link(state: str = "HOLD_NO_RESULT", digest: str = "d1") -> VerdictLink:
+ return VerdictLink(
+ subject_kind="CRITERION",
+ subject_id="C-1",
+ verdict_revision="r" * 64,
+ verdict_digest=digest,
+ state=state,
+ )
+
+
+def now(state: str, digest: str, revision: str = "n" * 64) -> CurrentVerdict:
+ return CurrentVerdict(verdict_revision=revision, verdict_digest=digest, state=state)
+
+
+def event(
+ reason: str,
+ *,
+ current: str = "d2",
+ hypothesis: str = H,
+ link_digest: str = "d1",
+ flipped: bool = False,
+) -> LinkRecheckEvent:
+ return LinkRecheckEvent(
+ event_id="e1",
+ batch_id="b1",
+ hypothesis_id=hypothesis,
+ hypothesis_revision_digest="h" * 64,
+ subject_kind="CRITERION",
+ subject_id="C-1",
+ link_verdict_digest=link_digest,
+ link_state="HOLD_NO_RESULT",
+ current_verdict_revision="n" * 64,
+ current_verdict_digest=current,
+ current_state="FAIL_COMPUTED",
+ flipped=flipped,
+ reason_code=reason, # type: ignore[arg-type]
+ note="",
+ actor_id="human:local-user",
+ created_at=datetime(2026, 10, 6, tzinfo=UTC),
+ )
+
+
+def test_a_hypothesis_without_a_link_is_not_judged() -> None:
+ assert assess_link(None, now("PASS", "d9")).state is LinkState.NONE
+
+
+def test_the_same_verdict_content_is_current_even_when_only_a_confirmation_was_added() -> None:
+ # a confirmation is stored apart from the verdict: the digest of its content does not move
+ result = assess_link(link(), now("HOLD_NO_RESULT", "d1", revision="m" * 64))
+ assert result.state is LinkState.CURRENT and result.change is None
+
+
+def test_a_changed_verdict_makes_the_link_stale_and_says_how() -> None:
+ held_to_failed = assess_link(link(), now("FAIL_COMPUTED", "d2"))
+ assert (held_to_failed.state, held_to_failed.change) == (LinkState.STALE, "CONTENT_CHANGED")
+ assert (held_to_failed.link_state, held_to_failed.current_state) == (
+ "HOLD_NO_RESULT",
+ "FAIL_COMPUTED",
+ )
+ same_state_other_reason = assess_link(link("FAIL_COMPUTED"), now("FAIL_COMPUTED", "d2"))
+ assert (
+ same_state_other_reason.change == "CONTENT_CHANGED"
+ ) # the state is the same; the content is not
+
+
+@pytest.mark.parametrize(
+ ("before", "after", "flip"),
+ [
+ ("PASS_COMPUTED", "FAIL_COMPUTED", True),
+ ("FAIL_COMPUTED", "PASS_COMPUTED", True),
+ ("PASS", "FAIL", True),
+ ("FAIL", "PASS", True),
+ ("PASS", "FAIL_WITH_INCOMPLETE_COVERAGE", True),
+ ("HOLD_NO_RESULT", "FAIL_COMPUTED", False),
+ ("PASS_COMPUTED", "HOLD_NO_RESULT", False),
+ ("FAIL_COMPUTED", "FAIL_COMPUTED", False),
+ ("PASS_COMPUTED", "PASS_COMPUTED", False),
+ ],
+)
+def test_only_met_to_failed_and_back_is_a_flip(before: str, after: str, flip: bool) -> None:
+ assert is_flip(before, after) is flip
+ found = assess_link(link(before), now(after, "d2"))
+ assert found.change == ("FLIPPED" if flip else "CONTENT_CHANGED")
+
+
+def test_a_row_that_is_gone_is_stale() -> None:
+ found = assess_link(link(), None)
+ assert (found.state, found.change, found.current_state) == (
+ LinkState.STALE,
+ "SUBJECT_MISSING",
+ None,
+ )
+
+
+def test_a_kept_recheck_of_this_very_change_makes_it_rechecked() -> None:
+ for reason in ("UNRELATED", "STILL_MATCHES", "OTHER"):
+ found = assess_link(link(), now("FAIL_COMPUTED", "d2"), (event(reason),), hypothesis_id=H)
+ assert found.state is LinkState.RECHECKED, reason
+ assert found.recheck is not None and found.recheck.reason_code == reason
+
+
+def test_a_recheck_that_asks_for_more_research_leaves_it_stale_and_keeps_the_event() -> None:
+ found = assess_link(
+ link(), now("FAIL_COMPUTED", "d2"), (event("NEEDS_RESEARCH"),), hypothesis_id=H
+ )
+ assert found.state is LinkState.STALE and found.recheck is not None
+
+
+def test_a_recheck_only_counts_for_the_hypothesis_and_the_change_it_looked_at() -> None:
+ kept = event("STILL_MATCHES")
+ assert (
+ assess_link(
+ link(), now("FAIL_COMPUTED", "d2"), (kept,), hypothesis_id="hypothesis:other"
+ ).state
+ is LinkState.STALE
+ )
+ assert (
+ assess_link(link(), now("FAIL_COMPUTED", "d3"), (kept,), hypothesis_id=H).state
+ is LinkState.STALE
+ ) # it changed again
+ assert (
+ assess_link(link(digest="d0"), now("FAIL_COMPUTED", "d2"), (kept,), hypothesis_id=H).state
+ is LinkState.STALE
+ ) # another link
+ # and when the verdict returns to what the hypothesis stood on, it is simply current
+ assert (
+ assess_link(link(), now("HOLD_NO_RESULT", "d1"), (kept,), hypothesis_id=H).state
+ is LinkState.CURRENT
+ )
+
+
+def test_the_newest_event_for_a_change_wins() -> None:
+ first, second = event("STILL_MATCHES"), event("NEEDS_RESEARCH")
+ assert (
+ assess_link(link(), now("FAIL_COMPUTED", "d2"), (first, second), hypothesis_id=H).state
+ is LinkState.STALE
+ )
+ assert (
+ assess_link(link(), now("FAIL_COMPUTED", "d2"), (second, first), hypothesis_id=H).state
+ is LinkState.RECHECKED
+ )
+
+
+@pytest.mark.parametrize(
+ ("reason", "flipped", "needed"),
+ [
+ ("UNRELATED", False, False),
+ ("STILL_MATCHES", False, False),
+ ("NEEDS_RESEARCH", False, False),
+ ("UNRELATED", True, True),
+ ("STILL_MATCHES", True, True),
+ ("NEEDS_RESEARCH", True, False),
+ ("OTHER", False, True),
+ ("OTHER", True, True),
+ ],
+)
+def test_free_text_is_needed_for_other_and_for_keeping_a_flipped_link(
+ reason: str, flipped: bool, needed: bool
+) -> None:
+ assert note_required(reason, flipped) is needed
+
+
+# --- a changed basis under the same verdict --------------------------------------------------
+
+
+def draft(
+ *,
+ state: CriterionVerdictState = CriterionVerdictState.PASS_COMPUTED,
+ chosen_revision: int = 1,
+ source: str = "a.yaml#x",
+ reasons: tuple[str, ...] = (),
+ threshold: str = "0.90",
+) -> VerdictDraft:
+ chosen = ResultCandidate(result_id="RES-1", result_revision=chosen_revision)
+ return VerdictDraft(
+ subject_kind=SubjectKind.CRITERION,
+ subject_id="C-1",
+ state=state,
+ cause=CauseOfChange(trigger="CSV_IMPORT"),
+ basis=VerdictBasis(input_digest=f"in-{chosen_revision}", source_span_refs=(source,)),
+ selection=ResultSelection(
+ policy=SelectionPolicy(), candidates=(chosen,), chosen=(chosen,), excluded=()
+ ),
+ conditions=AppliedConditions(
+ rule_id="R-1", rule_revision=1, condition="weather=dry", threshold=Decimal(threshold)
+ ),
+ actor=VerdictActor(computed_at=datetime(2026, 10, 6, tzinfo=UTC)),
+ reasons=VerdictReasons(computed=reasons),
+ )
+
+
+def test_the_core_ignores_which_results_and_evidence_positions_stand_behind_the_verdict() -> None:
+ base = draft()
+ assert verdict_core_digest(draft(chosen_revision=2)) == verdict_core_digest(base)
+ assert verdict_core_digest(draft(source="b.yaml#y")) == verdict_core_digest(base)
+ # while the full content digest does move, so the link still goes stale
+ assert verdict_digest_of(draft(chosen_revision=2)) != verdict_digest_of(base)
+
+
+def test_the_core_moves_with_the_state_the_reasons_and_the_rule_that_applied() -> None:
+ base = verdict_core_digest(draft())
+ assert verdict_core_digest(draft(state=CriterionVerdictState.FAIL_COMPUTED)) != base
+ assert verdict_core_digest(draft(reasons=("THRESHOLD_NOT_MET:RES-1:value=0.80",))) != base
+ assert verdict_core_digest(draft(threshold="0.95")) != base
+
+
+def test_the_same_state_and_reasons_with_other_results_is_told_apart_as_a_changed_basis() -> None:
+ stored = link(state="PASS_COMPUTED", digest="d1")
+ same_core = assess_link(
+ stored,
+ CurrentVerdict(
+ verdict_revision="n" * 64, verdict_digest="d2", state="PASS_COMPUTED", core_digest="c1"
+ ),
+ link_core_digest="c1",
+ )
+ assert (same_core.state, same_core.change) == (LinkState.STALE, "EVIDENCE_ONLY")
+ other_core = assess_link(
+ stored,
+ CurrentVerdict(
+ verdict_revision="n" * 64, verdict_digest="d2", state="PASS_COMPUTED", core_digest="c2"
+ ),
+ link_core_digest="c1",
+ )
+ assert other_core.change == "CONTENT_CHANGED"
+
+
+def test_a_link_made_before_the_core_was_known_is_not_called_a_changed_basis() -> None:
+ current = CurrentVerdict(
+ verdict_revision="n" * 64, verdict_digest="d2", state="PASS_COMPUTED", core_digest="c1"
+ )
+ unknown = assess_link(link(state="PASS_COMPUTED"), current) # no core to compare with
+ assert (unknown.state, unknown.change) == (LinkState.STALE, "CONTENT_CHANGED")
+
+
+def test_a_changed_basis_stays_stale_and_a_recheck_of_it_is_kept_like_any_other() -> None:
+ current = CurrentVerdict(
+ verdict_revision="n" * 64, verdict_digest="d2", state="PASS_COMPUTED", core_digest="c1"
+ )
+ kept = event("STILL_MATCHES", current="d2")
+ result = assess_link(
+ link(state="PASS_COMPUTED"), current, (kept,), hypothesis_id=H, link_core_digest="c1"
+ )
+ assert (result.state, result.change) == (LinkState.RECHECKED, "EVIDENCE_ONLY")
+ assert not note_required("UNRELATED", flipped=False) # no flip, no words needed
diff --git a/tests/unit/models/test_hypothesis_contract_wire_text.py b/tests/unit/models/test_hypothesis_contract_wire_text.py
new file mode 100644
index 0000000..b8568c4
--- /dev/null
+++ b/tests/unit/models/test_hypothesis_contract_wire_text.py
@@ -0,0 +1,81 @@
+"""The v3 contract text reaches the model only for the v3 prompt version.
+
+No model is called. The Codex, Claude and xAI adapters build their prompt with `prompt_envelope`
+and the OpenAI-compatible adapter calls `role_contract` with the same version, so one check
+covers them.
+"""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+
+from thoth.adapters.models import strict_output_schema
+from thoth.adapters.models.codex_oauth import prompt_envelope, role_contract
+from thoth.adapters.models.hypothesis_contract_text import hypothesis_contract_addendum
+from thoth.application.reducers import SufficiencySignals, assess_information_sufficiency
+from thoth.domain.enums import ModelRole
+from thoth.domain.hypothesis import HypothesisPortfolio, HypothesisPortfolioV3
+from thoth.domain.model import ContextPack, ModelRequest
+
+SHA = "b" * 64
+
+
+def _request(
+ version: str, output_model: type[HypothesisPortfolio]
+) -> ModelRequest[HypothesisPortfolio]:
+ cutoff = datetime(2026, 10, 6, tzinfo=UTC)
+ sufficiency = assess_information_sufficiency(
+ assessment_id="assessment:v3",
+ assessment_revision_id="revision:v3",
+ project_id="project:v3",
+ target_object_id="object:v3",
+ cutoff_at=cutoff,
+ decision_question="What explains the miss?",
+ criteria=(),
+ evidence=(),
+ signals=SufficiencySignals(),
+ policy_version="policy:1",
+ input_head_set_digest=SHA,
+ )
+ return ModelRequest(
+ role=ModelRole.HYPOTHESIS_GENERATOR,
+ project_id="project:v3",
+ cutoff_at=cutoff,
+ context_pack=ContextPack(
+ case_id="case:v3",
+ project_id="project:v3",
+ object_id="object:v3",
+ problem="What explains the miss?",
+ evidence=(),
+ criteria=(),
+ sufficiency=sufficiency,
+ input_head_set_digest=SHA,
+ ),
+ output_model=output_model,
+ prompt_version=version,
+ model_policy_ref="model-policy:v3",
+ max_output_tokens=500,
+ )
+
+
+def test_the_v3_text_is_added_only_for_the_v3_prompt_version() -> None:
+ v3 = prompt_envelope(_request("hypothesis_portfolio.v3", HypothesisPortfolioV3))
+ v2 = prompt_envelope(_request("hypothesis_portfolio.v2", HypothesisPortfolio))
+ assert "CONTRACT_V3" in v3 and "expected_by_hypothesis" in v3 and "refutation_conditions" in v3
+ assert "CONTRACT_V3" not in v2 and "expected_by_hypothesis" not in v2
+ assert hypothesis_contract_addendum("hypothesis_portfolio.v2") == ""
+ assert hypothesis_contract_addendum("") == ""
+
+
+def test_the_contract_without_a_version_is_the_text_v2_always_had() -> None:
+ plain = role_contract("HYPOTHESIS_GENERATOR")
+ assert role_contract("HYPOTHESIS_GENERATOR", "hypothesis_portfolio.v2") == plain
+ assert "CONTRACT_V3" not in plain
+ added = hypothesis_contract_addendum("hypothesis_portfolio.v3")
+ assert added and added in role_contract("HYPOTHESIS_GENERATOR", "hypothesis_portfolio.v3")
+
+
+def test_the_strict_schema_of_the_v3_form_asks_for_the_new_fields() -> None:
+ schema = str(strict_output_schema(HypothesisPortfolioV3))
+ assert "expected_by_hypothesis" in schema and "refutation_conditions" in schema
+ assert "expected_by_hypothesis" not in str(strict_output_schema(HypothesisPortfolio))
diff --git a/tests/unit/models/test_role_wire_context.py b/tests/unit/models/test_role_wire_context.py
index a2953a5..6fd85d7 100644
--- a/tests/unit/models/test_role_wire_context.py
+++ b/tests/unit/models/test_role_wire_context.py
@@ -114,3 +114,41 @@ def test_user_facing_prose_follows_question_language_without_translating_identif
"HYPOTHESIS_GENERATOR"
)
assert "specification, expected_information_value" in role_contract("ACTION_PLANNER")
+
+
+def test_user_facing_prose_is_asked_to_be_plain_korean_without_internal_names() -> None:
+ prompt = _prompt_envelope(
+ ModelRequest(
+ role=ModelRole.SEMANTIC_REVIEWER,
+ project_id="p",
+ cutoff_at=datetime.now(UTC),
+ context_pack=ContextPack(
+ case_id="c",
+ project_id="p",
+ object_id="o",
+ problem="한국어 질문",
+ evidence=(),
+ criteria=(),
+ sufficiency=None,
+ input_head_set_digest="a" * 64,
+ ),
+ output_model=EvidenceRanking,
+ prompt_version="fixture",
+ model_policy_ref="policy",
+ max_output_tokens=1000,
+ )
+ )
+ assert "easy Korean" in prompt
+ # The reader is a radar R&D engineer: standard terms and units stay as the material has them.
+ assert "standard abbreviations" in prompt and "SNR" in prompt and "dB" in prompt
+ assert "not an engineer" not in prompt and "English abbreviations" not in prompt
+ assert "internal codes" in prompt and "field names" in prompt and "made-up ids" in prompt
+ assert "탐지율 0.90 이상" in prompt # a criterion is spelled out in words
+ assert "사실" in prompt and "확인 불가" in prompt # the two labels are written in Korean
+ # Names that must reach the reader unchanged are still protected from translation.
+ assert "span IDs, file names" in prompt and "quoted source text" in prompt
+ # The old rule that kept the labels in English is gone; the new one names them once.
+ assert "FACT/UNKNOWN labels, GPU" not in prompt
+ assert "quoted evidence, FACT/UNKNOWN labels" not in prompt
+ # The structured output rules are untouched.
+ assert "Return only the schema object." in prompt and "Do not invent source IDs." in prompt
diff --git a/tests/unit/sandbox/test_sandbox_contract.py b/tests/unit/sandbox/test_sandbox_contract.py
index 6164542..8c692ec 100644
--- a/tests/unit/sandbox/test_sandbox_contract.py
+++ b/tests/unit/sandbox/test_sandbox_contract.py
@@ -61,9 +61,7 @@ def test_run_spec_forbids_secrets_and_unbounded_docker_image() -> None:
with pytest.raises(ValidationError):
spec(
runtime_profile="DOCKER_POC",
- image_digest=(
- "--mount=type=bind,src=/,dst=/host@sha256:" + "a" * 64
- ),
+ image_digest=("--mount=type=bind,src=/,dst=/host@sha256:" + "a" * 64),
argv=[f"python@sha256:{'b' * 64}", "-c", "print(1)"],
)
with pytest.raises(ValidationError):
@@ -344,3 +342,52 @@ async def test_sandbox_rejects_cross_project_input_before_adapter(tmp_path: Path
with pytest.raises(SandboxFailure, match="outside the project"):
await service.run(cross_project_spec)
assert adapter.seen_specs == []
+
+
+class MicrosecondProjectStore:
+ """A project whose cutoff was typed with sub-millisecond digits."""
+
+ def read(self, project_id: str) -> Project | None:
+ return Project(
+ project_id=project_id,
+ name="Sandbox",
+ cutoff_at=datetime(2026, 8, 31, 0, 0, 0, 456, tzinfo=UTC),
+ overlay="default",
+ policy_binding_ref="policy:sandbox:v1",
+ )
+
+
+def preflight_service() -> SandboxService:
+ router = RegisteredSandboxRouter()
+ router.register(
+ SandboxRuntimeProfile.SCRIPTED,
+ ScriptedSandboxAdapter(),
+ available=True,
+ version="scripted:test",
+ )
+ return SandboxService(
+ router=router,
+ artifacts=cast(ArtifactLedgerPort, object()),
+ objects=cast(ObjectStorePort, object()),
+ controls=cast(ControlRecordService, object()),
+ ingestion=cast(IngestionService, object()),
+ projects=cast(ProjectStorePort, MicrosecondProjectStore()),
+ policies=PolicyGate(policies=StaticSandboxPolicyStore(), clock=FixedClock()),
+ ledger=cast(LedgerPort, object()),
+ clock=cast(ClockPort, FixedClock()),
+ ids=cast(IdGeneratorPort, object()),
+ )
+
+
+def test_preflight_takes_the_stored_cutoff_for_the_project_cutoff_typed_with_microseconds() -> None:
+ stored = datetime(2026, 8, 31, tzinfo=UTC) # what a stored basis keeps: milliseconds
+ try:
+ preflight_service().preflight(spec(cutoff_at=stored))
+ except SandboxFailure as exc:
+ assert "stale" not in str(exc)
+
+
+def test_preflight_still_refuses_a_cutoff_that_moved_by_a_millisecond() -> None:
+ moved = datetime(2026, 8, 31, 0, 0, 0, 1000, tzinfo=UTC)
+ with pytest.raises(SandboxFailure, match="stale"):
+ preflight_service().preflight(spec(cutoff_at=moved))
diff --git a/tests/unit/services/test_action_effect_classification.py b/tests/unit/services/test_action_effect_classification.py
new file mode 100644
index 0000000..150a15f
--- /dev/null
+++ b/tests/unit/services/test_action_effect_classification.py
@@ -0,0 +1,179 @@
+"""A forbidden (R4) effect is classified before the declaration is checked for completeness."""
+
+from __future__ import annotations
+
+import pytest
+
+from thoth.application.reducers.authority_router import classify_authority
+from thoth.application.services.action_service import (
+ ActionService,
+ checked_effect_vector,
+ classify_effect_vector,
+)
+from thoth.domain.action import PROHIBITED_EFFECT_KEYS, ActionRiskFacts
+from thoth.domain.enums import RiskTier
+
+FORBIDDEN = (
+ "changes_official_kpi",
+ "grants_waiver",
+ "changes_safety_threshold",
+ "finalizes_model_weights",
+)
+PROHIBITED = (
+ "R4",
+ "PROHIBITED",
+ ("PROHIBITED_SEMANTIC_AUTHORITY",),
+ ("institution-authority",),
+)
+UNDEFINED = ("R3", "POLICY_UNDEFINED", ("EFFECT_COMPLETENESS_REVIEW",), ("effect-owner",))
+
+
+def test_both_classifiers_use_the_same_four_forbidden_effects() -> None:
+ assert set(PROHIBITED_EFFECT_KEYS) == set(FORBIDDEN)
+ assert set(PROHIBITED_EFFECT_KEYS) <= set(ActionRiskFacts.model_fields)
+
+
+@pytest.mark.parametrize("key", FORBIDDEN)
+@pytest.mark.parametrize("completeness", [{}, {"effect_completeness_confirmed": False}])
+def test_a_forbidden_effect_is_r4_even_when_the_declaration_is_not_complete(
+ key: str, completeness: dict[str, object]
+) -> None:
+ assert classify_effect_vector({**completeness, key: True}) == PROHIBITED
+
+
+@pytest.mark.parametrize("key", FORBIDDEN)
+def test_a_forbidden_effect_is_r4_when_the_declaration_is_complete(key: str) -> None:
+ assert classify_effect_vector({"effect_completeness_confirmed": True, key: True}) == PROHIBITED
+
+
+@pytest.mark.parametrize("key", FORBIDDEN)
+def test_the_deterministic_router_agrees_for_each_forbidden_effect(key: str) -> None:
+ assert classify_authority(ActionRiskFacts(**{key: True})).risk_tier is RiskTier.R4
+
+
+@pytest.mark.parametrize(
+ "effect",
+ [
+ {},
+ {"effect_completeness_confirmed": False},
+ {"effect_completeness_confirmed": False, "external_write": True},
+ {"effect_completeness_confirmed": False, "changes_official_kpi": False},
+ ],
+)
+def test_without_a_forbidden_effect_an_incomplete_declaration_stays_r3_policy_undefined(
+ effect: dict[str, object],
+) -> None:
+ assert classify_effect_vector(effect) == UNDEFINED
+
+
+@pytest.mark.parametrize(
+ ("effect", "expected"),
+ [
+ ({"effect_completeness_confirmed": True}, ("R0", "AUTO_ALLOWED")),
+ (
+ {"effect_completeness_confirmed": True, "changes_local_draft": True},
+ ("R1", "PREAUTHORIZED"),
+ ),
+ ({"effect_completeness_confirmed": True, "sandbox_required": True}, ("R2", "AUTO_ALLOWED")),
+ (
+ {"effect_completeness_confirmed": True, "runs_untrusted_code": True},
+ ("R2", "AUTO_ALLOWED"),
+ ),
+ (
+ {"effect_completeness_confirmed": True, "external_write": True},
+ ("R3", "APPROVAL_REQUIRED"),
+ ),
+ (
+ {"effect_completeness_confirmed": True, "physical_action": True},
+ ("R3", "APPROVAL_REQUIRED"),
+ ),
+ ],
+)
+def test_complete_declarations_keep_their_tiers(
+ effect: dict[str, object], expected: tuple[str, str]
+) -> None:
+ assert classify_effect_vector(effect)[:2] == expected
+
+
+BAD_VALUES = ["yes", "true", "no", "", 1, 0, [], {}, None]
+BOOLEAN_KEYS = (
+ *FORBIDDEN,
+ "effect_completeness_confirmed",
+ "external_write",
+ "physical_action",
+ "changes_official_baseline",
+ "operational_equipment_change",
+ "runs_untrusted_code",
+ "sandbox_required",
+ "changes_local_draft",
+)
+
+
+@pytest.mark.parametrize("key", BOOLEAN_KEYS)
+@pytest.mark.parametrize("bad", BAD_VALUES, ids=repr)
+def test_a_value_that_is_not_true_or_false_is_refused_instead_of_classified(
+ key: str, bad: object
+) -> None:
+ with pytest.raises(ValueError, match=key):
+ checked_effect_vector({"effect_completeness_confirmed": True, key: bad})
+
+
+@pytest.mark.parametrize("bad", BAD_VALUES, ids=repr)
+def test_a_bad_completeness_value_given_beside_the_effect_vector_is_refused_too(
+ bad: object,
+) -> None:
+ with pytest.raises(ValueError, match="effect_completeness_confirmed"):
+ ActionService._effect_vector({"effect_completeness_confirmed": bad, "effect_vector": {}})
+
+
+def test_true_false_and_a_missing_key_are_accepted_and_kept_as_given() -> None:
+ effect = checked_effect_vector(
+ {
+ "effect_completeness_confirmed": True,
+ "grants_waiver": False,
+ "cost": "UNRESOLVED",
+ "data": "WRITE",
+ }
+ )
+ assert effect == {
+ "effect_completeness_confirmed": True,
+ "grants_waiver": False,
+ "cost": "UNRESOLVED",
+ "data": "WRITE",
+ }
+ assert ActionService._effect_vector({})["effect_completeness_confirmed"] is False
+ assert (
+ ActionService._effect_vector({"effect_completeness_confirmed": True})[
+ "effect_completeness_confirmed"
+ ]
+ is True
+ )
+
+
+def test_the_refusal_names_the_key_and_does_not_repeat_the_value() -> None:
+ with pytest.raises(ValueError) as caught:
+ checked_effect_vector({"grants_waiver": "SECRET-TEXT"})
+ assert "grants_waiver" in str(caught.value) and "SECRET-TEXT" not in str(caught.value)
+
+
+@pytest.mark.parametrize("key", FORBIDDEN)
+def test_a_plan_step_with_a_badly_typed_forbidden_effect_is_refused_not_normalized(
+ key: str,
+) -> None:
+ class Ids:
+ def new(self, prefix: str) -> str:
+ return prefix + ":1"
+
+ service = ActionService.__new__(ActionService)
+ service._ids = Ids() # type: ignore[assignment]
+ step = {
+ "step_id": "step:1",
+ "effect_vector": {"effect_completeness_confirmed": True, key: "yes"},
+ }
+ with pytest.raises(ValueError, match=key):
+ service._normalize_step("project:1", step)
+ ok = service._normalize_step(
+ "project:1",
+ {"step_id": "step:1", "effect_vector": {"effect_completeness_confirmed": True, key: True}},
+ )
+ assert ok["risk_tier"] == "R4"
diff --git a/tests/unit/services/test_trace_closure_text.py b/tests/unit/services/test_trace_closure_text.py
new file mode 100644
index 0000000..32380b3
--- /dev/null
+++ b/tests/unit/services/test_trace_closure_text.py
@@ -0,0 +1,140 @@
+"""The closure_status cell of the trace CSV: one line of plain words per closed row, read only."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from tests.integration.trace_demo_helpers import demo_set, with_notice
+
+from thoth.application.services.trace_closure_text import (
+ closure_status_cells,
+ closure_status_text,
+ with_closure_notes,
+)
+from thoth.application.services.trace_csv import (
+ BOM,
+ CLOSURE_COLUMN,
+ COLUMNS,
+ LOSS_MANIFEST,
+ export_csv,
+ export_rows,
+ parse_csv,
+ write_csv,
+)
+
+
+def event(kind: str = "FIX_APPLIED", **patch: Any) -> dict[str, Any]:
+ return {
+ "event_id": "e",
+ "subject_kind": "CRITERION",
+ "subject_id": "C-1",
+ "kind": kind,
+ "basis_ref": "ECN-12",
+ "note": "",
+ "scope": None,
+ "created_at": "2026-10-06T01:02:03+00:00",
+ **patch,
+ }
+
+
+def row(*events: dict[str, Any], confirmed: bool = False, subject: str = "C-1") -> dict[str, Any]:
+ return {
+ "subject_kind": "CRITERION",
+ "subject_id": subject,
+ "effect_confirmed": confirmed,
+ "events": list(events) or [event()],
+ }
+
+
+def test_each_kind_has_its_plain_words_with_the_document_and_the_date() -> None:
+ assert closure_status_text(row()) == (
+ "수정 반영됨(효과 미확인) · 근거 문서: ECN-12 · 기록 날짜: 2026-10-06"
+ )
+ assert closure_status_text(row(event("HUMAN_CLOSED"))).startswith(
+ "사람 확인 종결(시험 결과 없음) · "
+ )
+ assert closure_status_text(row(event("WAIVER_RECORDED"))).startswith(
+ "편차·면제 승인(원 기준 미충족 유지) · "
+ )
+ scoped = row(event("CONDITION_CHANGED", scope="50 m 이하 안개"))
+ assert closure_status_text(scoped).startswith("운용 조건 변경(조건 범위: 50 m 이하 안개) · ")
+
+
+def test_only_the_rule_may_say_the_effect_is_confirmed() -> None:
+ assert closure_status_text(row(confirmed=True)).startswith("효과 확인됨 · 근거 문서: ECN-12")
+ assert not closure_status_text(row()).startswith("효과 확인됨")
+
+
+def test_the_latest_record_is_shown_and_the_earlier_ones_are_only_counted() -> None:
+ shown = closure_status_text(
+ row(
+ event("HUMAN_CLOSED", basis_ref="OLD"),
+ event("WAIVER_RECORDED", basis_ref="DEV-4", created_at="2026-10-07T00:00:00+00:00"),
+ )
+ )
+ assert shown == (
+ "편차·면제 승인(원 기준 미충족 유지) · 근거 문서: DEV-4 · 기록 날짜: 2026-10-07"
+ " · 이전 기록 1건(THOTH에서 확인)"
+ )
+ assert "OLD" not in shown
+
+
+def test_the_cells_are_keyed_by_the_row_and_hold_no_internal_code() -> None:
+ cells = closure_status_cells([row(subject="C-1"), row(event("HUMAN_CLOSED"), subject="R-1")])
+ assert set(cells) == {"C-1", "R-1"}
+ for text in cells.values():
+ assert "FIX_APPLIED" not in text and "HUMAN_CLOSED" not in text and "None" not in text
+ assert closure_status_cells([]) == {}
+
+
+def test_a_closure_column_appears_only_when_some_row_carries_a_value() -> None:
+ trace_set = with_notice(demo_set(1))
+ assert CLOSURE_COLUMN == "closure_status" and CLOSURE_COLUMN not in COLUMNS
+ plain = export_csv(trace_set)
+ assert CLOSURE_COLUMN not in plain.removeprefix(BOM).splitlines()[0]
+ assert write_csv(export_rows(trace_set)) == plain
+ criterion = next(item.item_id for item in trace_set.items if item.kind.value == "CRITERION")
+ rows = with_closure_notes(
+ export_rows(trace_set),
+ {criterion: "효과 확인됨 · 근거 문서: ECN-12 · 기록 날짜: 2026-10-06"},
+ )
+ marked = [item for item in rows if item.get(CLOSURE_COLUMN)]
+ assert [(item["row_type"], item["item_id"]) for item in marked] == [("ITEM", criterion)]
+ assert CLOSURE_COLUMN in write_csv(rows).removeprefix(BOM).splitlines()[0]
+
+
+def test_a_value_is_written_for_an_item_row_of_the_right_kind_only() -> None:
+ trace_set = demo_set(1)
+ by_kind = {item.item_id: item.kind.value for item in trace_set.items}
+ test_case = next(key for key, kind in by_kind.items() if kind == "TEST_CASE")
+ requirement = next(key for key, kind in by_kind.items() if kind == "REQUIREMENT")
+ rows = with_closure_notes(export_rows(trace_set), {test_case: "x", requirement: "y"})
+ assert [item["item_id"] for item in rows if item.get(CLOSURE_COLUMN)] == [requirement]
+
+
+def test_the_column_is_known_but_never_read_back() -> None:
+ trace_set = demo_set(1)
+ criterion = next(item.item_id for item in trace_set.items if item.kind.value == "CRITERION")
+ text = write_csv(
+ with_closure_notes(
+ export_rows(trace_set), {criterion: "사람 확인 종결(시험 결과 없음) · x"}
+ )
+ )
+ parsed = parse_csv(text)
+ assert parsed.issues == [] and parsed.ignored_columns == ()
+ again = parse_csv(text.replace("사람 확인 종결(시험 결과 없음) · x", "효과 확인됨"))
+ assert [item.model for item in parsed.rows] == [item.model for item in again.rows]
+
+
+def test_a_value_that_a_spreadsheet_would_run_is_written_with_the_apostrophe() -> None:
+ trace_set = demo_set(1)
+ criterion = next(item.item_id for item in trace_set.items if item.kind.value == "CRITERION")
+ text = write_csv(
+ with_closure_notes(export_rows(trace_set), {criterion: '=HYPERLINK("http://x","y")'})
+ )
+ assert "'=HYPERLINK" in text and ",=HYPERLINK" not in text
+
+
+def test_the_loss_list_says_the_column_is_for_reading_only() -> None:
+ line = next(item for item in LOSS_MANIFEST if item.startswith("closures:"))
+ assert "closure_status" in line and "reading only" in line
diff --git a/tests/unit/services/test_trace_csv_format.py b/tests/unit/services/test_trace_csv_format.py
new file mode 100644
index 0000000..58dc0ba
--- /dev/null
+++ b/tests/unit/services/test_trace_csv_format.py
@@ -0,0 +1,367 @@
+"""The trace CSV format by itself: escaping, rows, spreadsheet damage, planning an import."""
+
+from __future__ import annotations
+
+import csv
+import io
+
+import pytest
+from tests.integration.trace_demo_helpers import NOTICE, demo_set, with_notice
+
+from thoth.application.services.trace_csv import (
+ BOM,
+ COLUMNS,
+ FORMAT,
+ escape_cell,
+ export_csv,
+ export_rows,
+ parse_csv,
+ spreadsheet_damage,
+ spreadsheet_suspects,
+ unescape_cell,
+ write_csv,
+)
+from thoth.application.services.verification_trace_import import plan_import
+from thoth.domain.verification_trace import (
+ TraceItem,
+ TraceKind,
+ TraceSet,
+ VerificationTraceRecord,
+)
+
+TEXTS = [
+ "",
+ "plain",
+ "=1+1",
+ "+SUM(A1)",
+ "-5",
+ "@cmd",
+ "\tTab",
+ "\rCR",
+ "'=x",
+ "''+x",
+ "'",
+ "'plain",
+ "a'b",
+ "=",
+ "한글 =x",
+ "-",
+]
+
+
+@pytest.mark.parametrize("text", TEXTS)
+def test_escaping_is_undone_exactly(text: str) -> None:
+ assert unescape_cell(escape_cell(text)) == text
+
+
+def test_only_cells_a_spreadsheet_would_run_get_an_apostrophe() -> None:
+ assert escape_cell("=1+1") == "'=1+1"
+ assert escape_cell("-5") == "'-5"
+ assert escape_cell("'=x") == "''=x"
+ assert escape_cell("plain") == "plain"
+ assert escape_cell("'plain") == "'plain"
+ assert escape_cell("a=b") == "a=b"
+ assert unescape_cell("'plain") == "'plain"
+
+
+def with_row(**cells: str) -> list[str]:
+ row = {name: "" for name in COLUMNS}
+ row["format"] = FORMAT
+ row.update(cells)
+ return [row[name] for name in COLUMNS]
+
+
+def text_of(rows: list[list[str]], header: list[str] | None = None) -> str:
+ buffer = io.StringIO()
+ writer = csv.writer(buffer, lineterminator="\r\n")
+ writer.writerow(header or list(COLUMNS))
+ writer.writerows(rows)
+ return buffer.getvalue()
+
+
+def test_export_starts_with_the_mark_uses_crlf_and_the_fixed_columns() -> None:
+ text = export_csv(demo_set(1))
+ assert text.startswith(BOM)
+ assert "\r\n" in text and "\n" not in text.replace("\r\n", "")
+ assert text.removeprefix(BOM).splitlines()[0] == ",".join(COLUMNS)
+
+
+def test_what_is_exported_reads_back_as_the_same_things() -> None:
+ original = demo_set(2)
+ parsed = parse_csv(export_csv(original))
+ assert parsed.issues == [] and parsed.base_digests == (original.set_digest,)
+ by_type: dict[str, dict[str, object]] = {}
+ for row in parsed.rows:
+ by_type.setdefault(row.row_type, {})[row.key] = row.model
+ assert by_type["ITEM"] == {item.item_id: item for item in original.items}
+ assert by_type["LINK"] == {link.link_id: link for link in original.links}
+ assert by_type["RULE"] == {rule.rule_id: rule for rule in original.rules}
+ assert by_type["RESULT"] == {result.result_id: result for result in original.results}
+
+
+def test_a_row_is_found_by_its_id_not_by_its_line() -> None:
+ rows = [
+ with_row(row_type="ITEM", item_id=name, item_key="k" + name, kind="REQUIREMENT")
+ for name in ("B", "A")
+ ]
+ one = parse_csv(text_of(rows))
+ two = parse_csv(text_of(list(reversed(rows))))
+ assert {row.key for row in one.rows} == {row.key for row in two.rows} == {"A", "B"}
+ assert [row.row for row in one.rows] == [2, 3] # line numbers are for messages only
+
+
+def codes(text: str) -> list[str]:
+ return [issue.code for issue in parse_csv(text).issues]
+
+
+def test_bad_files_and_rows_are_reported_and_never_raise() -> None:
+ assert codes("") == ["CSV_EMPTY"]
+ assert codes("a,b\r\n1,2\r\n") == ["HEADER_MISSING_COLUMNS"]
+ duplicate = [*COLUMNS, "title"]
+ assert codes(text_of([], header=duplicate)) == ["HEADER_DUPLICATE_COLUMN"]
+ other = with_row(row_type="ITEM", item_id="A", kind="REQUIREMENT")
+ other[0] = "other-csv/9"
+ assert codes(text_of([other])) == ["FORMAT_UNSUPPORTED"]
+ assert codes(text_of([with_row(row_type="WHAT", item_id="A")])) == ["ROW_TYPE_UNKNOWN"]
+ assert codes(text_of([with_row(row_type="DELETE", target_kind="ITEM")])) == [
+ "DELETE_ROW_INCOMPLETE"
+ ]
+ assert codes(text_of([with_row(row_type="DELETE", target_id="A")])) == ["DELETE_ROW_INCOMPLETE"]
+ assert codes(text_of([with_row(row_type="ITEM", item_key="k", kind="REQUIREMENT")])) == [
+ "ID_MISSING"
+ ]
+ assert codes(
+ text_of([with_row(row_type="ITEM", item_id="A", item_key="k", kind="NOT_A_KIND")])
+ ) == ["ROW_INVALID"]
+ assert codes(
+ text_of([with_row(row_type="LINK", link_id="L", from_id="A", to_id="B", relation="LOVES")])
+ ) == ["ROW_INVALID"]
+ assert codes(
+ text_of(
+ [
+ with_row(
+ row_type="RULE",
+ rule_id="R",
+ criterion_id="C",
+ measure="m",
+ comparator="==",
+ threshold="1",
+ unit="u",
+ condition="c",
+ required="true",
+ )
+ ]
+ )
+ ) == ["ROW_INVALID"]
+ assert codes(
+ text_of(
+ [
+ with_row(
+ row_type="RULE",
+ rule_id="R",
+ criterion_id="C",
+ measure="m",
+ comparator=">=",
+ threshold="NaN",
+ unit="u",
+ condition="c",
+ required="true",
+ )
+ ]
+ )
+ ) == ["ROW_INVALID"]
+ twice = [with_row(row_type="ITEM", item_id="A", item_key="k", kind="REQUIREMENT")] * 2
+ assert codes(text_of(twice)) == ["DUPLICATE_ID_IN_FILE"]
+
+
+def test_extra_columns_are_ignored_and_reported_and_short_rows_are_padded() -> None:
+ header = [*COLUMNS, "my notes"]
+ row = [*with_row(row_type="ITEM", item_id="A", item_key="k", kind="REQUIREMENT"), "hello"]
+ parsed = parse_csv(text_of([row], header=header))
+ assert parsed.issues == [] and parsed.ignored_columns == ("my notes",)
+ padded = parse_csv(",".join(COLUMNS) + "\r\n" + FORMAT + ",ITEM\r\n")
+ assert [issue.code for issue in padded.issues] == ["ID_MISSING"]
+
+
+def test_an_item_with_no_key_gets_a_fixed_one_made_from_its_id() -> None:
+ parsed = parse_csv(text_of([with_row(row_type="ITEM", item_id="A", kind="REQUIREMENT")]))
+ (row,) = parsed.rows
+ assert isinstance(row.model, TraceItem) and row.model.item_key.startswith("csv-")
+ assert row.key_given is False
+
+
+@pytest.mark.parametrize(
+ ("original", "incoming", "reason"),
+ [
+ ("007", "7", "LEADING_ZEROS_LOST"),
+ ("00", "0", "LEADING_ZEROS_LOST"),
+ ("12345678901234567890", "1.23457E+19", "EXPONENT_FORM"),
+ ("1-2", "2-Jan", "DATE_FORM"),
+ ("2026-10", "Oct-26", "DATE_FORM"),
+ ("2026-10", "2026-10-01", "DATE_FORM"),
+ ("007", "007", None),
+ ("A-1", "A-2", None),
+ ("12", "1.2E+1", None),
+ ("1-2", "7-Aug", None),
+ ("123", "12", None),
+ ],
+)
+def test_ids_a_spreadsheet_rewrites_are_recognised(
+ original: str, incoming: str, reason: str | None
+) -> None:
+ assert spreadsheet_damage(original, incoming) == reason
+
+
+def test_a_new_id_is_only_a_suspect_when_the_original_is_missing_from_the_file() -> None:
+ assert spreadsheet_suspects({"007"}, {"7"}) == [("7", "007", "LEADING_ZEROS_LOST")]
+ assert spreadsheet_suspects({"007"}, {"007", "7"}) == []
+ assert spreadsheet_suspects({"007"}, {"8"}) == []
+
+
+def record(*items: TraceItem) -> VerificationTraceRecord:
+ return VerificationTraceRecord(project_id="p", trace_set=TraceSet(items=items))
+
+
+def item(item_id: str, key: str, title: str = "") -> TraceItem:
+ return TraceItem(item_id=item_id, item_key=key, kind=TraceKind.REQUIREMENT, title=title)
+
+
+def test_update_needs_one_base_digest_that_matches_the_trace_now() -> None:
+ current = record(item("A", "ka"))
+ now = current.trace_set.set_digest
+
+ def conflicts(*digests: str) -> set[str]:
+ rows = [
+ with_row(
+ row_type="ITEM",
+ item_id=f"I{number}",
+ item_key=f"k{number}",
+ kind="REQUIREMENT",
+ base_set_digest=digest,
+ )
+ for number, digest in enumerate(digests)
+ ]
+ return {
+ issue.code for issue in plan_import("p", current, "UPDATE", text_of(rows)).conflicts
+ }
+
+ assert conflicts("") == {"BASE_DIGEST_MISSING"}
+ assert conflicts(now, "f" * 64) == {"BASE_DIGEST_INCONSISTENT"}
+ assert conflicts("f" * 64) == {"STALE_BASE"}
+ assert conflicts(now) == set()
+
+
+def test_an_item_row_with_no_key_keeps_the_key_it_has_and_a_changed_key_is_refused() -> None:
+ current = record(item("A", "ka", "old"))
+ now = current.trace_set.set_digest
+ blank = with_row(
+ row_type="ITEM", item_id="A", kind="REQUIREMENT", title="new", base_set_digest=now
+ )
+ plan = plan_import("p", current, "UPDATE", text_of([blank]))
+ assert plan.conflicts == [] and [change.fields for change in plan.updated] == [("title",)]
+ assert plan.new_set is not None and plan.new_set.items[0].item_key == "ka"
+ other = with_row(
+ row_type="ITEM", item_id="A", item_key="zz", kind="REQUIREMENT", base_set_digest=now
+ )
+ assert [i.code for i in plan_import("p", current, "UPDATE", text_of([other])).conflicts] == [
+ "ITEM_KEY_CHANGED"
+ ]
+
+
+def test_the_preview_id_follows_the_exact_text_and_the_trace_now() -> None:
+ current = record(item("A", "ka"))
+ now = current.trace_set.set_digest
+ row = with_row(
+ row_type="ITEM", item_id="A", item_key="ka", kind="REQUIREMENT", base_set_digest=now
+ )
+ text = text_of([row])
+ same = plan_import("p", current, "UPDATE", text)
+ assert same.preview_id == plan_import("p", current, "UPDATE", text).preview_id
+ assert same.preview_id != plan_import("p", current, "UPDATE", text + "\r\n ,").preview_id
+ assert same.preview_id != plan_import("p", record(item("B", "kb")), "UPDATE", text).preview_id
+ assert same.preview_id != plan_import("q", current, "UPDATE", text).preview_id
+ assert same.input_sha256 != plan_import("p", current, "UPDATE", BOM + text).input_sha256
+
+
+def test_export_rows_can_be_written_and_read_by_other_tools() -> None:
+ rows = export_rows(demo_set(1))
+ read = list(csv.DictReader(io.StringIO(write_csv(rows).removeprefix(BOM), newline="")))
+ assert read[0]["format"] == FORMAT and {row["row_type"] for row in read} == {
+ "ITEM",
+ "LINK",
+ "RULE",
+ "RESULT",
+ }
+
+
+def test_a_synthetic_notice_has_its_own_column_on_every_row_and_reads_back() -> None:
+ original = with_notice(demo_set(1))
+ text = export_csv(original)
+ rows = list(csv.DictReader(io.StringIO(text.removeprefix(BOM), newline="")))
+ assert list(rows[0]) == [*COLUMNS, "synthetic_notice"]
+ assert {row["synthetic_notice"] for row in rows} == {NOTICE}
+ assert all("synthetic_notice" not in row["fields_json"] for row in rows)
+ parsed = parse_csv(text)
+ assert parsed.issues == [] and parsed.ignored_columns == ()
+ assert [row.model for row in parsed.rows if row.row_type == "ITEM"] == [
+ item
+ for item in sorted(original.items, key=lambda i: (list(TraceKind).index(i.kind), i.item_id))
+ ]
+
+
+def test_a_set_without_a_notice_gets_no_notice_column() -> None:
+ header = export_csv(demo_set(1)).removeprefix(BOM).splitlines()[0]
+ assert "synthetic_notice" not in header
+
+
+def test_a_file_without_the_notice_column_still_imports_and_keeps_the_notice() -> None:
+ current = VerificationTraceRecord(project_id="p", trace_set=with_notice(demo_set(1)))
+ now = current.trace_set.set_digest
+ row = with_row(
+ row_type="ITEM",
+ item_id="SYN-REQ-RAD-001",
+ item_key="k-req",
+ kind="REQUIREMENT",
+ title="renamed",
+ base_set_digest=now,
+ )
+ plan = plan_import("p", current, "UPDATE", text_of([row])) # the old header has no such column
+ assert plan.conflicts == [] and [change.fields for change in plan.updated] == [("title",)]
+ assert plan.new_set is not None
+ kept = next(i for i in plan.new_set.items if i.item_id == "SYN-REQ-RAD-001")
+ assert kept.fields["synthetic_notice"] == NOTICE
+ assert not any("synthetic_notice" in line for line in plan.losses)
+
+
+def test_a_blank_notice_cell_cannot_remove_the_notice_and_a_filled_one_adds_it() -> None:
+ current = VerificationTraceRecord(project_id="p", trace_set=with_notice(demo_set(1)))
+ now = current.trace_set.set_digest
+ header = [*COLUMNS, "synthetic_notice"]
+ same = [
+ *with_row(
+ row_type="ITEM",
+ item_id="SYN-REQ-RAD-001",
+ item_key="k-req",
+ kind="REQUIREMENT",
+ title=next(i.title for i in current.trace_set.items if i.item_id == "SYN-REQ-RAD-001"),
+ base_set_digest=now,
+ ),
+ "",
+ ]
+ plan = plan_import("p", current, "UPDATE", text_of([same], header=header))
+ assert plan.conflicts == [] and plan.updated == [] and plan.unchanged == 1
+ fresh = [
+ *with_row(
+ row_type="ITEM",
+ item_id="NEW-1",
+ item_key="knew",
+ kind="REQUIREMENT",
+ base_set_digest=now,
+ ),
+ NOTICE,
+ ]
+ added = plan_import("p", current, "UPDATE", text_of([fresh], header=header))
+ assert added.new_set is not None
+ assert next(i for i in added.new_set.items if i.item_id == "NEW-1").fields == {
+ "synthetic_notice": NOTICE
+ }
diff --git a/tests/unit/services/test_verification_trace.py b/tests/unit/services/test_verification_trace.py
new file mode 100644
index 0000000..c8caa1f
--- /dev/null
+++ b/tests/unit/services/test_verification_trace.py
@@ -0,0 +1,604 @@
+"""Rule-computed verdicts for the requirement trace: states, history, currentness, confirmation."""
+
+from __future__ import annotations
+
+import json
+from datetime import datetime, timedelta
+from decimal import Decimal
+from pathlib import Path
+from typing import Any
+
+import pytest
+from tests.integration.test_research_request_v2 import ControlledResearchModel, setup
+from tests.integration.trace_demo_helpers import (
+ POLICY,
+ REQ,
+ SCORER,
+ T0,
+ demo_set,
+ read_yaml,
+)
+
+from thoth.adapters.runtime import SystemClock, UuidIdGenerator
+from thoth.adapters.storage.control_record import SqliteControlRecordStore
+from thoth.application.services.request_records import RequestRecords
+from thoth.application.services.verification_trace import (
+ TraceConflict,
+ TraceError,
+ VerificationTraceService,
+ compute_verdicts,
+ dependency_changes,
+ mark_stale,
+)
+from thoth.domain.verification_trace import (
+ ChangedDependency,
+ Comparator,
+ CriterionRule,
+ CriterionVerdictState,
+ CurrentnessState,
+ RequirementVerdictState,
+ ResultRecord,
+ SelectionPolicy,
+ SelectionPolicyName,
+ SubjectKind,
+ TraceItem,
+ TraceKind,
+ TraceLink,
+ TraceRelation,
+ TraceSet,
+ VerdictRevision,
+ subject_key,
+)
+
+
+def states(verdicts: tuple[VerdictRevision, ...]) -> dict[str, str]:
+ return {item.subject_id: item.state.value for item in verdicts}
+
+
+def by_id(verdicts: tuple[VerdictRevision, ...]) -> dict[str, VerdictRevision]:
+ return {item.subject_id: item for item in verdicts}
+
+
+def run(
+ trace_set: TraceSet,
+ *,
+ previous: tuple[VerdictRevision, ...] = (),
+ at: datetime = T0,
+ trigger: str = "RECOMPUTE",
+ changes: tuple[ChangedDependency, ...] = (),
+) -> tuple[VerdictRevision, ...]:
+ return compute_verdicts(
+ trace_set,
+ POLICY,
+ computed_at=at,
+ previous=previous,
+ trigger=trigger,
+ changes=changes,
+ )
+
+
+# --- the demo phases ---
+
+
+@pytest.mark.parametrize("phase", [1, 2])
+def test_demo_phases_give_the_states_the_scorer_file_expects(phase: int) -> None:
+ expected = read_yaml(SCORER)["phases"][phase]
+ got = states(run(demo_set(phase)))
+ assert {key: got[key] for key in expected["criteria"]} == expected["criteria"]
+ assert {key: got[key] for key in expected["requirement"]} == expected["requirement"]
+
+
+def test_phase_one_is_held_and_phase_two_fails_with_incomplete_coverage() -> None:
+ one = by_id(run(demo_set(1)))
+ assert one[REQ].state is RequirementVerdictState.HOLD_INCOMPLETE
+ assert one["SYN-C-DET-RAIN"].state is CriterionVerdictState.HOLD_NO_RESULT
+ assert one["SYN-C-DET-RAIN"].reasons.computed == ("NO_RESULT:weather=rain",)
+ two = by_id(run(demo_set(2)))
+ assert two[REQ].state is RequirementVerdictState.FAIL_WITH_INCOMPLETE_COVERAGE
+ assert two["SYN-C-DET-RAIN"].reasons.computed == (
+ "THRESHOLD_NOT_MET:SYN-RES-SYN-C-DET-RAIN:value=0.80 need >= 0.90 ratio",
+ )
+ assert two["SYN-C-FA-FOG"].state is CriterionVerdictState.HOLD_NO_RESULT
+
+
+def test_each_verdict_records_the_rule_conditions_and_results_it_used() -> None:
+ dry = by_id(run(demo_set(2)))["SYN-C-DET-DRY"]
+ assert dry.conditions.condition == "weather=dry" and dry.conditions.unit == "ratio"
+ assert dry.conditions.comparator is Comparator.AT_LEAST and dry.conditions.threshold == Decimal(
+ "0.90"
+ )
+ assert dry.selection is not None
+ assert [item.result_id for item in dry.selection.chosen] == ["SYN-RES-SYN-C-DET-DRY"]
+ assert dry.selection.policy == POLICY
+ assert dry.basis.source_span_refs == ("30_RESULT_DRY_SYNTHETIC.yaml#detection",)
+ assert dry.basis.evidence_text_preserved is False
+ assert dry.actor.actor_id == "system:verification-trace"
+ requirement = by_id(run(demo_set(2)))[REQ]
+ assert len(requirement.conditions.required_criteria) == 6
+ assert len(requirement.basis.child_verdict_digests) == 6
+
+
+# --- the states ---
+
+
+def mini(
+ results: list[ResultRecord],
+ *,
+ comparator: Comparator = Comparator.AT_MOST,
+ threshold: str = "0.50",
+ unit: str = "per_min",
+ required: bool = True,
+ with_criterion: bool = True,
+) -> TraceSet:
+ items = [TraceItem(item_id="REQ-1", item_key="k1", kind=TraceKind.REQUIREMENT)]
+ links: list[TraceLink] = []
+ rules: list[CriterionRule] = []
+ if with_criterion:
+ items.append(TraceItem(item_id="C-1", item_key="k2", kind=TraceKind.CRITERION))
+ links.append(
+ TraceLink(link_id="l1", from_id="C-1", to_id="REQ-1", relation=TraceRelation.REFINES)
+ )
+ rules.append(
+ CriterionRule(
+ rule_id="R-1",
+ criterion_id="C-1",
+ measure="m",
+ comparator=comparator,
+ threshold=Decimal(threshold),
+ unit=unit,
+ condition="weather=rain",
+ required=required,
+ )
+ )
+ items.extend(
+ TraceItem(item_id=item.result_id, item_key=f"k-{item.result_id}", kind=TraceKind.RESULT)
+ for item in results
+ )
+ return TraceSet(
+ items=tuple(items), links=tuple(links), rules=tuple(rules), results=tuple(results)
+ )
+
+
+def result(
+ rid: str = "RES-1",
+ *,
+ value: str | None = "0",
+ unit: str = "per_min",
+ day: int = 0,
+ revision: int = 1,
+ condition: str = "weather=rain",
+ raw: str | None = None,
+ numerator: int | None = None,
+ denominator: int | None = None,
+) -> ResultRecord:
+ return ResultRecord(
+ result_id=rid,
+ result_revision=revision,
+ criterion_id="C-1",
+ condition=condition,
+ value=None if value is None else Decimal(value),
+ raw_value=raw,
+ unit=unit,
+ observed_at=T0 + timedelta(days=day),
+ numerator=numerator,
+ denominator=denominator,
+ )
+
+
+def test_a_measured_zero_is_a_value_and_an_empty_cell_is_not() -> None:
+ zero = by_id(run(mini([result(value="0", raw="0")])))
+ assert zero["C-1"].state is CriterionVerdictState.PASS_COMPUTED
+ assert (
+ by_id(run(mini([result(value="0")], comparator=Comparator.AT_LEAST, threshold="0.90")))[
+ "C-1"
+ ].state
+ is CriterionVerdictState.FAIL_COMPUTED
+ )
+ empty = by_id(run(mini([result(value=None)])))["C-1"]
+ assert empty.state is CriterionVerdictState.HOLD_INVALID_RESULT
+ assert empty.reasons.computed == ("VALUE_MISSING:RES-1",)
+ blank = by_id(run(mini([result(value=None, raw=" ")])))["C-1"]
+ assert blank.reasons.computed == ("VALUE_MISSING:RES-1",)
+ word = by_id(run(mini([result(value=None, raw="null")])))["C-1"]
+ assert word.reasons.computed == ("VALUE_NOT_A_NUMBER:RES-1",)
+
+
+def test_a_value_equal_to_the_threshold_meets_both_comparators() -> None:
+ at_most = by_id(run(mini([result(value="0.50")])))["C-1"]
+ assert at_most.state is CriterionVerdictState.PASS_COMPUTED
+ at_least = mini([result(value="0.90")], comparator=Comparator.AT_LEAST, threshold="0.90")
+ assert by_id(run(at_least))["C-1"].state is CriterionVerdictState.PASS_COMPUTED
+ just_over = by_id(run(mini([result(value="0.51")])))["C-1"]
+ assert just_over.state is CriterionVerdictState.FAIL_COMPUTED
+ below = mini([result(value="0.89")], comparator=Comparator.AT_LEAST, threshold="0.90")
+ assert by_id(run(below))["C-1"].state is CriterionVerdictState.FAIL_COMPUTED
+
+
+def test_a_wrong_unit_holds_instead_of_comparing() -> None:
+ wrong = by_id(run(mini([result(value="0.10", unit="per_hour")])))
+ assert wrong["C-1"].state is CriterionVerdictState.HOLD_INVALID_RESULT
+ assert wrong["C-1"].reasons.computed == ("UNIT_MISMATCH:RES-1:per_hour!=per_min",)
+ assert wrong["REQ-1"].state is RequirementVerdictState.HOLD_INCOMPLETE
+
+
+def test_counts_that_disagree_with_the_value_hold() -> None:
+ bad = by_id(run(mini([result(value="0.10", numerator=7, denominator=10)])))["C-1"]
+ assert bad.state is CriterionVerdictState.HOLD_INVALID_RESULT
+ assert bad.reasons.computed == ("VALUE_DISAGREES_WITH_COUNTS:RES-1",)
+ derived = by_id(run(mini([result(value=None, numerator=3, denominator=10)])))["C-1"]
+ assert derived.state is CriterionVerdictState.PASS_COMPUTED
+ zero_den = by_id(run(mini([result(value="0.1", numerator=0, denominator=0)])))["C-1"]
+ assert zero_den.reasons.computed == ("DENOMINATOR_NOT_POSITIVE:RES-1",)
+
+
+def test_a_criterion_without_a_rule_holds() -> None:
+ trace = mini([])
+ trace = TraceSet(items=trace.items, links=trace.links, rules=(), results=())
+ verdicts = by_id(run(trace))
+ assert verdicts["C-1"].state is CriterionVerdictState.HOLD_NO_RULE
+ assert verdicts["REQ-1"].state is RequirementVerdictState.HOLD_INCOMPLETE
+
+
+def test_an_empty_set_of_required_criteria_is_never_a_pass() -> None:
+ nothing = by_id(run(mini([], with_criterion=False)))
+ assert nothing["REQ-1"].state is RequirementVerdictState.HOLD_NO_CRITERIA
+ assert nothing["REQ-1"].reasons.computed == ("NO_REQUIRED_CRITERIA",)
+ optional = by_id(run(mini([result(value="0")], required=False)))
+ assert optional["C-1"].state is CriterionVerdictState.PASS_COMPUTED
+ assert optional["REQ-1"].state is RequirementVerdictState.HOLD_NO_CRITERIA
+
+
+def test_requirement_states_and_no_averaging_across_conditions() -> None:
+ assert by_id(run(mini([result(value="0")])))["REQ-1"].state is RequirementVerdictState.PASS
+ assert by_id(run(mini([result(value="0.9")])))["REQ-1"].state is RequirementVerdictState.FAIL
+ # Three conditions: pass, pass, fail. An average of their rates would clear the limit, the rule
+ # must not: one failed condition fails the requirement.
+ trace = demo_set(2)
+ fog = [
+ ResultRecord(
+ result_id=f"SYN-RES-{cid}",
+ criterion_id=cid,
+ condition="weather=fog",
+ value=Decimal(value),
+ unit=unit,
+ observed_at=T0 + timedelta(days=3),
+ )
+ for cid, value, unit in (
+ ("SYN-C-DET-FOG", "0.99", "ratio"),
+ ("SYN-C-FA-FOG", "0.10", "per_min"),
+ )
+ ]
+ items = (
+ *trace.items,
+ *(
+ TraceItem(item_id=f.result_id, item_key=f"k-{f.result_id}", kind=TraceKind.RESULT)
+ for f in fog
+ ),
+ )
+ full = TraceSet(
+ items=items, links=trace.links, rules=trace.rules, results=(*trace.results, *fog)
+ )
+ assert by_id(run(full))[REQ].state is RequirementVerdictState.FAIL
+
+
+def test_result_selection_is_recorded_and_policies_differ() -> None:
+ old_bad = result("RES-OLD", value="0.90", day=0)
+ new_good = result("RES-NEW", value="0.10", day=1)
+ other = result("RES-DRY", value="0.99", condition="weather=dry", day=2)
+ trace = mini([old_bad, new_good, other])
+ latest = by_id(run(trace))["C-1"]
+ assert latest.state is CriterionVerdictState.PASS_COMPUTED
+ assert latest.selection is not None
+ assert [item.result_id for item in latest.selection.chosen] == ["RES-NEW"]
+ assert {(item.result_id, item.reason) for item in latest.selection.excluded} == {
+ ("RES-OLD", "SUPERSEDED_BY_LATER_RESULT"),
+ ("RES-DRY", "CONDITION_MISMATCH"),
+ }
+ strict = compute_verdicts(
+ trace, SelectionPolicy(name=SelectionPolicyName.ALL_MUST_PASS), computed_at=T0
+ )
+ assert by_id(strict)["C-1"].state is CriterionVerdictState.FAIL_COMPUTED
+
+
+# --- digests, history, currentness ---
+
+
+def test_same_input_gives_the_same_digests_and_time_does_not_change_the_verdict() -> None:
+ first, second = run(demo_set(2)), run(demo_set(2))
+ assert [item.revision_digest for item in first] == [item.revision_digest for item in second]
+ later = run(demo_set(2), at=T0 + timedelta(days=9))
+ assert [item.verdict_digest for item in later] == [item.verdict_digest for item in first]
+
+
+def test_adding_a_rain_result_changes_only_rain_and_the_requirement() -> None:
+ one = run(demo_set(1))
+ two = run(demo_set(2), previous=one, at=T0 + timedelta(days=1), trigger="RECOMPUTE")
+ before, after = by_id(one), by_id(two)
+ unchanged = {"SYN-C-DET-DRY", "SYN-C-FA-DRY", "SYN-C-DET-FOG", "SYN-C-FA-FOG"}
+ for name in unchanged:
+ assert after[name] is before[name]
+ for name in ("SYN-C-DET-RAIN", "SYN-C-FA-RAIN", REQ):
+ assert after[name].revision_digest != before[name].revision_digest
+ assert after[name].parent_digest == before[name].revision_digest
+ assert before["SYN-C-DET-RAIN"].parent_digest is None
+
+
+def test_title_only_change_is_not_stale_but_a_rule_or_result_change_is() -> None:
+ old = demo_set(2)
+ items = tuple(
+ item.model_copy(update={"title": item.title + " (renamed)", "fields": {"note": "x"}})
+ for item in old.items
+ )
+ renamed = TraceSet(items=items, links=old.links, rules=old.rules, results=old.results)
+ assert dependency_changes(old, renamed) == ()
+ assert all(item.state is CurrentnessState.CURRENT for item in mark_stale(renamed, ()).values())
+ stricter = [
+ rule.model_copy(update={"threshold": Decimal("0.95"), "rule_revision": 2})
+ if rule.criterion_id == "SYN-C-DET-DRY"
+ else rule
+ for rule in old.rules
+ ]
+ edited = TraceSet(items=old.items, links=old.links, rules=tuple(stricter), results=old.results)
+ changes = dependency_changes(old, edited)
+ stale = mark_stale(edited, changes)
+ flagged = {
+ key[1] for key, value in stale.items() if value.state is CurrentnessState.STALE_BASIS
+ }
+ assert flagged == {"SYN-C-DET-DRY", REQ}
+ threshold = next(item for item in changes if item.field == "threshold")
+ assert (
+ threshold.before,
+ threshold.after,
+ threshold.before_revision,
+ threshold.after_revision,
+ ) == ("0.90", "0.95", 1, 2)
+
+
+def test_a_changed_rule_makes_a_new_dry_revision_that_records_what_changed() -> None:
+ old = demo_set(1)
+ first = run(old)
+ stricter = [
+ rule.model_copy(update={"threshold": Decimal("0.99"), "rule_revision": 2})
+ if rule.criterion_id == "SYN-C-DET-DRY"
+ else rule
+ for rule in old.rules
+ ]
+ new = TraceSet(items=old.items, links=old.links, rules=tuple(stricter), results=old.results)
+ changes = dependency_changes(old, new)
+ second = run(
+ new, previous=first, changes=changes, trigger="RULE_EDIT", at=T0 + timedelta(days=1)
+ )
+ dry = by_id(second)["SYN-C-DET-DRY"]
+ assert dry.state is CriterionVerdictState.FAIL_COMPUTED
+ assert dry.cause.trigger == "RULE_EDIT"
+ assert [(item.field, item.before, item.after) for item in dry.cause.changed] == [
+ ("rule_revision", "1", "2"),
+ ("threshold", "0.90", "0.99"),
+ ]
+ assert by_id(second)["SYN-C-FA-DRY"] is by_id(first)["SYN-C-FA-DRY"]
+
+
+# --- stored behaviour ---
+
+
+async def opened(tmp_path: Path):
+ runtime = await setup(tmp_path, ControlledResearchModel(), source=False)
+ records = RequestRecords(
+ runtime.ledger,
+ SqliteControlRecordStore(runtime.ledger.engine),
+ SystemClock(),
+ UuidIdGenerator(),
+ )
+ return runtime, VerificationTraceService(records)
+
+
+@pytest.mark.asyncio
+async def test_confirmation_is_separate_and_stays_on_the_old_revision(tmp_path: Path) -> None:
+ runtime, service = await opened(tmp_path)
+ try:
+ digest = service.replace_trace_set("p", demo_set(1), None, "human:local-user")
+ digest = service.recompute("p", digest, "human:local-user")
+ _, stored = service.read("p")
+ assert stored is not None
+ dry_one = service.current_verdicts("p", stored)[(SubjectKind.CRITERION, "SYN-C-DET-DRY")]
+ requirement_one = service.current_verdicts("p", stored)[(SubjectKind.REQUIREMENT, REQ)]
+ assert requirement_one.cause.trigger == "INITIAL_COMPUTE"
+
+ history_before = stored.history
+ digest = service.confirm(
+ "p", dry_one.revision_digest, "human:local-user", "checked the dry run", digest
+ )
+ digest = service.confirm(
+ "p", requirement_one.revision_digest, "human:local-user", "ok for now", digest
+ )
+ _, confirmed = service.read("p")
+ assert confirmed is not None and confirmed.history == history_before
+
+ digest = service.replace_trace_set("p", demo_set(2), digest, "human:local-user")
+ flagged = service.currentness("p")
+ assert (
+ flagged[(SubjectKind.CRITERION, "SYN-C-DET-RAIN")].state is CurrentnessState.STALE_BASIS
+ )
+ assert flagged[(SubjectKind.CRITERION, "SYN-C-DET-DRY")].state is CurrentnessState.CURRENT
+ assert flagged[(SubjectKind.REQUIREMENT, REQ)].state is CurrentnessState.STALE_BASIS
+ _, waiting = service.read("p")
+ assert (
+ waiting is not None and waiting.history == history_before
+ ) # nothing recomputed on its own
+
+ digest = service.recompute("p", digest, "human:local-user")
+ _, after = service.read("p")
+ assert after is not None
+ current = service.current_verdicts("p", after)
+ dry_two = current[(SubjectKind.CRITERION, "SYN-C-DET-DRY")]
+ requirement_two = current[(SubjectKind.REQUIREMENT, REQ)]
+ assert dry_two.revision_digest == dry_one.revision_digest
+ assert len(after.confirmations_of(dry_two.revision_digest)) == 1
+ assert requirement_two.revision_digest != requirement_one.revision_digest
+ assert requirement_two.state is RequirementVerdictState.FAIL_WITH_INCOMPLETE_COVERAGE
+ assert after.confirmations_of(requirement_two.revision_digest) == ()
+ assert len(after.confirmations_of(requirement_one.revision_digest)) == 1
+ old = after.history[subject_key(SubjectKind.REQUIREMENT, REQ)]
+ assert requirement_one.revision_digest in old # the old revision is still there
+ assert service.revision("p", requirement_one.revision_digest) == requirement_one
+ assert old[-1] == requirement_two.revision_digest and len(old) == 2
+ assert after.pending_changes == () and all(
+ item.state is CurrentnessState.CURRENT for item in service.currentness("p").values()
+ )
+ old_target = requirement_one.revision_digest
+ with pytest.raises(TraceError, match="TRACE_CONFIRM_TARGET_NOT_CURRENT"):
+ service.confirm("p", old_target, "human:local-user", "late", digest)
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_writes_need_the_digest_that_was_read_and_ids_survive_storage(tmp_path: Path) -> None:
+ runtime, service = await opened(tmp_path)
+ try:
+ trace = mini([result(value="0.30", numerator=3, denominator=10)])
+ renamed = TraceSet(
+ items=tuple(
+ item.model_copy(update={"item_id": "007"}) if item.item_id == "C-1" else item
+ for item in trace.items
+ ),
+ links=tuple(link.model_copy(update={"from_id": "007"}) for link in trace.links),
+ rules=tuple(rule.model_copy(update={"criterion_id": "007"}) for rule in trace.rules),
+ results=tuple(
+ item.model_copy(update={"criterion_id": "007"}) for item in trace.results
+ ),
+ )
+ digest = service.replace_trace_set("p", renamed, None, "human:local-user")
+ with pytest.raises(TraceConflict):
+ service.replace_trace_set("p", renamed, None, "human:local-user")
+ with pytest.raises(TraceConflict):
+ service.recompute("p", "0" * 64, "human:local-user")
+ with pytest.raises(TraceError, match="TRACE_NOT_FOUND"):
+ service.recompute("q", None, "human:local-user")
+ digest = service.recompute("p", digest, "human:local-user")
+ _, stored = service.read("p")
+ assert stored is not None
+ assert stored.trace_set.set_digest == renamed.set_digest
+ assert {item.item_id for item in stored.trace_set.items} >= {"007"}
+ assert stored.trace_set.rules[0].threshold == Decimal("0.50")
+ assert str(stored.trace_set.results[0].value) == "0.30"
+ assert (
+ service.current_verdicts("p", stored)[(SubjectKind.CRITERION, "007")].state
+ is CriterionVerdictState.PASS_COMPUTED
+ )
+ assert all(
+ service.revision("p", digest).verdict_digest
+ for digests in stored.history.values()
+ for digest in digests
+ )
+ finally:
+ runtime.close()
+
+
+# --- the set itself ---
+
+
+def test_the_set_rejects_broken_graphs_and_non_finite_numbers() -> None:
+ good = mini([result()])
+ with pytest.raises(ValueError, match="TRACE_LINK_ENDPOINT_INVALID"):
+ TraceSet(
+ items=good.items,
+ links=(
+ TraceLink(
+ link_id="x", from_id="REQ-1", to_id="C-1", relation=TraceRelation.REFINES
+ ),
+ ),
+ )
+ with pytest.raises(ValueError, match="TRACE_ITEM_ID_DUPLICATE"):
+ TraceSet(items=(*good.items, good.items[0].model_copy(update={"item_key": "other"})))
+ with pytest.raises(ValueError, match="TRACE_RESULT_ITEM_MISSING"):
+ TraceSet(items=good.items[:2], results=good.results)
+ with pytest.raises(ValueError):
+ CriterionRule(
+ rule_id="r",
+ criterion_id="C-1",
+ measure="m",
+ comparator=Comparator.AT_LEAST,
+ threshold=Decimal("NaN"),
+ unit="u",
+ condition="c",
+ )
+ with pytest.raises(ValueError):
+ TraceItem(item_id=" 007", item_key="k", kind=TraceKind.REQUIREMENT)
+ shuffled = TraceSet(
+ items=tuple(reversed(good.items)), links=good.links, rules=good.rules, results=good.results
+ )
+ assert shuffled.set_digest == good.set_digest
+
+
+def test_a_tampered_verdict_does_not_validate() -> None:
+ verdict = run(demo_set(1))[0]
+ payload = verdict.model_dump(mode="python")
+ payload["state"] = (
+ CriterionVerdictState.PASS_COMPUTED
+ if verdict.state is not CriterionVerdictState.PASS_COMPUTED
+ else CriterionVerdictState.FAIL_COMPUTED
+ )
+ with pytest.raises(ValueError, match="TRACE_VERDICT_DIGEST_MISMATCH"):
+ VerdictRevision.model_validate(payload)
+
+
+@pytest.mark.asyncio
+async def test_each_revision_is_its_own_record_and_the_trace_record_only_lists_digests(
+ tmp_path: Path,
+) -> None:
+ runtime, service = await opened(tmp_path)
+ try:
+ digest = service.replace_trace_set("p", demo_set(1), None, "human:local-user")
+ service.recompute("p", digest, "human:local-user")
+ _, stored = service.read("p")
+ assert stored is not None
+ digests = {d for ds in stored.history.values() for d in ds}
+ assert len(digests) == 7 # six criteria and the requirement
+ for one in digests:
+ assert service.revision("p", one).revision_digest == one
+ heads = runtime.ledger.read_heads("p")
+ assert {f"THREAD:trace-verdict:{d}" for d in digests} <= set(heads)
+ content = json.dumps(stored.model_dump(mode="json"))
+ assert "THRESHOLD_NOT_MET" not in content and "NO_RESULT" not in content # no verdict text
+ page = service.history("p", stored, SubjectKind.CRITERION, "SYN-C-DET-RAIN", limit=5)
+ assert page.total == 1 and len(page.revisions) == 1 and page.next_before is None
+ with pytest.raises(TraceError, match="TRACE_HISTORY_CURSOR_UNKNOWN"):
+ service.history("p", stored, SubjectKind.CRITERION, "SYN-C-DET-RAIN", before="0" * 64)
+ finally:
+ runtime.close()
+
+
+@pytest.mark.asyncio
+async def test_a_failure_while_saving_the_revisions_leaves_the_trace_untouched(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ runtime = await setup(tmp_path, ControlledResearchModel(), source=False)
+ records = RequestRecords(
+ runtime.ledger,
+ SqliteControlRecordStore(runtime.ledger.engine),
+ SystemClock(),
+ UuidIdGenerator(),
+ )
+ service = VerificationTraceService(records)
+ try:
+ digest = service.replace_trace_set("p", demo_set(1), None, "human:local-user")
+ before = runtime.ledger.read_heads("p")
+ stage = records.stage
+ calls = {"count": 0}
+
+ def failing(*args: Any, **kwargs: Any) -> Any:
+ calls["count"] += 1
+ if calls["count"] == 3:
+ raise RuntimeError("the third record could not be prepared")
+ return stage(*args, **kwargs)
+
+ monkeypatch.setattr(records, "stage", failing)
+ with pytest.raises(RuntimeError):
+ service.recompute("p", digest, "human:local-user")
+ monkeypatch.undo()
+ assert runtime.ledger.read_heads("p") == before
+ _, stored = service.read("p")
+ assert stored is not None and stored.history == {}
+ finally:
+ runtime.close()
diff --git a/tests/unit/test_cutoff_precision_sites.py b/tests/unit/test_cutoff_precision_sites.py
new file mode 100644
index 0000000..0c94cd7
--- /dev/null
+++ b/tests/unit/test_cutoff_precision_sites.py
@@ -0,0 +1,148 @@
+"""The places that compare a stored cutoff with the project's cutoff take the stored precision.
+
+A project can hold a cutoff typed with sub-millisecond digits; everything stored from it keeps
+milliseconds. Each test below fails on a plain datetime comparison and passes when the two are
+compared at the stored precision, and each still refuses a cutoff that moved by a millisecond."""
+
+from __future__ import annotations
+
+import re
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any, cast
+
+import pytest
+
+from thoth.application.commands.thread_analysis import ThreadAnalysisCommandHandlers
+from thoth.application.services.hypothesis_test_lifecycle import HypothesisTestLifecycle
+from thoth.application.services.memory_admission import MemoryAdmissionService
+from thoth.domain.enums import RiskTier
+from thoth.domain.governance import ProjectPolicy
+from thoth.domain.project import Project
+
+TYPED = datetime(2026, 8, 31, 0, 0, 0, 456, tzinfo=UTC) # the project, typed to the microsecond
+STORED = datetime(2026, 8, 31, 0, 0, 0, 0, tzinfo=UTC) # what a stored record keeps
+MOVED = STORED + timedelta(milliseconds=1)
+
+
+def project(cutoff: datetime) -> Project:
+ return Project(
+ project_id="p",
+ name="demo",
+ cutoff_at=cutoff,
+ overlay="default",
+ policy_binding_ref="policy:x",
+ )
+
+
+class Projects:
+ def __init__(self, cutoff: datetime) -> None:
+ self.cutoff = cutoff
+
+ def read(self, project_id: str) -> Project:
+ return project(self.cutoff)
+
+
+class Governance:
+ def read_policy(self, project_id: str) -> ProjectPolicy:
+ return ProjectPolicy(
+ policy_id="policy:x",
+ project_id=project_id,
+ version=1,
+ payload={},
+ policy_digest="a" * 64,
+ created_at=STORED,
+ )
+
+
+class Clock:
+ def now(self) -> datetime:
+ return datetime(2030, 1, 1, tzinfo=UTC)
+
+
+def memory_service() -> MemoryAdmissionService:
+ return MemoryAdmissionService(
+ projects=cast(Any, Projects(TYPED)),
+ governance=cast(Any, Governance()),
+ sessions=cast(Any, object()),
+ clock=cast(Any, Clock()),
+ )
+
+
+def capture(cutoff: datetime) -> None:
+ memory_service().capture(project_id="p", cutoff_at=cutoff, scope={}, actor=None)
+
+
+def test_memory_admission_takes_the_stored_cutoff_and_still_refuses_a_moved_one() -> None:
+ # Past the cutoff check, the next refusal is the empty policy digest.
+ with pytest.raises(ValueError, match="MEMORY_POLICY_DIGEST_MISMATCH"):
+ capture(STORED)
+ with pytest.raises(ValueError, match="MEMORY_PROJECT_POLICY_UNAVAILABLE"):
+ capture(MOVED)
+
+
+def prediction(knowledge_cutoff: datetime) -> None:
+ lifecycle = object.__new__(HypothesisTestLifecycle)
+ lifecycle.__dict__.update(_projects=Projects(TYPED), _clock=Clock())
+ current = cast(Any, SimpleNamespace(project_id="p", primary_intent=None))
+ lifecycle.prepare_prediction(
+ current,
+ knowledge_cutoff=knowledge_cutoff,
+ measurement_contract_ref="contract",
+ conditions={},
+ expected_outcome={},
+ )
+
+
+def test_a_prediction_cutoff_equal_to_the_project_cutoff_at_stored_precision_is_accepted() -> None:
+ with pytest.raises(ValueError, match="PREDICTION_INTENT_REQUIRED"): # the cutoff check passed
+ prediction(STORED)
+ with pytest.raises(ValueError, match="PREDICTION_CUTOFF_BASIS_UNRESOLVED"):
+ prediction(STORED - timedelta(milliseconds=2))
+
+
+async def r2_hold_reason(stored_project_cutoff: datetime) -> str | None:
+ handlers = object.__new__(ThreadAnalysisCommandHandlers)
+ handlers.__dict__.update(_projects=Projects(TYPED))
+ plan = cast(
+ Any,
+ SimpleNamespace(
+ alternatives=(SimpleNamespace(risk_tier=RiskTier.R2, action_id="a"),), frontier=("a",)
+ ),
+ )
+ try:
+ held = await handlers._execute_r2( # pyright: ignore[reportPrivateUsage]
+ project=project(stored_project_cutoff),
+ thread=cast(Any, None),
+ portfolio=cast(Any, None),
+ action_plan=plan,
+ )
+ except AttributeError:
+ return None # went on past the project check into work this test does not set up
+ return None if held is None else str(held.projection.get("reason_code"))
+
+
+@pytest.mark.asyncio
+async def test_the_closed_loop_run_ignores_a_cutoff_difference_below_a_millisecond() -> None:
+ assert await r2_hold_reason(STORED) != "R2_PROJECT_CONTEXT_CHANGED"
+ assert await r2_hold_reason(MOVED) == "R2_PROJECT_CONTEXT_CHANGED"
+
+
+CUTOFF_COMPARISON = re.compile(
+ r"(\.cutoff_at|knowledge_cutoff|captured_cutoff_at|\bcutoff)\s*(!=|==)"
+ r"|(!=|==)\s*[\w.]*(\.cutoff_at|knowledge_cutoff|captured_cutoff_at|\bcutoff_at)\b"
+)
+
+
+def test_no_cutoff_is_compared_as_a_plain_datetime_anywhere_in_the_program() -> None:
+ """A new comparison of two cutoffs must go through same_stored_instant.
+
+ Text forms (isoformat strings) and words such as "ELIGIBLE" are not datetimes and stay allowed.
+ """
+ offenders: list[str] = []
+ for path in sorted((Path(__file__).parents[2] / "src" / "thoth").rglob("*.py")):
+ for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
+ if CUTOFF_COMPARISON.search(line) and "isoformat()" not in line and '"' not in line:
+ offenders.append(f"{path.name}:{number}: {line.strip()}")
+ assert offenders == []
diff --git a/tests/unit/test_docling_locations_characterization.py b/tests/unit/test_docling_locations_characterization.py
new file mode 100644
index 0000000..6d82f4f
--- /dev/null
+++ b/tests/unit/test_docling_locations_characterization.py
@@ -0,0 +1,184 @@
+"""Pins what the Docling location helpers return today, so that moving them changes nothing.
+
+These helpers turn the character spans and boxes of a Docling export into THOTH locators. The
+expected values below were taken from the code before it was split into its own module.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+from tests.unit.test_parser_capability_selection import artifact_for
+
+from thoth.adapters.parsers.docling_locations import (
+ _charspan,
+ _located_segments,
+ _segment_node_id,
+ _source_locator,
+ _text_coordinate_basis,
+ _validated_charspan,
+)
+
+MISSING = "Docling multi-provenance item omitted a charspan"
+INVALID = "Docling multi-provenance item reported an invalid charspan"
+OVERLAP = "Docling multi-provenance item reported overlapping charspans"
+UNLOCATED = "Docling multi-provenance item has unlocated non-whitespace text"
+TRAILING = "Docling multi-provenance item has trailing unlocated text"
+MISMATCH = "Docling text and orig differ; charspan coordinate basis is unverified"
+OUTSIDE = "Docling provenance charspan is outside the item text"
+
+
+def test_segment_node_ids_are_stable_and_depend_on_every_part_of_the_segment() -> None:
+ artifact = artifact_for(b"docling-locations-characterization")
+ first = _segment_node_id(artifact, "#/texts/0", 0, 0, 5)
+ assert first == "node:6c1b7427dcc6966c8061c314"
+ assert _segment_node_id(artifact, "#/texts/0", 1, 6, 11) == "node:adbdfb63da3b403fe1eccf15"
+ assert _segment_node_id(artifact, "#/texts/0", 0, 0, 5) == first
+ other = artifact.model_copy(update={"artifact_id": "another-artifact"})
+ variants = {
+ _segment_node_id(other, "#/texts/0", 0, 0, 5),
+ _segment_node_id(artifact, "#/texts/1", 0, 0, 5),
+ _segment_node_id(artifact, "#/texts/0", 1, 0, 5),
+ _segment_node_id(artifact, "#/texts/0", 0, 1, 5),
+ _segment_node_id(artifact, "#/texts/0", 0, 0, 6),
+ }
+ assert first not in variants and len(variants) == 5
+ assert first.startswith("node:") and len(first) == len("node:") + 24
+
+
+@pytest.mark.parametrize(
+ ("provenance", "expected"),
+ [
+ ({"charspan": [2, 9]}, (2, 9)),
+ ({"charspan": (2, 9)}, (2, 9)),
+ ({"charspan": {"start": 2, "end": 9}}, (2, 9)),
+ ({"charspan": {"char_start": 2, "char_end": 9}}, (2, 9)),
+ ({"char_span": [2, 9]}, (2, 9)),
+ ({"char_start": 2, "char_end": 9}, (2, 9)),
+ ({"charspan": [2, 9], "char_span": [4, 5]}, (2, 9)),
+ ({"charspan": [2, 9, 11]}, None),
+ ({"charspan": [2]}, None),
+ ({"charspan": ["2", "9"]}, None),
+ ({"charspan": [2, 9.0]}, None),
+ ({"charspan": {"start": 2}}, None),
+ ({"charspan": "2-9"}, None),
+ ({"char_start": 2}, None),
+ ({}, None),
+ ],
+)
+def test_charspan_reads_each_form_and_refuses_anything_else(
+ provenance: dict[str, Any], expected: tuple[int, int] | None
+) -> None:
+ assert _charspan(provenance) == expected
+
+
+def _located(text: str, *spans: tuple[int, int]) -> Any:
+ return _located_segments(
+ text, [{"page_no": n + 1, "charspan": list(s)} for n, s in enumerate(spans)]
+ )
+
+
+def test_located_segments_are_sorted_with_the_end_exclusive_and_keep_their_provenance() -> None:
+ text = "alpha beta"
+ segments, problem = _located(text, (6, 10), (0, 5))
+ assert problem is None and segments is not None
+ assert [(start, end) for start, end, _ in segments] == [(0, 5), (6, 10)]
+ # each span keeps its own provenance: the span given first is page 1 and sorts second
+ assert [observed["page_no"] for _, _, observed in segments] == [2, 1]
+ assert text[segments[0][0] : segments[0][1]] == "alpha"
+ touching, problem = _located("alphabeta", (0, 5), (5, 9))
+ assert problem is None and touching is not None and len(touching) == 2
+
+
+@pytest.mark.parametrize(
+ ("text", "spans", "message"),
+ [
+ ("alpha beta", [(0, 5), (4, 10)], OVERLAP),
+ ("alpha beta", [(-1, 5), (6, 10)], INVALID),
+ ("alpha beta", [(5, 5), (6, 10)], INVALID),
+ ("alpha beta", [(0, 5), (6, 11)], INVALID),
+ ("alpha XX beta", [(0, 5), (9, 13)], UNLOCATED),
+ ("alpha beta XX", [(0, 5), (6, 10)], TRAILING),
+ ("XX alpha", [(3, 8)], UNLOCATED),
+ ],
+)
+def test_located_segments_refuse_gaps_overlaps_and_ranges_outside_the_text(
+ text: str, spans: list[tuple[int, int]], message: str
+) -> None:
+ segments, problem = _located(text, *spans)
+ assert segments is None and problem == message
+
+
+def test_located_segments_name_a_missing_charspan_and_allow_whitespace_gaps() -> None:
+ assert _located_segments("alpha beta", [{"page_no": 1}]) == (None, MISSING)
+ segments, problem = _located("alpha beta ", (0, 5), (8, 12))
+ assert problem is None and segments is not None and len(segments) == 2
+
+
+def test_the_coordinate_basis_is_the_orig_text_unless_it_differs_from_text() -> None:
+ assert _text_coordinate_basis({"text": "same", "orig": "same"}) == ("same", None)
+ assert _text_coordinate_basis({"text": "only text"}) == ("only text", None)
+ assert _text_coordinate_basis({"orig": "only orig"}) == ("only orig", None)
+ assert _text_coordinate_basis({"text": "a", "orig": "b"}) == ("", MISMATCH)
+ assert _text_coordinate_basis({}) == ("", None)
+ assert _text_coordinate_basis({"text": 3, "orig": None}) == ("", None)
+
+
+def test_a_validated_charspan_is_inside_the_text_with_the_end_exclusive() -> None:
+ item = {"text": "exact text", "orig": "exact text"}
+ assert _validated_charspan(item, {"charspan": [0, 10]}) == ((0, 10), None)
+ assert _validated_charspan(item, {"charspan": [6, 10]}) == ((6, 10), None)
+ assert _validated_charspan(item, {"charspan": [0, 11]}) == (None, OUTSIDE)
+ assert _validated_charspan(item, {"charspan": [-1, 4]}) == (None, OUTSIDE)
+ assert _validated_charspan(item, {"charspan": [4, 4]}) == (None, OUTSIDE)
+ assert _validated_charspan(item, {"charspan": [5, 4]}) == (None, OUTSIDE)
+
+
+def test_a_missing_span_is_not_reported_and_a_text_mismatch_is_reported_only_with_a_span() -> None:
+ assert _validated_charspan({"text": "a"}, {}) == (None, None)
+ assert _validated_charspan({"text": "a", "orig": "b"}, {}) == (None, None)
+ assert _validated_charspan({"text": "a", "orig": "b"}, {"charspan": [0, 1]}) == (None, MISMATCH)
+
+
+EXPORT = {"pages": {"1": {"size": {"height": 100}}, "2": {"size": {}}}}
+
+
+def _locator(prov: list[dict[str, Any]], **kwargs: Any) -> Any:
+ return _source_locator(EXPORT, {"prov": prov}, "#/texts/0", **kwargs)
+
+
+def test_a_locator_without_exactly_one_provenance_keeps_only_the_pointer() -> None:
+ for prov in ([], [{"page_no": 1}, {"page_no": 2}]):
+ locator = _locator(prov)
+ assert locator.json_pointer == "#/texts/0"
+ assert locator.page is None and locator.bbox is None and locator.char_start is None
+
+
+def test_a_top_left_box_is_kept_as_left_top_right_bottom_with_the_page_number() -> None:
+ box = {"l": 10, "t": 20, "r": 110, "b": 40, "coord_origin": "TOPLEFT"}
+ locator = _locator([{"page_no": 1, "bbox": box}])
+ assert locator.page == 1 and locator.bbox == (10, 20, 110, 40)
+ assert locator.char_start is None and locator.char_end is None
+
+
+def test_a_bottom_left_box_is_flipped_with_the_page_height_and_dropped_without_one() -> None:
+ box = {"l": 10, "t": 80, "r": 110, "b": 60, "coord_origin": "BOTTOMLEFT"}
+ assert _locator([{"page_no": 1, "bbox": box}]).bbox == (10, 20, 110, 40)
+ without_height = _locator([{"page_no": 2, "bbox": box}])
+ assert without_height.page == 2 and without_height.bbox is None
+ assert _locator([{"page_no": 1}]).bbox is None
+
+
+def test_one_given_provenance_and_a_char_range_become_the_locator() -> None:
+ box = {"l": 1, "t": 2, "r": 3, "b": 4, "coord_origin": "TOPLEFT"}
+ given = {"page_no": 2, "bbox": box}
+ locator = _source_locator(
+ EXPORT,
+ {"prov": [{"page_no": 1}, {"page_no": 5}]},
+ "#/texts/9",
+ provenance=given,
+ char_range=(3, 8),
+ )
+ assert (locator.page, locator.bbox) == (2, (1, 2, 3, 4))
+ assert (locator.char_start, locator.char_end, locator.json_pointer) == (3, 8, "#/texts/9")
diff --git a/tests/unit/test_hypothesis_eval_cases.py b/tests/unit/test_hypothesis_eval_cases.py
new file mode 100644
index 0000000..b1177bc
--- /dev/null
+++ b/tests/unit/test_hypothesis_eval_cases.py
@@ -0,0 +1,83 @@
+"""The synthetic reason-tag cases: the stored verdicts are what the server rule computes.
+
+No model is called. The web tests read the same `cases.json` for tags and next-check wording.
+"""
+
+from __future__ import annotations
+
+import json
+from typing import Any
+
+import pytest
+from tests.fixtures.hypothesis_eval.case_builder import FILE, NOTICE, view_of_case
+
+DOCUMENT = json.loads(FILE.read_text(encoding="utf-8"))
+CASES: list[dict[str, Any]] = DOCUMENT["cases"]
+REQUIRED = {
+ "fog-no-result",
+ "rain-valid-miss",
+ "rain-miss-and-fog-untested",
+ "dry-pass",
+ "rain-result-edited",
+ "unit-mismatch",
+ "denominator-zero",
+ "value-disagrees-with-counts",
+ "condition-mismatch",
+ "no-rule",
+ "two-conditions-miss",
+}
+
+
+def _verdict(view: dict[str, Any], kind: str, subject: str) -> dict[str, Any]:
+ found = [
+ v for v in view["verdicts"] if v["subject_kind"] == kind and v["subject_id"] == subject
+ ]
+ assert len(found) == 1, (kind, subject)
+ return found[0]
+
+
+def test_the_file_is_synthetic_and_covers_the_agreed_situations() -> None:
+ assert (
+ DOCUMENT["schema"] == "hypothesis-eval-cases/1" and DOCUMENT["synthetic_notice"] == NOTICE
+ )
+ ids = [case["id"] for case in CASES]
+ assert len(ids) == len(set(ids)) and set(ids) >= REQUIRED and len(ids) >= 10
+ for case in CASES:
+ assert case["synthetic_notice"] == NOTICE and case["banned_phrases"]
+ for item in case["view"]["items"]:
+ assert item["fields"]["synthetic_notice"] == NOTICE
+
+
+@pytest.mark.parametrize("case", CASES, ids=[case["id"] for case in CASES])
+def test_the_stored_view_is_what_the_rule_computes(case: dict[str, Any]) -> None:
+ computed = json.loads(json.dumps(view_of_case(case)))
+ assert computed == case["view"]
+
+
+@pytest.mark.parametrize("case", CASES, ids=[case["id"] for case in CASES])
+def test_states_and_reason_codes_match_the_answer_key(case: dict[str, Any]) -> None:
+ view = view_of_case(case)
+ for check in case["checks"]:
+ verdict = _verdict(view, check["kind"], check["subject_id"])
+ assert verdict["state"] == check["state"], check
+ heads = {code.split(":")[0] for code in verdict["reasons"]["computed"]}
+ assert heads == set(check["reason_codes"]), check
+ assert (verdict["currentness"]["state"] == "STALE_BASIS") == check["stale"], check
+
+
+def test_no_pass_stands_without_a_chosen_result_and_every_chosen_result_has_its_source() -> None:
+ passes = grounded = chosen_total = 0
+ for case in CASES:
+ for verdict in case["view"]["verdicts"]:
+ if verdict["subject_kind"] != "CRITERION":
+ continue
+ chosen = verdict["selection"]["chosen"] if verdict["selection"] else []
+ sources = verdict["basis"]["source_span_refs"]
+ if chosen:
+ chosen_total += 1
+ grounded += bool(sources)
+ if verdict["state"] == "PASS_COMPUTED":
+ passes += 1
+ assert chosen and sources, (case["id"], verdict["subject_id"])
+ assert passes >= 3 and chosen_total >= 8
+ assert grounded == chosen_total
diff --git a/tests/unit/test_hypothesis_v3_metrics.py b/tests/unit/test_hypothesis_v3_metrics.py
new file mode 100644
index 0000000..da581ac
--- /dev/null
+++ b/tests/unit/test_hypothesis_v3_metrics.py
@@ -0,0 +1,50 @@
+"""The v3 metric script counts tables, unknown rows, refutation conditions and basis links."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from scripts.hypothesis_v3_metrics import find_portfolio, main, measure
+
+
+def _row(hypothesis_id: str, expected: str, basis: list[str] | None = None) -> dict[str, object]:
+ return {"hypothesis_id": hypothesis_id, "expected": expected, "basis": basis or []}
+
+
+def _portfolio() -> dict[str, object]:
+ split = {
+ "test_id": "t1",
+ "expected_by_hypothesis": [_row("h1", "비 옴", ["s1"]), _row("h2", "안 옴", ["s2"])],
+ }
+ partial = {"test_id": "t2", "expected_by_hypothesis": [_row("h1", " 모름 ")]}
+ return {
+ "hypotheses": [
+ {
+ "hypothesis_id": "h1",
+ "discriminating_tests": [split, partial],
+ "refutation_conditions": ["a", "b"],
+ },
+ {"hypothesis_id": "h2", "discriminating_tests": [{"test_id": "t3"}]},
+ ]
+ }
+
+
+def test_counts_come_from_the_tables_and_a_v2_result_counts_zero_rows() -> None:
+ got = measure(_portfolio())
+ assert got["tests"] == 3 and got["tests_with_table"] == 2
+ assert got["countable_tests"] == 1 and got["splitting_tests"] == 1
+ assert got["table_rows"] == 3 and got["unknown_ratio"] == 0.333
+ assert got["refutation_conditions"] == 2 and got["rows_with_basis_ratio"] == 0.667
+ only = {"hypothesis_id": "h1", "discriminating_tests": [{"test_id": "t"}]}
+ v2 = measure({"hypotheses": [only]})
+ assert v2["table_rows"] == 0 and v2["unknown_ratio"] is None and v2["tests_with_table"] == 0
+
+
+def test_the_portfolio_is_found_in_a_thread_read_answer(tmp_path: Path) -> None:
+ wrapped = {"current_result": {"result": {"portfolio": _portfolio()}}}
+ assert find_portfolio(wrapped) is not None and find_portfolio({"x": 1}) is None
+ path = tmp_path / "r.json"
+ path.write_text(json.dumps(wrapped), encoding="utf-8")
+ assert main([str(path)]) == 0
+ assert main([str(tmp_path / "missing.json")]) == 1
diff --git a/tests/unit/test_lesson_recall_cases.py b/tests/unit/test_lesson_recall_cases.py
new file mode 100644
index 0000000..0ccdc12
--- /dev/null
+++ b/tests/unit/test_lesson_recall_cases.py
@@ -0,0 +1,90 @@
+"""Lesson recall cases: same or other condition, stale, refuted, and what is never a lesson.
+
+No model is called. The expected states are what the rule gives for synthetic lessons, in
+tests/fixtures/hypothesis_eval/lesson_cases.json (a separate file from the reason-tag cases).
+"""
+
+from __future__ import annotations
+
+import json
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import Any
+
+import pytest
+from pydantic import ValidationError
+
+from thoth.domain.lesson_ref import LessonContext, LessonRef, recall_lessons
+
+FILE = Path(__file__).resolve().parents[1] / "fixtures" / "hypothesis_eval" / "lesson_cases.json"
+DOCUMENT = json.loads(FILE.read_text(encoding="utf-8"))
+SOURCES = {
+ "TEST_RESULT": "DISCRIMINATION_RESULT",
+ "ELIMINATION": "DISCRIMINATION_RESULT",
+ "EFFECT_CONFIRMED": "TRACE_CLOSURE",
+ "HUMAN_CLOSURE": "TRACE_CLOSURE",
+}
+REQUIRED = {
+ "lesson-same-condition",
+ "lesson-other-condition",
+ "lesson-stale-fingerprint",
+ "lesson-refuted",
+}
+
+
+def ref_of(item: dict[str, Any]) -> LessonRef:
+ return LessonRef(
+ **{
+ "sources": [
+ {
+ "record_kind": SOURCES.get(item["kind"], "DISCRIMINATION_RESULT"),
+ "record_id": "src-" + item["lesson_id"],
+ }
+ ],
+ "actor_id": "human:local-user",
+ "created_at": datetime(2026, 10, 6, tzinfo=UTC),
+ **item,
+ }
+ )
+
+
+def test_the_file_is_synthetic_and_covers_the_agreed_situations() -> None:
+ assert DOCUMENT["schema_version"] == "hypothesis-lesson-cases/1"
+ assert "SYNTHETIC" in DOCUMENT["notice"]
+ ids = [case["id"] for case in DOCUMENT["cases"]]
+ assert len(ids) == len(set(ids)) and set(ids) >= REQUIRED
+ assert all(len(case["about"]) > 20 for case in DOCUMENT["cases"])
+
+
+@pytest.mark.parametrize("case", DOCUMENT["cases"], ids=lambda case: case["id"])
+def test_recall_gives_the_expected_states(case: dict[str, Any]) -> None:
+ refs = [ref_of(item) for item in case["refs"]]
+ current = LessonContext(**case["current"])
+ found = recall_lessons(refs, current, case["fingerprints"])
+ assert {ref.lesson_id: state for ref, state in found} == case["expected"]["states"]
+ for lesson_id in case["expected"]["absent"]:
+ assert all(ref.lesson_id != lesson_id for ref, _ in found) # not shown; the record stays
+ assert recall_lessons(refs, current, case["fingerprints"]) == found # same input, same answer
+ assert len(refs) == len(case["refs"]) # recalling never removes a lesson
+
+
+@pytest.mark.parametrize("bad", DOCUMENT["invalid_lessons"], ids=lambda bad: bad["about"])
+def test_what_is_not_a_rule_or_a_person_s_record_can_never_be_a_lesson(bad: dict[str, Any]) -> None:
+ base = {
+ "lesson_id": "x",
+ "kind": "TEST_RESULT",
+ "outcome": "ALTERNATIVE",
+ "hypothesis_id": "h1",
+ "test_id": "t1",
+ "context": DOCUMENT["cases"][0]["current"],
+ "fingerprint": "f",
+ }
+ with pytest.raises(ValidationError):
+ ref_of({**base, **bad["patch"]}) if "sources" not in bad["patch"] else LessonRef(
+ **{
+ "actor_id": "human:a",
+ "created_at": datetime(2026, 10, 6, tzinfo=UTC),
+ **base,
+ **bad["patch"],
+ }
+ )
diff --git a/tests/unit/test_memory_eval_families.py b/tests/unit/test_memory_eval_families.py
new file mode 100644
index 0000000..6b9ab38
--- /dev/null
+++ b/tests/unit/test_memory_eval_families.py
@@ -0,0 +1,275 @@
+"""Recall by question family: the file format, the measures, the split and the sealed guard."""
+
+from __future__ import annotations
+
+import importlib.util
+import json
+import sqlite3
+import sys
+from contextlib import closing
+from pathlib import Path
+from typing import Any
+
+import pytest
+from tests.unit.test_memory_recall import correction, memory
+
+from thoth.application.services.memory_recall import RecallLimits
+from thoth.domain.enums import MemoryKind
+from thoth.domain.memory import FullMemoryRevision
+from thoth.domain.memory_expansion import QueryExpansion
+
+SCRIPTS = Path(__file__).parents[2] / "scripts"
+
+
+def load_script(name: str) -> Any:
+ """The script by file path, registered under its own name so the scripts share one copy."""
+ spec = importlib.util.spec_from_file_location(name, SCRIPTS / f"{name}.py")
+ assert spec is not None and spec.loader is not None
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[name] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+fam: Any = load_script("memory_eval_families")
+replay: Any = load_script("memory_selection_replay")
+EXAMPLE = Path(__file__).parents[1] / "fixtures" / "memory_eval" / "families.example.jsonl"
+QUESTION = "출처별 대조 분석(A1)을 지금 바로 해도 돼? 무엇이 더 필요해?"
+A1_TEXT = "연결된 RFP PDF 원문과 표를 읽기 전용으로 대조한다."
+WIDENING = QueryExpansion(
+ synonyms=("원문 검토", "문서 비교"),
+ related=("근거 확인",),
+ note_line="원문을 읽기 전용으로 비교",
+)
+
+
+def line(family_id: str, kind: str, needed: list[str], **more: object) -> str:
+ return json.dumps(
+ {
+ "family_id": family_id,
+ "type": kind,
+ "project": "project:t",
+ "phrasings": [{"id": "original", "text": "질문 하나"}],
+ "gold": {"needed": needed, **more},
+ },
+ ensure_ascii=False,
+ )
+
+
+def test_the_example_file_parses_and_has_three_phrasings_per_family() -> None:
+ parsed = fam.parse_families(EXAMPLE.read_text(encoding="utf-8"))
+ assert [f.type for f in parsed] == ["DIRECT", "ELLIPTIC_FOLLOWUP", "IRRELEVANT"]
+ assert all(len(f.phrasings) == 3 and f.split == "dev" for f in parsed)
+
+
+@pytest.mark.parametrize(
+ ("text", "needle"),
+ [
+ (line("A", "DIRECT", []), "needs at least one needed memory"),
+ (line("A", "IRRELEVANT", ["m"]), "needs no memory"),
+ (line("A", "DIRECT", ["m"], forbidden=["m"]), "both needed and forbidden"),
+ (line("A", "CORRECTION", ["m"]), "a correction applies"),
+ (line("A", "DIRECT", ["m"]) + "\n" + line("A", "DIRECT", ["m"]), "used twice"),
+ ("not json", "line 1"),
+ ],
+)
+def test_a_family_that_breaks_the_form_is_refused_with_its_line(text: str, needle: str) -> None:
+ with pytest.raises(fam.FamilyError, match=needle):
+ fam.parse_families(text)
+
+
+def test_comments_and_blank_lines_are_skipped() -> None:
+ parsed = fam.parse_families("# a note\n\n" + line("A", "DIRECT", ["m"]) + "\n")
+ assert [f.family_id for f in parsed] == ["A"]
+
+
+def test_the_split_comes_from_the_split_file_or_the_family_and_never_both_sets() -> None:
+ parsed = fam.parse_families("\n".join(line(name, "DIRECT", ["m"]) for name in ("A", "B", "C")))
+ by_file = fam.select_split(parsed, "sealed", {"dev": ["A"], "sealed": ["B", "C"]})
+ assert [f.family_id for f in by_file] == ["B", "C"]
+ with pytest.raises(fam.FamilyError, match="both splits"):
+ fam.select_split(parsed, "dev", {"dev": ["A"], "sealed": ["A"]})
+ with pytest.raises(fam.FamilyError, match="unknown families"):
+ fam.select_split(parsed, "dev", {"dev": ["Z"]})
+ assert fam.select_split(parsed, "dev", None) == [] # no split written on the families
+
+
+def test_the_same_question_in_other_spacing_or_width_has_the_same_cache_key() -> None:
+ assert fam.question_key("AI 규제는?") == fam.question_key("\uff21\uff29 규제는?")
+ assert fam.question_key("a") != fam.question_key("b")
+
+
+def result(family: Any, phrasing: str, included: list[FullMemoryRevision]) -> Any:
+ chosen = next(p for p in family.phrasings if p.id == phrasing)
+ return fam.score_question(family, chosen, included)
+
+
+def family_of(family_id: str, kind: str, needed: list[str], forbidden: list[str]) -> Any:
+ return fam.Family.model_validate(
+ {
+ "family_id": family_id,
+ "type": kind,
+ "project": "p",
+ "phrasings": [{"id": f"p{n}", "text": f"q{n}"} for n in (1, 2, 3)],
+ "gold": {
+ "needed": needed,
+ "forbidden": forbidden,
+ "correction_applied": kind == "CORRECTION",
+ },
+ }
+ )
+
+
+def test_the_measures_are_counted_exactly_and_by_type() -> None:
+ a, b, x = memory("a"), memory("b"), memory("x")
+ fix, old = memory("fix"), memory("old")
+ direct = family_of("F1", "DIRECT", ["memory:a", "memory:b"], [])
+ plain = family_of("F2", "IRRELEVANT", [], [])
+ corrected = family_of("F3", "CORRECTION", ["memory:fix"], ["memory:old"])
+ results = [
+ result(direct, "p1", [a, b]),
+ result(direct, "p2", [a, x]),
+ result(direct, "p3", []),
+ result(plain, "p1", [x]),
+ result(plain, "p2", []),
+ result(corrected, "p1", [fix, old]),
+ ]
+ total = fam.summarize(results)
+ assert total["questions"] == 6 and total["questions_that_need_memory"] == 4
+ assert total["needed_recall_micro"] == 0.5714 and total["needed_recall_macro"] == 0.625
+ assert total["all_needed_given_rate"] == 0.5 and total["family_complete_rate"] == 0.5
+ assert total["memories_given"] == 7 and total["unwanted_share"] == 0.4286
+ assert total["forbidden_injected"] == 1 and total["questions_with_unwanted"] == 3
+ assert total["false_positive_rate"] == 0.5 and total["questions_that_need_none"] == 2
+ by_type = fam.summarize_by_type(results)
+ assert set(by_type) == {"DIRECT", "CORRECTION", "IRRELEVANT"}
+ assert by_type["IRRELEVANT"]["false_positive_rate"] == 0.5
+ assert by_type["IRRELEVANT"]["needed_recall_micro"] is None
+ assert by_type["CORRECTION"]["forbidden_injected"] == 1
+ assert by_type["DIRECT"]["family_complete_rate"] == 0.0
+
+
+def a1_project() -> list[FullMemoryRevision]:
+ return [
+ memory("a1", kind=MemoryKind.ACTION, body=A1_TEXT, evidence=("span:1",)),
+ memory("h2", body="표본이 작으면 결론을 보류한다", evidence=("span:1",), minutes=1),
+ correction("fix", "도로 결과는 참고자료로만 쓴다", minutes=2),
+ ]
+
+
+def a1_family(split: str | None = None) -> Any:
+ return fam.Family.model_validate(
+ {
+ "family_id": "F-A1",
+ "type": "REPHRASED",
+ "project": "project:t",
+ "split": split,
+ "phrasings": [{"id": "original", "text": QUESTION}],
+ "gold": {"needed": ["memory:a1"]},
+ }
+ )
+
+
+def setup(cache: dict[str, QueryExpansion]) -> Any:
+ return fam.RunSetup(replay.family_player(RecallLimits()), cache)
+
+
+def test_the_three_conditions_run_and_a_missing_expansion_is_counted_not_assumed() -> None:
+ report = fam.evaluate([a1_family()], {"project:t": a1_project()}, setup({}))
+ blocks = report["conditions"]
+ assert blocks["off"]["overall"]["needed_recall_micro"] == 0.0
+ assert blocks["off"]["overall"]["memories_given"] == 0
+ # one question word ("대조") is met, an automatic memory needs two: not given without help
+ assert blocks["current"]["overall"]["needed_recall_micro"] == 0.0
+ assert blocks["expansion"]["overall"]["questions"] == 0
+ assert blocks["expansion"]["overall"]["questions_missing_expansion"] == 1
+ assert report["missing_expansions"] == {fam.question_key(QUESTION): QUESTION}
+
+
+def test_cached_added_words_let_the_memory_in_and_the_question_is_not_asked_again() -> None:
+ cache = {fam.question_key(QUESTION): WIDENING}
+ report = fam.evaluate([a1_family()], {"project:t": a1_project()}, setup(cache))
+ block = report["conditions"]["expansion"]["overall"]
+ assert block["needed_recall_micro"] == 1.0 and block["questions_missing_expansion"] == 0
+ assert report["missing_expansions"] == {}
+ row = report["questions"]["expansion"][0]
+ assert row["matched_by"] == {"기타(a1)": "EXPANSION"} and row["unwanted"] == []
+
+
+def test_a_family_naming_a_project_with_no_memories_loaded_is_refused() -> None:
+ with pytest.raises(fam.FamilyError, match="no memories loaded"):
+ fam.evaluate([a1_family()], {}, setup({}))
+
+
+def make_database(path: Path) -> None:
+ with closing(sqlite3.connect(path)) as connection:
+ connection.execute("CREATE TABLE memory_context_packs (project_id TEXT, content_json TEXT)")
+ connection.execute(
+ "CREATE TABLE memory_revision_ledger (project_id TEXT, content_json TEXT)"
+ )
+ for item in a1_project():
+ stored = item.model_copy(update={"project_id": "project:t"})
+ connection.execute(
+ "INSERT INTO memory_revision_ledger VALUES (?, ?)",
+ ("project:t", stored.model_dump_json()),
+ )
+ connection.commit()
+
+
+def write_families(path: Path, split: str) -> None:
+ path.write_text(a1_family(split).model_dump_json() + "\n", encoding="utf-8")
+
+
+def command(tmp_path: Path, families_file: Path, split: str, *more: str) -> list[str]:
+ return [
+ "--source",
+ str(tmp_path / "thoth.sqlite3"),
+ "--copy",
+ str(tmp_path / "copy.sqlite3"),
+ "--families",
+ str(families_file),
+ "--split",
+ split,
+ "--output",
+ str(tmp_path / f"{split}.json"),
+ *more,
+ ]
+
+
+def test_the_command_line_runs_with_no_model_call_and_opens_a_sealed_set_once(
+ tmp_path: Path,
+) -> None:
+ make_database(tmp_path / "thoth.sqlite3")
+ sealed = tmp_path / "families.jsonl"
+ write_families(sealed, "sealed")
+ base = command(tmp_path, sealed, "sealed", "--conditions", "off,current")
+ # not opened without the confirmation
+ assert replay.main(base) == 2 and not (tmp_path / "sealed.json").exists()
+ assert replay.main([*base, "--confirm-sealed"]) == 0
+ written = json.loads((tmp_path / "sealed.json").read_text(encoding="utf-8"))
+ assert written["meta"]["model_calls"] == 0 and written["meta"]["questions"] == 1
+ assert set(written["conditions"]) == {"off", "current"}
+ assert fam.sealed_marker(sealed).exists()
+ # a second opening is refused unless it is said to turn the set into a development set
+ assert replay.main([*base, "--confirm-sealed"]) == 2
+ assert replay.main([*base, "--confirm-sealed", "--reopen-sealed"]) == 0
+ # a development run needs no confirmation and leaves no marker
+ other = tmp_path / "dev.jsonl"
+ write_families(other, "dev")
+ assert replay.main(command(tmp_path, other, "dev")) == 0
+ assert not fam.sealed_marker(other).exists()
+
+
+def test_the_database_is_never_written_and_the_missing_questions_are_listed(
+ tmp_path: Path,
+) -> None:
+ make_database(tmp_path / "thoth.sqlite3")
+ before = (tmp_path / "thoth.sqlite3").read_bytes()
+ families_file = tmp_path / "families.jsonl"
+ write_families(families_file, "dev")
+ missing = tmp_path / "missing.json"
+ arguments = command(tmp_path, families_file, "dev", "--missing-out", str(missing), "--markdown")
+ assert replay.main(arguments) == 0
+ assert (tmp_path / "thoth.sqlite3").read_bytes() == before
+ assert json.loads(missing.read_text(encoding="utf-8")) == {fam.question_key(QUESTION): QUESTION}
+ assert "| expansion | 전체 |" in (tmp_path / "dev.json").read_text(encoding="utf-8")
diff --git a/tests/unit/test_memory_recall.py b/tests/unit/test_memory_recall.py
index 56788dd..798257d 100644
--- a/tests/unit/test_memory_recall.py
+++ b/tests/unit/test_memory_recall.py
@@ -25,6 +25,7 @@
MemoryRoleReview,
MemoryTransition,
)
+from thoth.domain.memory_terms import words
NOW = datetime(2026, 9, 30, tzinfo=UTC)
REVIEWS = tuple(
@@ -40,12 +41,13 @@ def memory(
*,
kind: MemoryKind = MemoryKind.HYPOTHESIS,
source: str | None = None,
- body: str = "짧은 기억 한 줄",
- terms: tuple[str, ...] = ("표본",),
+ body: str = "짧은 기억 한 줄 표본",
minutes: int = 0,
mode: MemoryPayloadMode = MemoryPayloadMode.DOMAIN_REFERENCE,
evidence: tuple[str, ...] = (),
) -> FullMemoryRevision:
+ """The words a memory is found by come from its text (the line after the reference line)."""
+ reference = source if source is not None else f"{kind.value}:{tag}"
return FullMemoryRevision(
memory_revision_id=f"rev:{tag}",
memory_id=f"memory:{tag}",
@@ -54,11 +56,11 @@ def memory(
payload_mode=mode,
kind=kind,
owner_revision_ref="a" * 64,
- source_ref=source if source is not None else f"{kind.value}:{tag}",
- content_excerpt=f"{kind.value}:{tag}\n{body}",
+ source_ref=reference,
+ content_excerpt=f"{reference}\n{body}",
scope={},
evidence_refs=evidence,
- query_terms=terms,
+ query_terms=(),
support_status="SUPPORTED",
authority_status="AUTHORITATIVE",
cutoff_at=NOW,
@@ -79,7 +81,7 @@ def ids(items: list[FullMemoryRevision]) -> list[str]:
def test_twenty_memories_fit_inside_every_limit_and_the_rest_are_counted() -> None:
kinds = (MemoryKind.HYPOTHESIS, MemoryKind.ACTION, MemoryKind.FACT, MemoryKind.LESSON)
items = [memory(f"m{n:02d}", kind=kinds[n % 4], source=f"REC:{n // 2}") for n in range(20)]
- plan = plan_recall(items, "표본", frozenset({"표본"}), RecallLimits())
+ plan = plan_recall(items, "표본", words("표본"), RecallLimits())
assert len(plan.included) <= 8 and len(plan.selected) <= 8
assert plan.tokens <= 1_600
per_kind: dict[str, int] = {}
@@ -94,7 +96,7 @@ def test_twenty_memories_fit_inside_every_limit_and_the_rest_are_counted() -> No
def test_only_twenty_four_are_retrieved_and_one_kind_cannot_take_everything() -> None:
items = [memory(f"h{n:02d}", source=f"HYP:{n}") for n in range(30)]
- plan = plan_recall(items, "표본", frozenset({"표본"}), RecallLimits())
+ plan = plan_recall(items, "표본", words("표본"), RecallLimits())
assert len(plan.retrieved) == 24 and plan.omitted_by_limit["candidates"] == 6
assert len(plan.selected) == 3 and plan.omitted_by_limit["per_kind"] == 21
@@ -107,108 +109,120 @@ def test_an_item_is_cut_to_its_share_and_the_total_stops_at_the_budget() -> None
(MemoryKind.HYPOTHESIS, MemoryKind.ACTION, MemoryKind.FACT, MemoryKind.LESSON)
)
]
- plan = plan_recall(items, "표본", frozenset({"표본"}), RecallLimits())
+ plan = plan_recall(items, "표본", words("표본"), RecallLimits())
assert all(estimate_tokens(item.content_excerpt) <= 320 for item in plan.included)
assert len(plan.truncated) == len(plan.included) == 4
assert plan.tokens <= 1_600
- tight = plan_recall(items, "표본", frozenset({"표본"}), RecallLimits(total_tokens=700))
+ tight = plan_recall(items, "표본", words("표본"), RecallLimits(total_tokens=700))
assert len(tight.included) == 2 and tight.omitted_by_limit == {"total_tokens": 2}
def test_the_order_is_id_then_phrase_then_word_hits_then_spread_then_newest_then_id() -> None:
- named = memory("named", source="HYPOTHESIS:hypothesis:o:special-one", terms=("가나다",))
- phrase = memory("phrase", body="표본이 작으면 결론을 보류한다", terms=("표본이",))
- many = memory("many", terms=("표본이", "결론을", "보류한다"))
- few = memory("few", terms=("표본이",))
+ named = memory("named", source="HYPOTHESIS:hypothesis:o:special-one", body="가나다")
+ phrase = memory("phrase", body="표본이 작으면 결론을 보류한다")
+ many = memory("many", body="표본이 결론을 보류한다")
+ few = memory("few", body="표본이")
query = (
"hypothesis:o:special-one 은 어떤가. 표본이 작으면 결론을 보류한다 "
"를 표본이 결론을 보류한다"
)
- terms = frozenset({"표본이", "결론을", "보류한다"})
- order = order_recall([few, many, phrase, named], query, terms)
+ order = order_recall([few, many, phrase, named], query, words(query))
assert ids(order) == ["named", "phrase", "many", "few"]
# equal on the first three: kinds alternate, then newest first, then id
a1 = memory("a1", kind=MemoryKind.ACTION, minutes=1)
a2 = memory("a2", kind=MemoryKind.ACTION, minutes=5)
h1 = memory("h1", kind=MemoryKind.HYPOTHESIS, minutes=2)
h2 = memory("h2", kind=MemoryKind.HYPOTHESIS, minutes=2)
- assert ids(order_recall([a1, h2, a2, h1], "표본", frozenset({"표본"}))) == [
- "a2",
- "h1",
- "a1",
- "h2",
- ]
+ assert ids(order_recall([a1, h2, a2, h1], "표본", words("표본"))) == ["a2", "h1", "a1", "h2"]
+
+
+def test_the_word_hits_are_counted_per_question_word_not_per_letter_pair() -> None:
+ # "자료를" has two pairs; a memory holding both of its pairs still meets one question word,
+ # so the memory that meets two different question words is ahead of it.
+ one_word = memory("one", body="자료를 자료가")
+ two_words = memory("two", body="자료 분석")
+ query = "자료를 분석을 보여 줘"
+ assert ids(order_recall([one_word, two_words], query, words(query))) == ["two", "one"]
-def correction(tag: str, terms: tuple[str, ...], minutes: int = 0) -> FullMemoryRevision:
+def correction(tag: str, text: str, minutes: int = 0) -> FullMemoryRevision:
"""A user's correction: free text under MEMORY_ASSERTION, never an automatic reference."""
return memory(
tag,
kind=MemoryKind.LESSON,
source=f"MEMORY:{tag}",
- terms=terms,
minutes=minutes,
mode=MemoryPayloadMode.MEMORY_ASSERTION,
- ).model_copy(update={"assertion": "표본이 작으면 다시 확인한다"})
+ ).model_copy(update={"assertion": text})
def pool(common: str = "프로젝트") -> list[FullMemoryRevision]:
- """Six automatic memories that all mention one project-wide word, each with its own words."""
- own = ("가설A", "가설B", "행동C", "행동D", "결과E", "결과F")
+ """Six automatic memories that all mention one project-wide word, each with its own word."""
+ own = ("사과", "포도", "수박", "참외", "딸기", "복숭아")
return [
- memory(f"p{n}", terms=(common, own[n]), source=f"HYP:p{n}", evidence=("span:1",))
+ memory(f"p{n}", body=f"{own[n]} {common}", source=f"HYP:p{n}", evidence=("span:1",))
for n in range(6)
]
def test_a_word_every_memory_shares_is_a_common_word_and_nothing_else_is() -> None:
items = pool()
- assert common_terms(items) == frozenset({"프로젝트"})
+ query = words("프로젝트 사과 이야기")
+ assert common_terms(items, query) == frozenset({"프로젝트"})
# a pool too small to tell what is common says no word is
- assert common_terms(items[:3]) == frozenset()
+ assert common_terms(items[:3], query) == frozenset()
def test_an_automatic_memory_that_meets_the_question_on_a_common_word_only_is_left_out() -> None:
items = pool()
- terms = frozenset({"프로젝트", "무관한"})
- narrowed = narrow_recall(items, "프로젝트 무관한 질문", terms)
+ query = "프로젝트 무관한 질문"
+ narrowed = narrow_recall(items, query, words(query))
assert narrowed.relevant == []
assert narrowed.excluded[AUTO_MEMORY_WEAK_MATCH] == [i.memory_revision_id for i in items]
assert narrowed.common_terms == frozenset({"프로젝트"})
# one word of its own is still only one word
- one = narrow_recall(items, "가설A 이야기", frozenset({"가설a", "가설A"}))
+ one = narrow_recall(items, "사과 이야기", words("사과 이야기"))
assert [i.memory_id for i in one.relevant] == []
def test_two_words_that_are_not_common_let_an_automatic_memory_in() -> None:
items = pool()
- terms = frozenset({"가설A", "가설B", "프로젝트"})
- narrowed = narrow_recall(items, "프로젝트 가설A 가설B", terms)
- # p0 meets 가설A only, p1 meets 가설B only: no memory has two of its own words
+ query = "프로젝트 사과 포도"
+ narrowed = narrow_recall(items, query, words(query))
+ # p0 meets 사과 only, p1 meets 포도 only: no memory has two of its own words
assert narrowed.relevant == []
- both = memory("both", terms=("프로젝트", "가설A", "가설B"), evidence=("span:1",))
- narrowed = narrow_recall([*items, both], "프로젝트 가설A 가설B", terms)
+ both = memory("both", body="프로젝트 사과 포도", evidence=("span:1",))
+ narrowed = narrow_recall([*items, both], query, words(query))
assert ids(narrowed.relevant) == ["both"]
+def test_a_word_met_through_its_letter_pairs_counts_once_not_twice() -> None:
+ # "필요해" and "필요하다" meet through the pair "필요"; that is one question word, so a memory
+ # holding only that word is one word short of the two an automatic memory needs.
+ items = [memory(f"n{n}", body=f"공통 {n}번째 필요하다", evidence=("span:1",)) for n in range(4)]
+ query = "필요해 이거"
+ narrowed = narrow_recall(items, query, words(query))
+ assert narrowed.relevant == []
+ assert len(narrowed.excluded[AUTO_MEMORY_WEAK_MATCH]) == 4
+
+
def test_a_memory_that_does_not_meet_the_question_at_all_keeps_the_old_reason() -> None:
- narrowed = narrow_recall(pool(), "전혀 다른 말", frozenset({"전혀", "다른", "말"}))
+ narrowed = narrow_recall(pool(), "전혀 다른 말", words("전혀 다른 말"))
assert AUTO_MEMORY_WEAK_MATCH not in narrowed.excluded
assert len(narrowed.excluded["QUERY_IRRELEVANT"]) == 6
def test_a_user_correction_still_needs_only_one_shared_word() -> None:
- items = [*pool(), correction("fix", ("프로젝트", "표본이"))]
- narrowed = narrow_recall(items, "프로젝트 이야기", frozenset({"프로젝트", "이야기"}))
+ items = [*pool(), correction("fix", "프로젝트 표본이 작으면 다시 확인한다")]
+ narrowed = narrow_recall(items, "프로젝트 이야기", words("프로젝트 이야기"))
assert ids(narrowed.relevant) == ["fix"]
def test_a_memory_the_question_names_gets_in_without_two_shared_words() -> None:
items = pool()
named = items[0].model_copy(update={"source_ref": "HYPOTHESIS:hypothesis:o:special-one"})
- narrowed = narrow_recall(
- [named, *items[1:]], "hypothesis:o:special-one 은?", frozenset({"special"})
- )
+ query = "hypothesis:o:special-one 은?"
+ narrowed = narrow_recall([named, *items[1:]], query, words(query))
assert ids(narrowed.relevant) == ["p0"]
@@ -223,16 +237,17 @@ def test_a_follow_up_question_skips_the_word_gate_and_says_which_marker_it_found
assert follow_up_markers("aboveboard review") == ()
assert set(FOLLOW_UP_MARKERS) >= {"앞에서", "아까", "방금", "그 가설", "previous"}
items = pool()
- narrowed = narrow_recall(items, "앞에서 세운 가설을 정리해 줘", frozenset({"앞에서", "정리해"}))
+ query = "앞에서 세운 가설을 정리해 줘"
+ narrowed = narrow_recall(items, query, words(query))
assert narrowed.follow_up_markers == ("앞에서",)
assert len(narrowed.relevant) == 6 and not narrowed.excluded
def test_in_a_follow_up_the_users_correction_comes_first_then_the_newest_memories() -> None:
- items = [*pool(), correction("fix", ("표본이",), minutes=-30)]
+ items = [*pool(), correction("fix", "표본이 작으면 다시 확인한다", minutes=-30)]
for n, item in enumerate(items[:6]):
items[n] = item.model_copy(update={"created_at": NOW + timedelta(minutes=n)})
- plan = plan_recall(items, "앞에서 한 일", frozenset(), RecallLimits(), follow_up=True)
+ plan = plan_recall(items, "앞에서 한 일", words("앞에서 한 일"), RecallLimits(), follow_up=True)
assert ids(plan.selected)[0] == "fix"
assert ids(plan.selected)[1] == "p5"
# the usual limits still apply: three hypotheses at most
@@ -246,5 +261,55 @@ def test_an_automatic_hypothesis_or_action_without_evidence_is_one_to_leave_out(
assert lacks_evidence(bare) and lacks_evidence(action) and not lacks_evidence(backed)
# a measured outcome is backed by its execution, not by source spans; a correction by its words
assert not lacks_evidence(memory("out", kind=MemoryKind.FACT))
- assert not lacks_evidence(correction("fix", ("표본이",)))
+ assert not lacks_evidence(correction("fix", "표본이 작으면 다시 확인한다"))
assert AUTO_MEMORY_NO_EVIDENCE == "AUTO_MEMORY_NO_EVIDENCE"
+
+
+def test_two_words_met_only_through_letter_pairs_are_not_enough_for_an_automatic_memory() -> None:
+ item = memory("pairs", body="실측 결과의 공개 기록", evidence=("span:1",))
+ query = "결과물을 공개할 검토"
+ narrowed = narrow_recall([item], query, words(query))
+ assert narrowed.relevant == []
+ assert narrowed.excluded[AUTO_MEMORY_WEAK_MATCH] == [item.memory_revision_id]
+
+
+def test_one_word_found_as_written_plus_one_through_a_pair_lets_it_in_marked_partial() -> None:
+ item = memory("mixed", body="실측 결과의 공개 기록", evidence=("span:1",))
+ query = "결과물을 공개 검토"
+ narrowed = narrow_recall([item], query, words(query))
+ assert ids(narrowed.relevant) == ["mixed"]
+ match = narrowed.matches[item.memory_revision_id]
+ assert match.matched_by == "QUERY" and match.match_strength == "PARTIAL"
+ assert match.partial_words == ("결과물을",) and match.words == ("결과물을", "공개")
+ whole = narrow_recall([item], "실측 공개 검토", words("실측 공개 검토"))
+ strong = whole.matches[item.memory_revision_id]
+ assert strong.match_strength == "WHOLE" and strong.partial_words == ()
+
+
+def test_a_users_correction_is_not_held_to_the_found_as_written_rule() -> None:
+ fix = correction("fix", "실측 결과의 공개 기록")
+ query = "결과물을 이야기"
+ narrowed = narrow_recall([fix], query, words(query))
+ assert ids(narrowed.relevant) == ["fix"]
+ assert narrowed.matches[fix.memory_revision_id].match_strength == "PARTIAL"
+
+
+def test_added_words_need_one_found_as_written_and_a_pair_only_one_is_marked_weak() -> None:
+ from thoth.domain.memory_terms import added_words
+
+ item = memory("a1", body="원문과 표를 읽기 전용으로 대조한다", evidence=("span:1",))
+ query = "대조 방법"
+ own = words(query)
+ # "원문" is found as written in the memory; "원문서" meets it only through the pair "원문"
+ strong = narrow_recall([item], query, own, added_words("원문 검토", own))
+ match = strong.matches[item.memory_revision_id]
+ assert ids(strong.relevant) == ["a1"] and match.matched_by == "EXPANSION"
+ assert match.added_words == ("원문",) and match.partial_added_words == ()
+ weak = narrow_recall([item], query, own, added_words("원문서 검토", own))
+ assert weak.relevant == [] and weak.excluded[AUTO_MEMORY_WEAK_MATCH] == [
+ item.memory_revision_id
+ ]
+ # a pair-only added word beside a whole one is let in and marked
+ both = narrow_recall([item], query, own, added_words("원문 전용서", own))
+ marked = both.matches[item.memory_revision_id]
+ assert marked.match_strength == "PARTIAL" and marked.partial_added_words == ("전용서",)
diff --git a/tests/unit/test_memory_terms.py b/tests/unit/test_memory_terms.py
new file mode 100644
index 0000000..f0aa459
--- /dev/null
+++ b/tests/unit/test_memory_terms.py
@@ -0,0 +1,54 @@
+"""The words of a text: Korean endings do not hide a shared word, and a pair never counts twice."""
+
+from __future__ import annotations
+
+from thoth.domain.memory_terms import matched_words, terms, words
+
+
+def met(question: str, memory_text: str) -> list[str]:
+ return [word.run for word in matched_words(words(question), terms(memory_text))]
+
+
+def test_a_word_with_a_different_ending_still_meets_the_same_word() -> None:
+ assert met("검증이 필요해", "추가 검증이 필요하다") == ["검증이", "필요해"]
+
+
+def test_a_two_letter_word_stands_alone_and_is_found_whole() -> None:
+ assert [w.run for w in words("대조")] == ["대조"] and words("대조")[0].pairs == ()
+ assert met("대조 방법", "두 표를 대조 한다") == ["대조"]
+ # a two-letter word is not found inside a longer word it only happens to be part of
+ assert met("대조", "대조군 설계") == ["대조"]
+ assert met("조사", "대조 설계") == []
+
+
+def test_a_word_with_a_particle_meets_the_bare_word_and_the_other_way_round() -> None:
+ assert met("자료를 보자", "자료 정리") == ["자료를"]
+ assert met("자료 정리", "자료를 보자") == ["자료"]
+
+
+def test_one_question_word_is_matched_once_however_many_of_its_pairs_are_found() -> None:
+ query = words("분석방법")
+ memory = terms("분석방법 분석 석방 방법")
+ found = matched_words(query, memory)
+ assert [w.run for w in found] == ["분석방법"]
+ assert len(query[0].pairs) == 3 and len(found) == 1
+
+
+def test_short_latin_and_digit_words_are_kept_and_filler_words_are_not() -> None:
+ assert [w.run for w in words("AI 와 5G, a is of")] == ["ai", "5g"]
+ assert met("AI 규제", "ai regulation") == ["ai"]
+ assert words("x") == () and words("") == ()
+
+
+def test_full_width_and_capital_letters_are_the_same_word() -> None:
+ assert met("\uff21\uff29 Safety", "ai safety notes") == ["ai", "safety"]
+
+
+def test_a_very_long_run_is_handled_without_error_and_stays_small() -> None:
+ long_run = "가" * 100_000
+ found = words(long_run)
+ assert len(found) == 1 and found[0].run == long_run
+ assert len(found[0].pairs) <= 1
+ assert terms(long_run) >= {long_run}
+ # a pair of letters from the very start of the run still works
+ assert met("가가가", long_run[:50]) == ["가가가"]
diff --git a/tests/unit/test_research_completion_view_hold.py b/tests/unit/test_research_completion_view_hold.py
new file mode 100644
index 0000000..d9ea6e9
--- /dev/null
+++ b/tests/unit/test_research_completion_view_hold.py
@@ -0,0 +1,110 @@
+"""A research result whose closed-loop test is held shows as held, with what the screen needs.
+
+The hold itself is made by the R2 replay guard (test_r2_replay_guard). Here the read side is
+checked on its own: what the thread view says about the answer when the stored result carries
+"r2_closed_loop" with terminal_state HOLD, and that every other result reads exactly as before."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from typing import Any, cast
+
+import pytest
+
+from thoth.application.services.research_completion_view import completion_view
+
+PENDING: dict[str, Any] = {
+ "plan_execution_id": "execution:1",
+ "execution_revision": 7,
+ "attempt_id": "attempt:1",
+ "attempt_state": "SUCCEEDED",
+ "observation_completeness": "NOT_ADMITTED",
+ "action_id": "action:1",
+ "object_id": "object:1",
+ "plan_id": "plan:1",
+ "input_difference": {
+ "same": False,
+ "previous_count": 3,
+ "current_count": 4,
+ "removed": ["a"],
+ "added": ["b", "c"],
+ },
+ "runtime_binding_same": True,
+ "exits": ["CLEAR_AND_RERUN"],
+}
+
+
+def view(result: dict[str, Any], *, fresh: bool = True, phase: str = "COMPLETE") -> dict[str, Any]:
+ manifest = SimpleNamespace(
+ result=result,
+ phase=phase,
+ completion="TERMINAL",
+ record_refs=(),
+ source_context_version="2.1.0",
+ terminal_reason="BOUNDED_RESEARCH_COMPLETE",
+ request_ref=None,
+ )
+ return completion_view(
+ cast(Any, None),
+ cast(Any, None),
+ None,
+ cast(Any, manifest),
+ fresh,
+ "SUCCEEDED",
+ "IDLE",
+ None,
+ )
+
+
+def test_a_held_closed_loop_test_makes_the_answer_a_hold_and_carries_the_hold() -> None:
+ held = view(
+ {
+ "answer": "A partial answer",
+ "r2_closed_loop": {
+ "terminal_state": "HOLD",
+ "reason_code": "RESEARCH_TEST_RESULT_RECONCILIATION_REQUIRED",
+ "pending_result": PENDING,
+ },
+ }
+ )
+ assert held["answer_outcome"]["state"] == "HOLD"
+ assert held["answer_outcome"]["has_answer"] is True
+ hold = held["execution_hold"]
+ assert hold["schema_version"] == "1.0.0"
+ assert hold["reason_code"] == "RESEARCH_TEST_RESULT_RECONCILIATION_REQUIRED"
+ for key in ("plan_execution_id", "execution_revision", "attempt_id", "attempt_state"):
+ assert hold[key] == PENDING[key]
+ assert hold["input_difference"] == PENDING["input_difference"]
+ assert hold["exits"] == ["CLEAR_AND_RERUN"]
+
+
+@pytest.mark.parametrize(
+ "result",
+ [
+ {"answer": "An answer"},
+ {"answer": "An answer", "r2_closed_loop": None},
+ {"answer": "An answer", "r2_closed_loop": {"terminal_state": "COMPLETED"}},
+ ],
+)
+def test_a_result_without_a_held_test_reads_exactly_as_before(result: dict[str, Any]) -> None:
+ plain = view(result)
+ assert plain["answer_outcome"]["state"] == "PARTIAL"
+ assert "execution_hold" not in plain
+
+
+def test_another_hold_is_a_hold_without_anything_to_clear() -> None:
+ held = view(
+ {
+ "answer": "A partial answer",
+ "r2_closed_loop": {
+ "terminal_state": "HOLD",
+ "reason_code": "RESEARCH_TEST_TEMPLATE_UNAVAILABLE",
+ "runtime_io_completed": False,
+ },
+ }
+ )
+ assert held["answer_outcome"]["state"] == "HOLD"
+ hold = held["execution_hold"]
+ assert hold["reason_code"] == "RESEARCH_TEST_TEMPLATE_UNAVAILABLE"
+ assert hold["exits"] == [] and hold["plan_execution_id"] is None
+ assert hold["attempt_id"] is None and hold["input_difference"] is None
diff --git a/tests/unit/test_trace_origin_context.py b/tests/unit/test_trace_origin_context.py
new file mode 100644
index 0000000..d2931d3
--- /dev/null
+++ b/tests/unit/test_trace_origin_context.py
@@ -0,0 +1,85 @@
+"""A trace origin in the shortlist and the context: what is pinned, and what is never used."""
+
+from typing import Any, cast
+
+from tests.unit.test_research_short_text_selection import source
+
+from thoth.application.services.research_retrieval import lexical_candidates
+from thoth.application.services.trace_origin import apply_trace_origin
+from thoth.domain.enums import CutoffState
+from thoth.domain.research_execution import ResearchWork
+from thoth.domain.trace_origin import TraceVerdictOrigin
+
+
+def origin(**changes: Any) -> dict[str, Any]:
+ value = TraceVerdictOrigin(
+ project_id="p",
+ trace_set_digest="a" * 12,
+ subject_kind="CRITERION",
+ subject_id="C-1",
+ verdict_revision="b" * 64,
+ state="HOLD_NO_RESULT",
+ reason_codes=("NO_RESULT:weather=rain",),
+ source_span_refs=("span:0",),
+ ).model_dump(mode="json")
+ return {**value, **changes}
+
+
+def work() -> ResearchWork:
+ return ResearchWork(cast(Any, None), "question", cast(Any, None))
+
+
+def test_a_pinned_span_stays_in_a_full_shortlist_without_widening_it() -> None:
+ evidence = source(160)
+ plain = lexical_candidates("latency 12 ms alpha", (), evidence)
+ assert "span:0" not in {s.span_id for s in plain} # the question alone does not reach it
+ pinned = lexical_candidates("latency 12 ms alpha", (), evidence, ("span:0",))
+ ids = [s.span_id for s in pinned]
+ assert ids[0] == "span:0" # kept first
+ assert len(pinned) == len(plain) + 1 # only the pin is added to what the question found
+ assert set(ids) - {"span:0"} <= {s.span_id for s in plain}
+
+
+def test_a_pin_that_is_not_retrievable_is_ignored() -> None:
+ evidence = list(source(5))
+ late = evidence[0].model_copy(
+ update={"span_id": "span:late", "cutoff_state": CutoffState.AFTER_CUTOFF}
+ )
+ evidence.append(late)
+ found = lexical_candidates("latency alpha", (), tuple(evidence), ("span:late", "span:none"))
+ assert {s.span_id for s in found} == {
+ f"span:{i}" for i in range(5)
+ } # eligibility is not bypassed
+
+
+def test_no_pin_gives_the_same_shortlist_as_before() -> None:
+ evidence = source(160)
+ assert lexical_candidates("latency 12 ms alpha", (), evidence) == lexical_candidates(
+ "latency 12 ms alpha", (), evidence, ()
+ )
+
+
+def test_a_valid_origin_goes_to_the_context_and_pins_its_source_positions() -> None:
+ item = work()
+ apply_trace_origin(item, {"origin": origin()}, "p")
+ assert item.context["origin"]["subject_id"] == "C-1" # type: ignore[index]
+ assert "note" in cast(dict[str, Any], item.context["origin"])
+ assert item.pinned_spans == ("span:0",)
+
+
+def test_no_origin_leaves_the_work_as_it_was() -> None:
+ item = work()
+ apply_trace_origin(item, {}, "p")
+ apply_trace_origin(item, {"origin": None}, "p")
+ assert item.context == {} and item.pinned_spans == ()
+
+
+def test_an_origin_of_another_project_or_in_the_wrong_shape_is_left_out_and_said_so() -> None:
+ other = work()
+ apply_trace_origin(other, {"origin": origin(project_id="q")}, "p")
+ assert "origin" not in other.context and other.pinned_spans == ()
+ assert other.context["origin_rejected"] == "TRACE_ORIGIN_PROJECT_MISMATCH"
+ made_up = work()
+ apply_trace_origin(made_up, {"origin": {"kind": "TRACE_VERDICT", "state": "PASS", "x": 1}}, "p")
+ assert "origin" not in made_up.context and made_up.pinned_spans == ()
+ assert made_up.context["origin_rejected"] == "TRACE_ORIGIN_INVALID"
diff --git a/tests/unit/test_update_source_manifest.py b/tests/unit/test_update_source_manifest.py
new file mode 100644
index 0000000..3aaf82b
--- /dev/null
+++ b/tests/unit/test_update_source_manifest.py
@@ -0,0 +1,129 @@
+"""The public file list is rebuilt by one script and a stale list says how to refresh it."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from pathlib import Path
+
+import pytest
+from scripts import update_source_manifest as tool
+from tests.architecture.public_source_profile import PublicPackageError, public_source_profile
+
+HEADER = {
+ "schema_version": "thoth-source-preview-manifest-v1",
+ "created_at": "2026-01-01T00:00:00+00:00",
+ "license": "MIT",
+ "git_history_included": False,
+ "full_suite_verified": False,
+ "known_selected_regression_failures": 3,
+ "files": [],
+ "source_archive_sha256": "a" * 64,
+ "public_repository": "https://example.invalid/repo",
+ "snapshot_type": "MIT source-only public preview",
+ "verification_basis": "Kept as written.",
+}
+PACKAGING = (
+ "# Source-only package boundary\n"
+ "deployment configuration is excluded.\n"
+ "HOSTED_IMAGE_NOT_VERIFIED\n"
+)
+ANCHORS = {
+ "docs/architecture/rpc-method-catalog.md": "# rpc\n",
+ "config/architecture-conformance.json": json.dumps(
+ {"canonical_owners": [{"atomicity_debt": {"status": "OPEN"}}]}
+ ),
+ "docs/PACKAGING.md": PACKAGING,
+ "docs/VERIFICATION.md": "# verification\n",
+ "docs/architecture/backend-runtime-boundaries.md": "# boundaries\n",
+}
+
+
+def _tree(root: Path, extra: dict[str, str] | None = None) -> list[str]:
+ files = {**ANCHORS, "b.txt": "second\n", "a/z.txt": "first\n", **(extra or {})}
+ for relative, text in files.items():
+ path = root / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(text.encode("utf-8"))
+ (root / "SOURCE_MANIFEST.json").write_text("{}", encoding="utf-8")
+ return [*files, "SOURCE_MANIFEST.json"]
+
+
+def test_build_skips_the_list_itself_sorts_paths_keeps_the_header_and_renews_created_at(
+ tmp_path: Path,
+) -> None:
+ paths = _tree(tmp_path)
+ built = tool.build_manifest(
+ tmp_path, paths, dict(HEADER), created_at="2026-10-04T00:00:00+00:00"
+ )
+ listed = [row["path"] for row in built["files"]]
+ assert listed == sorted(listed) and "SOURCE_MANIFEST.json" not in listed
+ assert set(listed) == set(ANCHORS) | {"b.txt", "a/z.txt"}
+ assert built["created_at"] == "2026-10-04T00:00:00+00:00"
+ assert list(built) == list(HEADER)
+ for key, expected in HEADER.items():
+ if key not in {"files", "created_at"}:
+ assert built[key] == expected
+ row = next(item for item in built["files"] if item["path"] == "b.txt")
+ assert row == {
+ "path": "b.txt",
+ "sha256": hashlib.sha256(b"second\n").hexdigest(),
+ "bytes": 7,
+ }
+
+
+def test_build_refuses_a_header_with_other_fields(tmp_path: Path) -> None:
+ paths = _tree(tmp_path)
+ with pytest.raises(ValueError, match="header"):
+ tool.build_manifest(tmp_path, paths, {**HEADER, "extra": 1}, created_at="x")
+ with pytest.raises(ValueError, match="header"):
+ tool.build_manifest(
+ tmp_path,
+ paths,
+ {key: value for key, value in HEADER.items() if key != "license"},
+ created_at="x",
+ )
+
+
+def test_compare_counts_changed_unlisted_and_missing_files(tmp_path: Path) -> None:
+ paths = _tree(tmp_path)
+ manifest = tool.build_manifest(tmp_path, paths, dict(HEADER), created_at="t")
+ assert tool.compare(tmp_path, manifest, paths).clean
+ (tmp_path / "b.txt").write_bytes(b"changed bytes\n")
+ (tmp_path / "new.txt").write_bytes(b"new\n")
+ (tmp_path / "a/z.txt").unlink()
+ current = [path for path in [*paths, "new.txt"] if path != "a/z.txt"]
+ difference = tool.compare(tmp_path, manifest, current)
+ assert difference.changed == ("b.txt",)
+ assert difference.unlisted == ("new.txt",)
+ assert difference.missing == ("a/z.txt",)
+ assert not difference.clean
+ report = tool.format_report(difference)
+ assert "1" in report and "b.txt" in report and "new.txt" in report and "a/z.txt" in report
+
+
+def test_the_report_lists_at_most_twenty_names_and_counts_the_rest(tmp_path: Path) -> None:
+ names = tuple(f"f{index:03}.txt" for index in range(25))
+ report = tool.format_report(tool.Difference(changed=names, unlisted=(), missing=()))
+ assert "f019.txt" in report and "f020.txt" not in report and "5" in report
+
+
+def test_a_rebuilt_list_passes_the_public_source_profile_check(tmp_path: Path) -> None:
+ paths = _tree(tmp_path)
+ manifest = tool.build_manifest(
+ tmp_path, paths, dict(HEADER), created_at="2026-10-04T00:00:00+00:00"
+ )
+ (tmp_path / "SOURCE_MANIFEST.json").write_bytes(tool.render(manifest))
+ profile = public_source_profile(tmp_path)
+ assert profile is not None and set(profile.files) == {row["path"] for row in manifest["files"]}
+ (tmp_path / "b.txt").write_bytes(b"edited after the list was made\n")
+ with pytest.raises(PublicPackageError, match="update_source_manifest"):
+ public_source_profile(tmp_path)
+
+
+def test_render_uses_lf_utf8_without_bom_and_a_final_newline(tmp_path: Path) -> None:
+ paths = _tree(tmp_path)
+ manifest = tool.build_manifest(tmp_path, paths, dict(HEADER), created_at="t")
+ raw = tool.render(manifest)
+ assert raw.endswith(b"}\n") and b"\r" not in raw and not raw.startswith(b"\xef\xbb\xbf")
+ assert json.loads(raw) == manifest
diff --git a/tests/unit/test_validate_synthetic_radar_demo.py b/tests/unit/test_validate_synthetic_radar_demo.py
new file mode 100644
index 0000000..2aa44bf
--- /dev/null
+++ b/tests/unit/test_validate_synthetic_radar_demo.py
@@ -0,0 +1,130 @@
+"""The synthetic radar demo folder is internally consistent and the checker catches edits."""
+
+from __future__ import annotations
+
+import importlib.util
+import shutil
+import sys
+from pathlib import Path
+
+import pytest
+
+REPO = Path(__file__).parents[2]
+DEMO = REPO / "examples" / "synthetic-radar-demo-v1"
+SCORER = REPO / "examples" / "synthetic-radar-demo-v1-scorer"
+SCRIPT = REPO / "scripts" / "validate_synthetic_radar_demo.py"
+
+
+def _load():
+ spec = importlib.util.spec_from_file_location("validate_synthetic_radar_demo", SCRIPT)
+ assert spec is not None and spec.loader is not None
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[spec.name] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+validator = _load()
+
+
+@pytest.fixture
+def copy(tmp_path: Path) -> Path:
+ """A private copy of the demo and scorer folders to damage."""
+ root = tmp_path / "synthetic-radar-demo-v1"
+ shutil.copytree(DEMO, root)
+ shutil.copytree(SCORER, tmp_path / "synthetic-radar-demo-v1-scorer")
+ return root
+
+
+def _rewrite_manifest(root: Path) -> None:
+ (root / validator.MANIFEST_NAME).write_text(
+ validator.manifest_text(validator.file_digests(root)), encoding="utf-8", newline="\n"
+ )
+
+
+def test_shipped_demo_is_consistent() -> None:
+ assert validator.validate(DEMO) == []
+
+
+def test_rates_are_recomputed_from_the_trial_files() -> None:
+ result = validator.read_yaml(DEMO / "31_RESULT_RAIN_SYNTHETIC.yaml")
+ rows, problems = validator.read_trials(DEMO / result["trials_csv"])
+ assert problems == []
+ detected = sum(
+ 1 for row in rows if row["row_kind"] == "TARGET_TRIAL" and row["detected"] == "1"
+ )
+ assert (detected, str(validator.rate(detected, 20))) == (16, "0.80")
+ events = sum(1 for row in rows if row["row_kind"] == "FALSE_TRACK_EVENT")
+ assert (events, str(validator.rate(events, 10))) == (7, "0.70")
+
+
+def test_edited_trial_row_is_reported_even_when_the_manifest_is_refreshed(copy: Path) -> None:
+ path = copy / "data" / "31_RAIN_TRIALS_SYNTHETIC.csv"
+ text = path.read_text(encoding="utf-8").replace("SYN-T-RAIN-17,,0,", "SYN-T-RAIN-17,,1,")
+ path.write_text(text, encoding="utf-8", newline="\n")
+ _rewrite_manifest(copy)
+ problems = validator.validate(copy)
+ assert any("detection recomputed" in problem for problem in problems)
+
+
+def test_edit_without_refreshing_the_manifest_is_reported(copy: Path) -> None:
+ path = copy / "20_TEST_PLAN_SYNTHETIC.yaml"
+ path.write_text(
+ path.read_text(encoding="utf-8").replace("Dry weather run", "Dry run"),
+ encoding="utf-8",
+ newline="\n",
+ )
+ assert any("changed since the manifest" in problem for problem in validator.validate(copy))
+
+
+def test_duplicate_id_is_reported(copy: Path) -> None:
+ path = copy / "20_TEST_PLAN_SYNTHETIC.yaml"
+ text = path.read_text(encoding="utf-8").replace("id: SYN-TC-RAIN", "id: SYN-TC-DRY")
+ path.write_text(text, encoding="utf-8", newline="\n")
+ _rewrite_manifest(copy)
+ assert any("duplicate id SYN-TC-DRY" in problem for problem in validator.validate(copy))
+
+
+def test_missing_notice_is_reported(copy: Path) -> None:
+ path = copy / "01_SOURCE_LINEAGE.yaml"
+ lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
+ path.write_text("".join(lines[1:]), encoding="utf-8", newline="\n")
+ _rewrite_manifest(copy)
+ assert any("not the synthetic notice" in problem for problem in validator.validate(copy))
+
+
+def test_unit_mismatch_between_criterion_and_result_is_reported(copy: Path) -> None:
+ path = copy / "10_REQUIREMENTS_SYNTHETIC.yaml"
+ text = path.read_text(encoding="utf-8").replace(
+ 'threshold: "0.90", unit: ratio, condition: weather=rain',
+ 'threshold: "0.90", unit: per_min, condition: weather=rain',
+ )
+ path.write_text(text, encoding="utf-8", newline="\n")
+ _rewrite_manifest(copy)
+ assert any("differs from the criterion's" in problem for problem in validator.validate(copy))
+
+
+def test_schema_rejects_a_missing_required_field(copy: Path) -> None:
+ path = copy / "10_REQUIREMENTS_SYNTHETIC.yaml"
+ text = path.read_text(encoding="utf-8").replace(", required: true}", "}", 1)
+ path.write_text(text, encoding="utf-8", newline="\n")
+ _rewrite_manifest(copy)
+ assert any("missing 'required'" in problem for problem in validator.validate(copy))
+
+
+def test_scorer_naming_an_unknown_criterion_is_reported(copy: Path) -> None:
+ scorer = copy.parent / "synthetic-radar-demo-v1-scorer" / "40_ASSESSMENT_DRAFT_SYNTHETIC.yaml"
+ scorer.write_text(
+ scorer.read_text(encoding="utf-8").replace("SYN-C-DET-FOG", "SYN-C-DET-SNOW", 1),
+ encoding="utf-8",
+ newline="\n",
+ )
+ assert any(
+ "unknown criterion SYN-C-DET-SNOW" in problem for problem in validator.validate(copy)
+ )
+
+
+def test_manifest_says_it_is_only_an_integrity_check() -> None:
+ manifest = validator.read_yaml(DEMO / validator.MANIFEST_NAME)
+ statement = manifest["integrity_statement"]
+ assert "do not show that the data is true" in statement and "approved" in statement