From f4b03acc27ec40ec4facd9d028f594195f8351fd Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 23:49:50 +0900 Subject: [PATCH 001/393] feat(dashboard): quantify case types and preserve project journeys --- backend/app/operations_dashboard.py | 35 +++++++++++++++---- docs/product-technical-gap-baseline.md | 12 +++---- frontend/src/App.css | 2 ++ frontend/src/App.test.tsx | 1 + frontend/src/App.tsx | 10 ++++++ frontend/src/api.ts | 7 ++++ .../OperationsDashboard.stories.tsx | 7 ++++ .../components/OperationsDashboard.test.tsx | 16 +++++++++ .../src/components/OperationsDashboard.tsx | 18 ++++++++-- frontend/src/components/WorkspaceNav.test.tsx | 5 +-- frontend/src/gnbChrome.ts | 1 + frontend/src/i18n.test.ts | 2 +- tests/test_operations_dashboard.py | 32 ++++++++++++++++- 13 files changed, 129 insertions(+), 19 deletions(-) diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py index 6342d8fd4..8bbcba085 100644 --- a/backend/app/operations_dashboard.py +++ b/backend/app/operations_dashboard.py @@ -105,16 +105,21 @@ async def fetch_operations_dashboard( classification.summary_text, classification.evidence_text, classification.evidence_post_id, coalesce(post.event_occurred_at, post.created_at) as occurred_at, - coalesce(nullif(btrim(post.source_project_name), ''), project.project_name) - as project_name + coalesce(nullif(btrim(post.source_project_name), ''), project.project_names[1]) + as project_name, + coalesce(project.project_names, array[]::text[]) as project_names from operations_case_classification classification join source_post post on post.post_id = classification.post_id left join lateral ( - select mention.project_name - from post_project_mention mention - where mention.post_id = post.post_id - order by mention.confidence desc, mention.project_name, mention.project_key - limit 1 + select array_agg(names.project_name order by names.project_name) as project_names + from ( + select nullif(btrim(post.source_project_name), '') as project_name + union + select nullif(btrim(mention.project_name), '') + from post_project_mention mention + where mention.post_id = post.post_id + ) names + where names.project_name is not null ) project on true where {visible} order by coalesce(post.event_occurred_at, post.created_at) desc, @@ -148,6 +153,12 @@ async def fetch_operations_dashboard( ) total = int(metrics["total_post_count"]) external = int(metrics["external_post_count"]) + case_post_ids: dict[str, set[str]] = {} + case_event_counts: dict[str, int] = {} + for row in case_rows: + kind = row["case_kind_code"] + case_post_ids.setdefault(kind, set()).add(str(row["post_id"])) + case_event_counts[kind] = case_event_counts.get(kind, 0) + 1 return { "period_label": _period_label(period_start, period_end), "total_post_count": total, @@ -156,12 +167,22 @@ async def fetch_operations_dashboard( "external_percent": external * 100 / total if total else 0.0, "pending_analysis_count": int(metrics["pending_analysis_count"]), "failed_analysis_count": int(metrics["failed_analysis_count"]), + "case_metrics": [ + { + "case_kind_code": kind, + "case_kind_label": label, + "event_count": case_event_counts.get(kind, 0), + "post_count": len(case_post_ids.get(kind, set())), + } + for kind, label in CASE_KIND_LABELS.items() + ], "cases": [ { "post_id": str(row["post_id"]), "case_kind_code": row["case_kind_code"], "case_kind_label": CASE_KIND_LABELS[row["case_kind_code"]], "project_name": row["project_name"], + "project_names": list(row["project_names"]), "summary_text": row["summary_text"], "evidence_text": row["evidence_text"], "evidence_post_id": str(row["evidence_post_id"]), diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 9a65c2eb6..ae8ea7524 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,17 +1,17 @@ # Product & Technical Gap Baseline -> Dashboard delivery snapshot: 2026-08-25 21:34 KST. Protected `main` was -> `d7d5eeb310b055b5e138060cf2dfb929b03090a6`. This local branch is not +> Dashboard delivery snapshot: 2026-08-25 23:54 KST. Protected `main` was +> `04e6b610655d0db91d5f7ba9486bdda1440e0b19`. This local branch is not > protected-main release evidence. ## Operations Dashboard PRD/TRD traceability | Requirement | Evidence contract | Delivery state | |---|---|---| -| Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification with cited spans; Event Lineage context | Candidate implementation; authenticated runtime acceptance pending | -| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus persisted summary actions/roles | Candidate implementation; corpus backfill pending | -| External information count/rate and sales/project relation | ADR 0206; semantic `external_information` classification inside Dashboard GNB | Candidate implementation; no separate Board by product decision | -| Project-specific journey | Explicit source/semantic project membership plus event-time ordering | Candidate API and ordered journey UI implemented; authenticated runtime acceptance pending | +| Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification with cited spans; Event Lineage context | Candidate API/UI now reports separate per-kind Event and distinct-post counts beside cited qualitative facts; authenticated runtime acceptance pending | +| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus persisted summary actions/roles | Candidate API/UI now reports separate per-kind Event and distinct-post counts; corpus backfill pending | +| External information count/rate and sales/project relation | ADR 0206; semantic `external_information` classification inside Dashboard GNB | Candidate GNB destination filters the Dashboard to external evidence; no separate Board by product decision; authenticated runtime acceptance pending | +| Project-specific journey | Explicit source/semantic project membership plus event-time ordering | Candidate API preserves every explicit project membership and the UI orders each journey chronologically; authenticated runtime acceptance pending | | Repeat issue to design improvement | `repeat_issue`, `issue_pattern`, and `improvement_action` cited facts | Candidate semantic contract; design-system connector acceptance pending | | Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback; authenticated runtime acceptance pending | | Similar VOC, customer cohort, prior action | Persisted repeat-issue candidate semantics plus orchestrator pair adjudication and extractive evidence | Candidate live post endpoint and post-detail UI implemented; authenticated runtime acceptance pending | diff --git a/frontend/src/App.css b/frontend/src/App.css index eeaa61299..79aa1c852 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1299,6 +1299,7 @@ .dashboard-metrics > div:last-child { border-right: 0; } .dashboard-metrics dt { color: var(--color-text); font-size: 0.875rem; } .dashboard-metrics dd { margin: 0.25rem 0 0; font-size: 1.5rem; font-weight: 700; } +.dashboard-case-metrics .dashboard-metrics { grid-template-columns: repeat(4, minmax(0, 1fr)); } .dashboard-case-grid { display: grid; @@ -1353,6 +1354,7 @@ .operations-dashboard { padding: 1rem; } .operations-dashboard-heading { align-items: start; flex-direction: column; } .dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .dashboard-case-metrics .dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .dashboard-case-grid { grid-template-columns: 1fr; } } diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 2dee4513d..fa941721d 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -4198,6 +4198,7 @@ describe("App, authenticated", () => { expect(screen.getByRole("button", { name: "게시판" })).toHaveAttribute("aria-current", "page"); expect(within(nav).getAllByRole("button").map((button) => button.textContent)).toEqual([ "Dashboard", + "외부 정보", "게시판", "고객 마스터", "달력", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 76ff51dec..b0dc2ff79 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5070,6 +5070,16 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean }} /> ) : null} + {destination === "external" ? ( + { + setPostToOpen(postId); + setDestination("board"); + }} + /> + ) : null} {destination === "board" ? ( ; cases: OperationsDashboardCase[]; } diff --git a/frontend/src/components/OperationsDashboard.stories.tsx b/frontend/src/components/OperationsDashboard.stories.tsx index 5187bafcc..5a545bc4b 100644 --- a/frontend/src/components/OperationsDashboard.stories.tsx +++ b/frontend/src/components/OperationsDashboard.stories.tsx @@ -12,6 +12,12 @@ export const EvidenceReady: Story = { data: { period_label: "2026-08-01–2026-08-25 · Event time", total_post_count: 40, total_event_count: 17, external_post_count: 9, external_percent: 22.5, pending_analysis_count: 3, + case_metrics: [ + { case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 규명", event_count: 7, post_count: 5 }, + { case_kind_code: "rebid_handover", case_kind_label: "재입찰 · 인수인계", event_count: 4, post_count: 3 }, + { case_kind_code: "external_information", case_kind_label: "발주 공고 · 시장 동향", event_count: 9, post_count: 9 }, + { case_kind_code: "repeat_issue", case_kind_label: "반복 이슈", event_count: 2, post_count: 2 }, + ], failed_analysis_count: 0, cases: [ { post_id: "synthetic-post-1", case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 역추적", project_name: "Synthetic Transformer Renewal", summary_text: "사양 변경 이후 원인 수주와 Pool을 확인", evidence_text: "Revision B originated in order SO-100 from pool SP-20.", evidence_post_id: "synthetic-post-1", occurred_at: "2026-08-04T00:00:00Z", facts: [{ fact_type_code: "originating_order", fact_type_label: "원인 수주", value_text: "SO-100 · SP-20", evidence_text: "order SO-100 from pool SP-20", evidence_post_id: "synthetic-post-1" }] }, @@ -25,6 +31,7 @@ export const EvidenceReady: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText("9건 · 22.5%")).toBeInTheDocument(); + await expect(canvas.getByText("7 Event · 5글")).toBeVisible(); await expect(canvas.getAllByRole("button", { name: "분류 근거 글 열기" })[0]).toBeVisible(); }, }; diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx index 02f2089e8..cf8b9bcea 100644 --- a/frontend/src/components/OperationsDashboard.test.tsx +++ b/frontend/src/components/OperationsDashboard.test.tsx @@ -17,6 +17,10 @@ const data = { external_percent: 25, pending_analysis_count: 2, failed_analysis_count: 0, + case_metrics: [ + { case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 규명", event_count: 3, post_count: 2 }, + { case_kind_code: "rebid_handover", case_kind_label: "재입찰 · 인수인계", event_count: 2, post_count: 2 }, + ], cases: [{ post_id: "post-1", case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 역추적", project_name: "Synthetic Grid Upgrade", summary_text: "사양 변경 이후 원인 수주를 확인했습니다.", evidence_text: "Revision B changed the enclosure.", evidence_post_id: "evidence-post-1", occurred_at: "2026-08-12T00:00:00Z", @@ -28,6 +32,7 @@ describe("OperationsDashboardView", () => { it("distinguishes posts, events, percentages and opens evidence", async () => { const onOpenPost = vi.fn(); render(); + expect(screen.getByText("3 Event · 2글")).toBeInTheDocument(); expect(screen.getByText("5건 · 25.0%")).toBeInTheDocument(); expect(screen.getByText("원인 수주")).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "분류 근거 글 열기" })); @@ -41,6 +46,17 @@ describe("OperationsDashboardView", () => { expect(screen.getByRole("status")).toHaveTextContent("분석 대기 건부터 처리하세요"); }); + it("places multi-project evidence in every explicit journey and orders events oldest first", () => { + const later = { ...data.cases[0], post_id: "post-later", occurred_at: "2026-08-20T00:00:00Z", project_names: ["Synthetic Grid Upgrade", "Synthetic Relay Renewal"] }; + const earlier = { ...data.cases[0], post_id: "post-earlier", occurred_at: "2026-08-01T00:00:00Z", project_names: ["Synthetic Grid Upgrade"] }; + render( undefined} />); + + expect(screen.getByRole("heading", { name: "Synthetic Relay Renewal" })).toBeInTheDocument(); + const primaryJourney = screen.getByRole("heading", { name: "Synthetic Grid Upgrade" }).parentElement; + expect(primaryJourney?.querySelectorAll("time")[0]).toHaveAttribute("datetime", earlier.occurred_at); + expect(primaryJourney?.querySelectorAll("time")[1]).toHaveAttribute("datetime", later.occurred_at); + }); + it("separates failed analysis from pending work and gives the next action", () => { render( undefined} />); expect(screen.getByText("분석 실패").nextElementSibling).toHaveTextContent("2"); diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx index e207f474e..6b2890647 100644 --- a/frontend/src/components/OperationsDashboard.tsx +++ b/frontend/src/components/OperationsDashboard.tsx @@ -54,7 +54,8 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost const cases = externalOnly ? data.cases.filter((item) => item.case_kind_code === "external_information") : data.cases; const journeys = Object.entries( cases.reduce>((groups, item) => { - if (item.project_name) (groups[item.project_name] ??= []).push(item); + const projects = item.project_names ?? (item.project_name ? [item.project_name] : []); + projects.forEach((project) => (groups[project] ??= []).push(item)); return groups; }, {}), ); @@ -71,6 +72,19 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
분석 대기
{data.pending_analysis_count}
분석 실패
{data.failed_analysis_count}
+ {!externalOnly ? ( +
+

업무 유형별 현황

+
+ {data.case_metrics.map((metric) => ( +
+
{metric.case_kind_label}
+
{metric.event_count} Event · {metric.post_count}글
+
+ ))} +
+
+ ) : null} {!externalOnly && journeys.length ? (

프로젝트 여정

@@ -78,7 +92,7 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost

{project}

    - {(events ?? []).map((event) => ( + {[...(events ?? [])].sort((left, right) => left.occurred_at.localeCompare(right.occurred_at)).map((event) => (
- {cases.length === 0 && data.failed_analysis_count === 0 ? ( -

{data.pending_analysis_count > 0 ? "선택 기간에 분석 완료된 근거가 없습니다. 분석 대기 건부터 처리하세요." : "선택 기간에 분석할 수 있는 근거가 없습니다. 기간이나 접근 범위를 확인하세요."}

+ {cases.length === 0 && (externalOnly || data.failed_analysis_count === 0) ? ( +

{externalOnly ? "선택 기간에 분류된 외부 정보가 없습니다. 기간이나 접근 범위를 확인하세요." : data.pending_analysis_count > 0 ? "선택 기간에 분석 완료된 근거가 없습니다. 분석 대기 건부터 처리하세요." : "선택 기간에 분석할 수 있는 근거가 없습니다. 기간이나 접근 범위를 확인하세요."}

) : null} - {data.failed_analysis_count > 0 ?

분석 실패 {data.failed_analysis_count}건을 재처리한 뒤 근거 누락 여부를 다시 확인하세요.

: null} + {!externalOnly && data.failed_analysis_count > 0 ?

분석 실패 {data.failed_analysis_count}건을 재처리한 뒤 근거 누락 여부를 다시 확인하세요.

: null}
); } diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py index 9f67303d9..a9ed05915 100644 --- a/tests/test_operations_dashboard.py +++ b/tests/test_operations_dashboard.py @@ -122,6 +122,9 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None: assert "process_unit_id::text = any($2::text[])" in query assert "coalesce(post.event_occurred_at, post.created_at)" in query assert args[1:] == (["00000000-0000-0000-0000-000000000008"], date(2026, 8, 1), date(2026, 8, 31)) + case_query = conn.queries[1][0] + assert "order by primary_mention.confidence desc" in case_query + assert "coalesce(nullif(btrim(post.source_project_name), ''), project.primary_project_name)" in case_query @pytest.mark.anyio From 41527fa956ab7d29f3b9566d07898e0963f96e11 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 26 Aug 2026 00:05:49 +0900 Subject: [PATCH 004/393] docs: refresh exact PR and UI evidence --- docs/product-technical-gap-baseline.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 7de069ed8..255a47ce5 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,6 +1,6 @@ # Product & Technical Gap Baseline -> Dashboard delivery snapshot: 2026-08-26 00:20 KST. Protected `main` was +> Dashboard delivery snapshot: 2026-08-26 00:05 KST. Protected `main` was > `04e6b610655d0db91d5f7ba9486bdda1440e0b19`. This local branch is not > protected-main release evidence. @@ -55,20 +55,25 @@ card overflow. Narrow inspection showed two-column metrics, readable cards and scrollable. No identifying runtime record or screenshot is committed. The `EvidenceReady`, `NarrowViewport`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` scenes cover the ADR 0206 state inventory. +The exact #640 head `4677052c` adds `ExternalInformationEmpty`; its Storybook +build was inspected at 1440×1000 and 390×844 and exposes neither corpus-wide +pending/failed counts nor a misleading corpus failure alert in that scoped +destination. Screenshots remain local synthetic audit evidence and are not +committed. Authenticated authorized-corpus acceptance remains separate and may return only aggregate, non-identifying evidence to this repository. ### Exact open-PR boundary At this snapshot there were 7 open PRs and 10 open issues. Exact observed heads -were `#640 f4b03acc`, `#639 aee02dca`, `#636 1230a1a7`, `#632 187a4832`, -`#631 c0022c97`, `#629 ac38c652`, and `#579 689a21b6`. All remain blocked on +were `#640 4677052c`, `#639 aee02dca`, `#636 eeeb23c6`, `#632 3851c7cf`, +`#631 c0022c97`, `#629 0f4665b5`, and `#579 689a21b6`. All remain blocked on hosted gates and/or independent review. These observations are not merge readiness. Re-fetch exact heads, unresolved threads, checks, approvals, rulesets, and merge SHA before any lifecycle claim. -> Audit snapshot: 2026-08-26 00:20 KST (refreshed by the autonomous merge +> Audit snapshot: 2026-08-26 00:05 KST (refreshed by the autonomous merge > loop). This repository records synthetic fixtures and aggregate, > non-identifying runtime evidence only. Open PRs and local checks are not > protected-default-branch release evidence. Identifying post identifiers, @@ -85,12 +90,12 @@ context only. | PR | Exact observed head | Merge/check state at this snapshot | | ---: | --- | --- | -| #640 | `f4b03acc` | quantifies dashboard case metrics and preserves project journeys; hosted gates and independent review remain required | +| #640 | `4677052c` | quantifies dashboard case metrics, preserves confidence-ranked project labels, and keeps scoped external evidence honest; hosted gates and independent review remain required | | #639 | `aee02dca` | repairs Running-action, Compose, and TEPP configuration contracts; hosted gates and independent review remain required | -| #636 | `1230a1a7` | publishes the calibrated external-lineage contract; hosted gates and independent review remain required | -| #632 | `187a4832` | preserves graph-fact source provenance and authorization; hosted gates and independent review remain required | +| #636 | `eeeb23c6` | publishes the calibrated external-lineage contract without a redundant explicit-child filter; hosted gates and independent review remain required | +| #632 | `3851c7cf` | preserves graph-fact source provenance and authorization with a static landing-query contract; hosted gates and independent review remain required | | #631 | `c0022c97` | decomposes ADR gaps and queue baseline; hosted gates and independent review remain required | -| #629 | `ac38c652` | releases provider work from database leases and bounds landing reads; hosted gates and independent review remain required | +| #629 | `0f4665b5` | releases provider work from database leases and bounds landing reads; hosted gates and independent review remain required | | #579 | `689a21b6` | delegates leftover interaction-map arithmetic to fast-mlsirm and persists only the consumer projection; hosted gates and independent review remain required | No row above is merge evidence. Immediately before any lifecycle action, From bdef6531e199642a4836bff0c591e34a40ac26a5 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 26 Aug 2026 00:10:04 +0900 Subject: [PATCH 005/393] docs: refresh exact-head queue snapshot --- docs/product-technical-gap-baseline.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 255a47ce5..24bb941e9 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,6 +1,6 @@ # Product & Technical Gap Baseline -> Dashboard delivery snapshot: 2026-08-26 00:05 KST. Protected `main` was +> Dashboard delivery snapshot: 2026-08-26 01:10 KST. Protected `main` was > `04e6b610655d0db91d5f7ba9486bdda1440e0b19`. This local branch is not > protected-main release evidence. @@ -66,14 +66,14 @@ only aggregate, non-identifying evidence to this repository. ### Exact open-PR boundary At this snapshot there were 7 open PRs and 10 open issues. Exact observed heads -were `#640 4677052c`, `#639 aee02dca`, `#636 eeeb23c6`, `#632 3851c7cf`, +were `#640 41527fa9`, `#639 aee02dca`, `#636 eeeb23c6`, `#632 bfeaecd9`, `#631 c0022c97`, `#629 0f4665b5`, and `#579 689a21b6`. All remain blocked on hosted gates and/or independent review. These observations are not merge readiness. Re-fetch exact heads, unresolved threads, checks, approvals, rulesets, and merge SHA before any lifecycle claim. -> Audit snapshot: 2026-08-26 00:05 KST (refreshed by the autonomous merge +> Audit snapshot: 2026-08-26 01:10 KST (refreshed by the autonomous merge > loop). This repository records synthetic fixtures and aggregate, > non-identifying runtime evidence only. Open PRs and local checks are not > protected-default-branch release evidence. Identifying post identifiers, @@ -90,10 +90,10 @@ context only. | PR | Exact observed head | Merge/check state at this snapshot | | ---: | --- | --- | -| #640 | `4677052c` | quantifies dashboard case metrics, preserves confidence-ranked project labels, and keeps scoped external evidence honest; hosted gates and independent review remain required | +| #640 | `41527fa9` | quantifies dashboard case metrics, preserves confidence-ranked project labels, and keeps scoped external evidence honest; hosted gates and independent review remain required | | #639 | `aee02dca` | repairs Running-action, Compose, and TEPP configuration contracts; hosted gates and independent review remain required | | #636 | `eeeb23c6` | publishes the calibrated external-lineage contract without a redundant explicit-child filter; hosted gates and independent review remain required | -| #632 | `3851c7cf` | preserves graph-fact source provenance and authorization with a static landing-query contract; hosted gates and independent review remain required | +| #632 | `bfeaecd9` | preserves graph-fact source provenance and authorization with a static landing-query contract; hosted gates and independent review remain required | | #631 | `c0022c97` | decomposes ADR gaps and queue baseline; hosted gates and independent review remain required | | #629 | `0f4665b5` | releases provider work from database leases and bounds landing reads; hosted gates and independent review remain required | | #579 | `689a21b6` | delegates leftover interaction-map arithmetic to fast-mlsirm and persists only the consumer projection; hosted gates and independent review remain required | From 686cc5d26b38a01301830b9bdc0a28e877d9d47e Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 26 Aug 2026 00:10:03 +0900 Subject: [PATCH 006/393] fix: scope external dashboard metrics --- docs/product-technical-gap-baseline.md | 10 +++++----- .../src/components/OperationsDashboard.stories.tsx | 2 ++ frontend/src/components/OperationsDashboard.test.tsx | 2 ++ frontend/src/components/OperationsDashboard.tsx | 4 ++-- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 24bb941e9..270231981 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -55,9 +55,9 @@ card overflow. Narrow inspection showed two-column metrics, readable cards and scrollable. No identifying runtime record or screenshot is committed. The `EvidenceReady`, `NarrowViewport`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` scenes cover the ADR 0206 state inventory. -The exact #640 head `4677052c` adds `ExternalInformationEmpty`; its Storybook +The current #640 candidate adds `ExternalInformationEmpty`; its Storybook build was inspected at 1440×1000 and 390×844 and exposes neither corpus-wide -pending/failed counts nor a misleading corpus failure alert in that scoped +total/pending/failed counts nor a misleading corpus failure alert in that scoped destination. Screenshots remain local synthetic audit evidence and are not committed. Authenticated authorized-corpus acceptance remains separate and may return @@ -66,7 +66,7 @@ only aggregate, non-identifying evidence to this repository. ### Exact open-PR boundary At this snapshot there were 7 open PRs and 10 open issues. Exact observed heads -were `#640 41527fa9`, `#639 aee02dca`, `#636 eeeb23c6`, `#632 bfeaecd9`, +were `#640 41527fa9` (this branch's observed parent), `#639 aee02dca`, `#636 eeeb23c6`, `#632 bfeaecd9`, `#631 c0022c97`, `#629 0f4665b5`, and `#579 689a21b6`. All remain blocked on hosted gates and/or independent review. These observations are not merge readiness. Re-fetch exact heads, @@ -90,10 +90,10 @@ context only. | PR | Exact observed head | Merge/check state at this snapshot | | ---: | --- | --- | -| #640 | `41527fa9` | quantifies dashboard case metrics, preserves confidence-ranked project labels, and keeps scoped external evidence honest; hosted gates and independent review remain required | +| #640 | `41527fa9` (observed parent) | this row is updated by #640 itself, so its exact head advances after the snapshot is encoded; it quantifies dashboard case metrics, preserves confidence-ranked project labels, and keeps scoped external evidence honest; hosted gates and independent review remain required | | #639 | `aee02dca` | repairs Running-action, Compose, and TEPP configuration contracts; hosted gates and independent review remain required | | #636 | `eeeb23c6` | publishes the calibrated external-lineage contract without a redundant explicit-child filter; hosted gates and independent review remain required | -| #632 | `bfeaecd9` | preserves graph-fact source provenance and authorization with a static landing-query contract; hosted gates and independent review remain required | +| #632 | `bfeaecd9` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required | | #631 | `c0022c97` | decomposes ADR gaps and queue baseline; hosted gates and independent review remain required | | #629 | `0f4665b5` | releases provider work from database leases and bounds landing reads; hosted gates and independent review remain required | | #579 | `689a21b6` | delegates leftover interaction-map arithmetic to fast-mlsirm and persists only the consumer projection; hosted gates and independent review remain required | diff --git a/frontend/src/components/OperationsDashboard.stories.tsx b/frontend/src/components/OperationsDashboard.stories.tsx index 4c00c8038..c9093993a 100644 --- a/frontend/src/components/OperationsDashboard.stories.tsx +++ b/frontend/src/components/OperationsDashboard.stories.tsx @@ -47,6 +47,8 @@ export const ExternalInformationEmpty: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByRole("status")).toHaveTextContent("분류된 외부 정보가 없습니다"); + await expect(canvas.queryByText("전체 글")).not.toBeInTheDocument(); + await expect(canvas.queryByText("분류 Event")).not.toBeInTheDocument(); await expect(canvas.queryByText("분석 대기")).not.toBeInTheDocument(); await expect(canvas.queryByText("분석 실패")).not.toBeInTheDocument(); }, diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx index 59057b0a1..7124d7ca7 100644 --- a/frontend/src/components/OperationsDashboard.test.tsx +++ b/frontend/src/components/OperationsDashboard.test.tsx @@ -46,6 +46,8 @@ describe("OperationsDashboardView", () => { expect(screen.getByRole("status")).toHaveTextContent("기간이나 접근 범위를 확인하세요"); expect(screen.queryByText("분석 대기")).not.toBeInTheDocument(); expect(screen.queryByText("분석 실패")).not.toBeInTheDocument(); + expect(screen.queryByText("전체 글")).not.toBeInTheDocument(); + expect(screen.queryByText("분류 Event")).not.toBeInTheDocument(); }); it("places multi-project evidence in every explicit journey and orders events oldest first", () => { diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx index 72fa65312..810795a00 100644 --- a/frontend/src/components/OperationsDashboard.tsx +++ b/frontend/src/components/OperationsDashboard.tsx @@ -66,8 +66,8 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost

수치를 선택하면 근거 글에서 다음 조치를 확인할 수 있습니다.

-
전체 글
{data.total_post_count}
-
분류 Event
{data.total_event_count}
+ {!externalOnly ?
전체 글
{data.total_post_count}
: null} + {!externalOnly ?
분류 Event
{data.total_event_count}
: null}
외부 정보
{data.external_post_count}건 · {data.external_percent.toFixed(1)}%
{!externalOnly ?
분석 대기
{data.pending_analysis_count}
: null} {!externalOnly ?
분석 실패
{data.failed_analysis_count}
: null} From 68e52a8b94c9b9be805db1cfb4979b63cab3b848 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 26 Aug 2026 00:11:58 +0900 Subject: [PATCH 007/393] docs(ui): align analyst destination count --- frontend/src/gnbChrome.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/gnbChrome.ts b/frontend/src/gnbChrome.ts index f5c126648..2067ecadb 100644 --- a/frontend/src/gnbChrome.ts +++ b/frontend/src/gnbChrome.ts @@ -1,4 +1,4 @@ -/** Analyst GNB chrome: four Korean destinations, no Buyer/Cubee labels. */ +/** Analyst GNB chrome: six Korean destinations, no Buyer/Cubee labels. */ export const ANALYST_GNB_ITEMS = [ { id: "dashboard", label: "Dashboard" }, From f9a7a78394a3ef566ea5fd11b0018fcef3c17b68 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 08:15:27 -0700 Subject: [PATCH 008/393] feat(dashboard): persist unsupported required answers (#642) Co-authored-by: seonghobae --- backend/app/operations_case_ingestion.py | 5 +++ backend/app/operations_dashboard.py | 20 +++++++++++ .../adr/0206-evidence-operations-dashboard.md | 8 ++++- docs/product-technical-gap-baseline.md | 4 +-- docs/storybook-inventory.md | 2 +- frontend/src/api.ts | 1 + .../OperationsDashboard.stories.tsx | 18 +++++++--- .../components/OperationsDashboard.test.tsx | 2 ++ .../src/components/OperationsDashboard.tsx | 6 ++++ lineageweave/operations_case_analysis.py | 30 +++++++++++++--- .../0211_operations_case_missing_fact.sql | 13 +++++++ tests/test_operations_case_analysis.py | 34 +++++++++++++++---- tests/test_operations_case_ingestion.py | 23 +++++++++++++ tests/test_operations_dashboard.py | 11 +++++- tests/test_schema.py | 16 +++++++++ 15 files changed, 174 insertions(+), 19 deletions(-) create mode 100644 migrations/0211_operations_case_missing_fact.sql diff --git a/backend/app/operations_case_ingestion.py b/backend/app/operations_case_ingestion.py index a2a1fc84f..92cfc1fc8 100644 --- a/backend/app/operations_case_ingestion.py +++ b/backend/app/operations_case_ingestion.py @@ -61,3 +61,8 @@ async def persist_operations_cases( for ordinal, fact in enumerate(case.facts) ], ) + if case.missing_fact_type_codes: + await conn.executemany( + "insert into operations_case_missing_fact (post_id, case_kind_code, fact_type_code) values ($1, $2, $3)", + [(post_id, case.case_kind_code, code) for code in case.missing_fact_type_codes], + ) diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py index ca1b7d5cd..af610cf43 100644 --- a/backend/app/operations_dashboard.py +++ b/backend/app/operations_dashboard.py @@ -148,6 +148,16 @@ async def fetch_operations_dashboard( """, *args, ) + missing_rows = await conn.fetch( + f""" + select missing.post_id, missing.case_kind_code, missing.fact_type_code + from operations_case_missing_fact missing + join source_post post on post.post_id = missing.post_id + where {visible} + order by missing.post_id, missing.case_kind_code, missing.fact_type_code + """, + *args, + ) facts: dict[tuple[str, str], list[dict[str, str]]] = {} for row in fact_rows: key = (str(row["post_id"]), row["case_kind_code"]) @@ -160,6 +170,15 @@ async def fetch_operations_dashboard( "evidence_post_id": str(row["evidence_post_id"]), } ) + missing_facts: dict[tuple[str, str], list[dict[str, str]]] = {} + for row in missing_rows: + key = (str(row["post_id"]), row["case_kind_code"]) + missing_facts.setdefault(key, []).append( + { + "fact_type_code": row["fact_type_code"], + "fact_type_label": FACT_TYPE_LABELS[row["fact_type_code"]], + } + ) total = int(metrics["total_post_count"]) external = int(metrics["external_post_count"]) case_post_ids: dict[str, set[str]] = {} @@ -197,6 +216,7 @@ async def fetch_operations_dashboard( "evidence_post_id": str(row["evidence_post_id"]), "occurred_at": row["occurred_at"].isoformat(), "facts": facts.get((str(row["post_id"]), row["case_kind_code"]), []), + "missing_facts": missing_facts.get((str(row["post_id"]), row["case_kind_code"]), []), } for row in case_rows ], diff --git a/docs/adr/0206-evidence-operations-dashboard.md b/docs/adr/0206-evidence-operations-dashboard.md index 3f9f5505d..ce6a8c2ee 100644 --- a/docs/adr/0206-evidence-operations-dashboard.md +++ b/docs/adr/0206-evidence-operations-dashboard.md @@ -65,8 +65,14 @@ provenance. post evidence. 8. Claim-investigation and rebid/handover panels include positively classified cases and show extracted answers plus cited spans. A required answer that - the source does not support is stored as an explicit missing fact, so the + the source does not support is stored in the normalized + `operations_case_missing_fact` relation as an explicit missing fact, so the next action is collection or human correction rather than keyword guessing. + A provider result is invalid unless every required question is represented + exactly once as either a cited supported fact or an explicit missing fact; + a fact cannot be both. Missing facts carry no invented value or evidence + span and inherit the analysis run and authorized-source boundary through + their classification parent. 9. Project journeys group events only by an explicit source project or stored semantic project mention. A multi-project post may appear in multiple journeys. Unbound events remain visible as unassigned evidence and are not diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 270231981..f73925b70 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -8,8 +8,8 @@ | Requirement | Evidence contract | Delivery state | |---|---|---| -| Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification with cited spans; Event Lineage context | Candidate API/UI now reports separate per-kind Event and distinct-post counts beside cited qualitative facts; authenticated runtime acceptance pending | -| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus persisted summary actions/roles | Candidate API/UI now reports separate per-kind Event and distinct-post counts; corpus backfill pending | +| Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification with cited spans; Event Lineage context | Candidate API/UI reports separate per-kind Event and distinct-post counts; every required answer is cited or stored as an explicit missing fact with a collection action; authenticated runtime acceptance pending | +| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus persisted summary actions/roles | Candidate API/UI reports separate per-kind Event and distinct-post counts; every required answer is cited or stored as an explicit missing fact; corpus backfill pending | | External information count/rate and sales/project relation | ADR 0206; semantic `external_information` classification inside Dashboard GNB | Candidate GNB destination filters the Dashboard to external evidence; no separate Board by product decision; authenticated runtime acceptance pending | | Project-specific journey | Explicit source/semantic project membership plus event-time ordering | Candidate API preserves every explicit project membership and the UI orders each journey chronologically; authenticated runtime acceptance pending | | Repeat issue to design improvement | `repeat_issue`, `issue_pattern`, and `improvement_action` cited facts | Candidate semantic contract; design-system connector acceptance pending | diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 3716656cd..3559cf139 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -5,7 +5,7 @@ operator-facing control you can click before changing product CSS. | Story | Operator next action | Token / module | |---|---|---| -| `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, or repeat-issue fact. `EvidenceReady`, `NarrowViewport`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover populated, mobile, unavailable-evidence, analysis-pending, retryable failure, and transport-error states. | `--color-dashboard-*`, `OperationsDashboard` | +| `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, or repeat-issue fact. `EvidenceReady`, `NarrowViewport`, `RequiredFactMissing`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover populated, mobile, explicit evidence-absence, analysis-pending, retryable failure, and transport-error states. | `--color-dashboard-*`, `OperationsDashboard` | | `Post/SimilarVocPanel` | Compare ontology/semantic similar VOC and prior action evidence, then open the source; unavailable states show no fabricated TEPP theta or weight. | `SimilarVocPanel.css`, `SimilarVocPanel` | | `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` | | `Evidence/OrganizationAliasChip` | Click a cataloged org; the parenthetical is the unique corroborated SKOS companion. | `--color-chip-border`, `--radius-chip`, `OrganizationAliasChip` | diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 60c8b0c6f..c63436b9f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -60,6 +60,7 @@ export interface OperationsDashboardCase { evidence_post_id: string; occurred_at: string; facts: OperationsDashboardFact[]; + missing_facts: Array<{ fact_type_code: string; fact_type_label: string }>; } export interface OperationsDashboardResponse { diff --git a/frontend/src/components/OperationsDashboard.stories.tsx b/frontend/src/components/OperationsDashboard.stories.tsx index c9093993a..560bbbe33 100644 --- a/frontend/src/components/OperationsDashboard.stories.tsx +++ b/frontend/src/components/OperationsDashboard.stories.tsx @@ -20,10 +20,10 @@ export const EvidenceReady: Story = { ], failed_analysis_count: 0, cases: [ - { post_id: "synthetic-post-1", case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 역추적", project_name: "Synthetic Transformer Renewal", summary_text: "사양 변경 이후 원인 수주와 Pool을 확인", evidence_text: "Revision B originated in order SO-100 from pool SP-20.", evidence_post_id: "synthetic-post-1", occurred_at: "2026-08-04T00:00:00Z", facts: [{ fact_type_code: "originating_order", fact_type_label: "원인 수주", value_text: "SO-100 · SP-20", evidence_text: "order SO-100 from pool SP-20", evidence_post_id: "synthetic-post-1" }] }, - { post_id: "synthetic-post-2", case_kind_code: "rebid_handover", case_kind_label: "재입찰 · 인수인계", project_name: "Synthetic Transformer Renewal", summary_text: "담당자 교체 전 협의와 후속 결정을 연결", evidence_text: "The account owner and design lead agreed to submit the revised proposal.", evidence_post_id: "synthetic-post-2", occurred_at: "2026-08-11T00:00:00Z", facts: [{ fact_type_code: "decision", fact_type_label: "이어진 결정", value_text: "수정 제안 제출", evidence_text: "submit the revised proposal", evidence_post_id: "synthetic-post-2" }] }, - { post_id: "synthetic-post-3", case_kind_code: "external_information", case_kind_label: "외부 정보", project_name: "Synthetic Transformer Renewal", summary_text: "시장 공고를 영업 기회와 연결", evidence_text: "The public procurement notice opened on August 15.", evidence_post_id: "synthetic-post-3", occurred_at: "2026-08-15T00:00:00Z", facts: [{ fact_type_code: "business_relation", fact_type_label: "사업 관계", value_text: "갱신 제안 준비", evidence_text: "procurement notice", evidence_post_id: "synthetic-post-3" }] }, - { post_id: "synthetic-post-4", case_kind_code: "repeat_issue", case_kind_label: "반복 이슈 반영", project_name: "Synthetic Transformer Renewal", summary_text: "동일 유형 이슈를 설계 개선으로 환류", evidence_text: "The same enclosure issue recurred after Revision B.", evidence_post_id: "synthetic-post-4", occurred_at: "2026-08-18T00:00:00Z", facts: [{ fact_type_code: "improvement_action", fact_type_label: "개선 과제", value_text: "표준 사양 개정", evidence_text: "Update the standard enclosure specification.", evidence_post_id: "synthetic-post-4" }] }, + { post_id: "synthetic-post-1", case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 역추적", project_name: "Synthetic Transformer Renewal", summary_text: "사양 변경 이후 원인 수주와 Pool을 확인", evidence_text: "Revision B originated in order SO-100 from pool SP-20.", evidence_post_id: "synthetic-post-1", occurred_at: "2026-08-04T00:00:00Z", facts: [{ fact_type_code: "originating_order", fact_type_label: "원인 수주", value_text: "SO-100 · SP-20", evidence_text: "order SO-100 from pool SP-20", evidence_post_id: "synthetic-post-1" }], missing_facts: [{ fact_type_code: "order", fact_type_label: "발생 수주" }, { fact_type_code: "specification_change", fact_type_label: "사양 변경" }, { fact_type_code: "sales_pool", fact_type_label: "수주 Pool" }] }, + { post_id: "synthetic-post-2", case_kind_code: "rebid_handover", case_kind_label: "재입찰 · 인수인계", project_name: "Synthetic Transformer Renewal", summary_text: "담당자 교체 전 협의와 후속 결정을 연결", evidence_text: "The account owner and design lead agreed to submit the revised proposal.", evidence_post_id: "synthetic-post-2", occurred_at: "2026-08-11T00:00:00Z", facts: [{ fact_type_code: "decision", fact_type_label: "이어진 결정", value_text: "수정 제안 제출", evidence_text: "submit the revised proposal", evidence_post_id: "synthetic-post-2" }], missing_facts: [{ fact_type_code: "discussion", fact_type_label: "협의 내용" }, { fact_type_code: "counterparty", fact_type_label: "협의 상대" }, { fact_type_code: "our_owner", fact_type_label: "우리측 담당자" }] }, + { post_id: "synthetic-post-3", case_kind_code: "external_information", case_kind_label: "외부 정보", project_name: "Synthetic Transformer Renewal", summary_text: "시장 공고를 영업 기회와 연결", evidence_text: "The public procurement notice opened on August 15.", evidence_post_id: "synthetic-post-3", occurred_at: "2026-08-15T00:00:00Z", facts: [{ fact_type_code: "external_relation", fact_type_label: "업무 관계", value_text: "갱신 제안 준비", evidence_text: "procurement notice", evidence_post_id: "synthetic-post-3" }], missing_facts: [] }, + { post_id: "synthetic-post-4", case_kind_code: "repeat_issue", case_kind_label: "반복 이슈 반영", project_name: "Synthetic Transformer Renewal", summary_text: "동일 유형 이슈를 설계 개선으로 환류", evidence_text: "The same enclosure issue recurred after Revision B.", evidence_post_id: "synthetic-post-4", occurred_at: "2026-08-18T00:00:00Z", facts: [{ fact_type_code: "improvement_action", fact_type_label: "개선 과제", value_text: "표준 사양 개정", evidence_text: "Update the standard enclosure specification.", evidence_post_id: "synthetic-post-4" }], missing_facts: [{ fact_type_code: "issue_pattern", fact_type_label: "반복 유형" }] }, ], }, onOpenPost: () => undefined, @@ -54,6 +54,16 @@ export const ExternalInformationEmpty: Story = { }, }; +export const RequiredFactMissing: Story = { + args: { + data: { ...EvidenceReady.args!.data!, cases: [EvidenceReady.args!.data!.cases[0]] }, + onOpenPost: () => undefined, + }, + play: async ({ canvasElement }) => { + await expect(within(canvasElement).getByText(/수주 Pool: 권한 범위 내 근거가 없습니다/)).toBeVisible(); + }, +}; + export const AnalysisPendingAndMissingEvidence: Story = { args: { data: { ...EvidenceReady.args!.data!, total_event_count: 0, pending_analysis_count: 3, cases: [] }, diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx index 7124d7ca7..a3947e2a2 100644 --- a/frontend/src/components/OperationsDashboard.test.tsx +++ b/frontend/src/components/OperationsDashboard.test.tsx @@ -25,6 +25,7 @@ const data = { post_id: "post-1", case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 역추적", project_name: "Synthetic Grid Upgrade", summary_text: "사양 변경 이후 원인 수주를 확인했습니다.", evidence_text: "Revision B changed the enclosure.", evidence_post_id: "evidence-post-1", occurred_at: "2026-08-12T00:00:00Z", facts: [{ fact_type_code: "originating_order", fact_type_label: "원인 수주", value_text: "ORDER-100", evidence_text: "Original order ORDER-100", evidence_post_id: "evidence-post-2" }], + missing_facts: [{ fact_type_code: "sales_pool", fact_type_label: "수주 Pool" }], }], }; @@ -35,6 +36,7 @@ describe("OperationsDashboardView", () => { expect(screen.getByText("3 Event · 2글")).toBeInTheDocument(); expect(screen.getByText("5건 · 25.0%")).toBeInTheDocument(); expect(screen.getByText("원인 수주")).toBeInTheDocument(); + expect(screen.getByText(/수주 Pool: 권한 범위 내 근거가 없습니다/)).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "분류 근거 글 열기" })); expect(onOpenPost).toHaveBeenCalledWith("evidence-post-1"); await userEvent.click(screen.getByRole("button", { name: "원인 수주 근거 열기" })); diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx index 810795a00..cd739c0d6 100644 --- a/frontend/src/components/OperationsDashboard.tsx +++ b/frontend/src/components/OperationsDashboard.tsx @@ -112,6 +112,12 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost

{item.summary_text}

{item.evidence_text}
{item.facts.map((fact) =>
{fact.fact_type_label}
{fact.value_text}
)}
+ {item.missing_facts.length ? ( +
+

추가 확인 필요

+
    {item.missing_facts.map((fact) =>
  • {fact.fact_type_label}: 권한 범위 내 근거가 없습니다. 관련 원문을 연결하세요.
  • )}
+
+ ) : null} ))} diff --git a/lineageweave/operations_case_analysis.py b/lineageweave/operations_case_analysis.py index 43decd006..5e4c46fde 100644 --- a/lineageweave/operations_case_analysis.py +++ b/lineageweave/operations_case_analysis.py @@ -19,6 +19,12 @@ "issue_pattern", "improvement_action", } ) +REQUIRED_FACT_TYPES = { + "claim_investigation": frozenset({"order", "specification_change", "originating_order", "sales_pool"}), + "rebid_handover": frozenset({"discussion", "counterparty", "our_owner", "decision"}), + "external_information": frozenset({"external_relation"}), + "repeat_issue": frozenset({"issue_pattern", "improvement_action"}), +} @dataclass(frozen=True) @@ -42,6 +48,7 @@ class OperationsCase: facts: tuple[OperationsCaseFact, ...] evidence_post_id: str = "" evidence_input_sha256: str = "" + missing_fact_type_codes: tuple[str, ...] = () @dataclass(frozen=True) @@ -88,8 +95,14 @@ def analyze( evidence_post_id, evidence_text (a verbatim span from that numbered source), and facts. Each fact has fact_type_code (one of order, specification_change, originating_order, sales_pool, discussion, counterparty, our_owner, decision, external_relation, -issue_pattern, improvement_action), value_text, evidence_post_id, and evidence_text (a verbatim span from that source). Return [] only when the -record supports none of the case kinds. Never fill an unsupported fact. +issue_pattern, improvement_action), value_text, evidence_post_id, and evidence_text (a verbatim span from that source). +Each item must also have missing_fact_type_codes. Put every required fact type for that case +that is not supported anywhere in the authorized sources in this array; never invent a value or +evidence span for it. Required types are: claim_investigation = order, +specification_change, originating_order, sales_pool; rebid_handover = discussion, +counterparty, our_owner, decision; external_information = external_relation; +repeat_issue = issue_pattern, improvement_action. Return [] only when the record supports none +of the case kinds. Stored context (hints, not proof): {context} Authorized numbered sources: @@ -123,8 +136,9 @@ def parse_operations_case_response( evidence = item.get("evidence_text") evidence_post_id = item.get("evidence_post_id") or ("focal" if legacy_focal else None) facts = item.get("facts") + missing_fact_types = item.get("missing_fact_type_codes") evidence_source = sources_by_id.get(evidence_post_id) - if not isinstance(summary, str) or not summary.strip() or not isinstance(evidence, str) or not evidence.strip() or evidence_source is None or evidence not in evidence_source.text or not isinstance(facts, list): + if not isinstance(summary, str) or not summary.strip() or not isinstance(evidence, str) or not evidence.strip() or evidence_source is None or evidence not in evidence_source.text or not isinstance(facts, list) or not isinstance(missing_fact_types, list): return None parsed_facts: list[OperationsCaseFact] = [] for fact in facts: @@ -137,7 +151,15 @@ def parse_operations_case_response( if not isinstance(value, str) or not value.strip() or not isinstance(fact_evidence, str) or not fact_evidence.strip() or fact_source is None or fact_evidence not in fact_source.text: return None parsed_facts.append(OperationsCaseFact(fact["fact_type_code"], value.strip(), fact_evidence, fact_source.post_id, fact_source.input_sha256)) - cases.append(OperationsCase(item["case_kind_code"], summary.strip(), evidence, tuple(parsed_facts), evidence_source.post_id, evidence_source.input_sha256)) + supported_types = {fact.fact_type_code for fact in parsed_facts} + if ( + any(not isinstance(code, str) or code not in FACT_TYPES for code in missing_fact_types) + or len(set(missing_fact_types)) != len(missing_fact_types) + or supported_types.intersection(missing_fact_types) + or supported_types.union(missing_fact_types) != REQUIRED_FACT_TYPES[item["case_kind_code"]] + ): + return None + cases.append(OperationsCase(item["case_kind_code"], summary.strip(), evidence, tuple(parsed_facts), evidence_source.post_id, evidence_source.input_sha256, tuple(missing_fact_types))) return tuple(cases) diff --git a/migrations/0211_operations_case_missing_fact.sql b/migrations/0211_operations_case_missing_fact.sql new file mode 100644 index 000000000..a72edcb55 --- /dev/null +++ b/migrations/0211_operations_case_missing_fact.sql @@ -0,0 +1,13 @@ +-- ADR 0206: unsupported required answers remain explicit without fabricated evidence. +create table if not exists operations_case_missing_fact ( + post_id uuid not null, + case_kind_code text not null, + fact_type_code text not null check (fact_type_code in ('order', 'specification_change', 'originating_order', 'sales_pool', 'discussion', 'counterparty', 'our_owner', 'decision', 'external_relation', 'issue_pattern', 'improvement_action')), + primary key (post_id, case_kind_code, fact_type_code), + foreign key (post_id, case_kind_code) + references operations_case_classification(post_id, case_kind_code) + on delete cascade +); + +create index if not exists operations_case_missing_fact_kind_idx + on operations_case_missing_fact (case_kind_code, fact_type_code, post_id); diff --git a/tests/test_operations_case_analysis.py b/tests/test_operations_case_analysis.py index 312189879..ebfdb20fa 100644 --- a/tests/test_operations_case_analysis.py +++ b/tests/test_operations_case_analysis.py @@ -9,8 +9,8 @@ def test_parses_multiple_cases_and_grounded_facts() -> None: """One record may support multiple case kinds without losing evidence.""" body = "The revised specification caused the claim. Mina agreed with Alex to rebid." payload = [ - {"case_kind_code": "claim_investigation", "summary_text": "Specification-linked claim", "evidence_text": "The revised specification caused the claim.", "facts": [{"fact_type_code": "specification_change", "value_text": "revised specification", "evidence_text": "The revised specification caused the claim."}]}, - {"case_kind_code": "rebid_handover", "summary_text": "Rebid agreement", "evidence_text": "Mina agreed with Alex to rebid.", "facts": [{"fact_type_code": "counterparty", "value_text": "Mina and Alex", "evidence_text": "Mina agreed with Alex to rebid."}]}, + {"case_kind_code": "claim_investigation", "summary_text": "Specification-linked claim", "evidence_text": "The revised specification caused the claim.", "facts": [{"fact_type_code": "specification_change", "value_text": "revised specification", "evidence_text": "The revised specification caused the claim."}], "missing_fact_type_codes": ["order", "originating_order", "sales_pool"]}, + {"case_kind_code": "rebid_handover", "summary_text": "Rebid agreement", "evidence_text": "Mina agreed with Alex to rebid.", "facts": [{"fact_type_code": "counterparty", "value_text": "Mina and Alex", "evidence_text": "Mina agreed with Alex to rebid."}], "missing_fact_type_codes": ["discussion", "our_owner", "decision"]}, ] result = parse_operations_case_response(json.dumps(payload), body) assert result is not None @@ -19,7 +19,7 @@ def test_parses_multiple_cases_and_grounded_facts() -> None: def test_rejects_uncited_model_claim() -> None: """A plausible answer absent from the source is not persisted.""" - payload = [{"case_kind_code": "external_information", "summary_text": "Market note", "evidence_text": "invented", "facts": []}] + payload = [{"case_kind_code": "external_information", "summary_text": "Market note", "evidence_text": "invented", "facts": [], "missing_fact_type_codes": ["external_relation"]}] assert parse_operations_case_response(json.dumps(payload), "source body") is None @@ -37,11 +37,11 @@ def test_rejects_unknown_codes_and_malformed_json() -> None: def test_rejects_duplicate_case_kinds_and_blank_evidence() -> None: """One normalized key has one grounded classification, never an empty span.""" duplicate = [ - {"case_kind_code": "repeat_issue", "summary_text": "First", "evidence_text": "body", "facts": []}, - {"case_kind_code": "repeat_issue", "summary_text": "Second", "evidence_text": "body", "facts": []}, + {"case_kind_code": "repeat_issue", "summary_text": "First", "evidence_text": "body", "facts": [], "missing_fact_type_codes": ["issue_pattern", "improvement_action"]}, + {"case_kind_code": "repeat_issue", "summary_text": "Second", "evidence_text": "body", "facts": [], "missing_fact_type_codes": ["issue_pattern", "improvement_action"]}, ] blank = [ - {"case_kind_code": "repeat_issue", "summary_text": "Blank", "evidence_text": "", "facts": []} + {"case_kind_code": "repeat_issue", "summary_text": "Blank", "evidence_text": "", "facts": [], "missing_fact_type_codes": ["issue_pattern", "improvement_action"]} ] assert parse_operations_case_response(json.dumps(duplicate), "body") is None assert parse_operations_case_response(json.dumps(blank), "body") is None @@ -64,6 +64,7 @@ def test_linked_fact_retains_its_authorized_source_post_and_input_digest() -> No "evidence_post_id": "linked", "evidence_text": "Specification S2 replaced S1.", }], + "missing_fact_type_codes": ["order", "originating_order", "sales_pool"], }] result = parse_operations_case_response(json.dumps(payload), sources) @@ -73,3 +74,24 @@ def test_linked_fact_retains_its_authorized_source_post_and_input_digest() -> No assert result[0].facts[0].evidence_input_sha256 == sources[1].input_sha256 payload[0]["facts"][0]["evidence_post_id"] = "unauthorized" assert parse_operations_case_response(json.dumps(payload), sources) is None + + +def test_requires_each_case_question_to_be_supported_or_explicitly_missing() -> None: + """The provider cannot silently omit or both support and miss a required answer.""" + payload = [{ + "case_kind_code": "external_information", + "summary_text": "External notice", + "evidence_text": "A public notice was published.", + "facts": [], + "missing_fact_type_codes": [], + }] + body = "A public notice was published." + assert parse_operations_case_response(json.dumps(payload), body) is None + + payload[0]["facts"] = [{ + "fact_type_code": "external_relation", + "value_text": "Sales opportunity", + "evidence_text": body, + }] + payload[0]["missing_fact_type_codes"] = ["external_relation"] + assert parse_operations_case_response(json.dumps(payload), body) is None diff --git a/tests/test_operations_case_ingestion.py b/tests/test_operations_case_ingestion.py index d1270d5ca..a1d6f63f5 100644 --- a/tests/test_operations_case_ingestion.py +++ b/tests/test_operations_case_ingestion.py @@ -57,3 +57,26 @@ def test_persists_supported_empty_analysis() -> None: asyncio.run(persist_operations_cases(conn, "post-1", "ordinary", "session-1", ())) assert len(conn.calls) == 2 assert conn.batches == [] + + +def test_persists_missing_required_facts_without_invented_evidence() -> None: + """Unsupported answers use the normalized missing-fact relation only.""" + conn = _Connection() + case = OperationsCase( + "claim_investigation", + "Claim", + "source", + (), + "post-1", + "a" * 64, + ("order", "specification_change", "originating_order", "sales_pool"), + ) + + asyncio.run(persist_operations_cases(conn, "post-1", "source", "session-1", (case,))) + + assert conn.batches == [[ + ("post-1", "claim_investigation", "order"), + ("post-1", "claim_investigation", "specification_change"), + ("post-1", "claim_investigation", "originating_order"), + ("post-1", "claim_investigation", "sales_pool"), + ]] diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py index a9ed05915..d3a6ae43e 100644 --- a/tests/test_operations_dashboard.py +++ b/tests/test_operations_dashboard.py @@ -37,6 +37,12 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: "fact_ordinal": 0, } ] + if "operations_case_missing_fact missing" in query: + return [{ + "post_id": "00000000-0000-0000-0000-000000000001", + "case_kind_code": "claim_investigation", + "fact_type_code": "sales_pool", + }] return [ { "post_id": "00000000-0000-0000-0000-000000000001", @@ -113,9 +119,12 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None: "evidence_post_id": "00000000-0000-0000-0000-000000000002", } ], + "missing_facts": [ + {"fact_type_code": "sales_pool", "fact_type_label": "수주 Pool"} + ], } ] - assert len(conn.queries) == 3 + assert len(conn.queries) == 4 for query, args in conn.queries: assert "visibility_code = 'public'" in query assert "corporate_entity_id::text = any($1::text[])" in query diff --git a/tests/test_schema.py b/tests/test_schema.py index d88d07bd2..ab0a2818c 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -86,6 +86,15 @@ / "migrations" / "0182_report_leftover_map_unexplained.sql" ) +_OPERATIONS_CASE_MIGRATION = ( + Path(__file__).resolve().parents[1] / "migrations" / "0208_operations_case_analysis.sql" +) +_OPERATIONS_CASE_EVIDENCE_MIGRATION = ( + Path(__file__).resolve().parents[1] / "migrations" / "0209_operations_case_evidence_source.sql" +) +_OPERATIONS_CASE_MISSING_MIGRATION = ( + Path(__file__).resolve().parents[1] / "migrations" / "0211_operations_case_missing_fact.sql" +) def _postgres_available() -> bool: @@ -131,6 +140,9 @@ def schema_db(): cur.execute(_LEFTOVER_MAP_UNEXPLAINED_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_CROSS_SHARE_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_RECONSTRUCTION_MIGRATION.read_text()) + cur.execute(_OPERATIONS_CASE_MIGRATION.read_text()) + cur.execute(_OPERATIONS_CASE_EVIDENCE_MIGRATION.read_text()) + cur.execute(_OPERATIONS_CASE_MISSING_MIGRATION.read_text()) conn.commit() yield conn finally: @@ -185,6 +197,10 @@ def test_migration_applies_cleanly(schema_db) -> None: "post_summary_action", "post_chat_result", "post_chat_citation", + "operations_case_analysis", + "operations_case_classification", + "operations_case_fact", + "operations_case_missing_fact", } assert expected <= tables From a42cd443861195aa4e05b9caee8000b37ae910ed Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 26 Aug 2026 00:20:51 +0900 Subject: [PATCH 009/393] docs: refresh exact open PR baseline --- docs/product-technical-gap-baseline.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index f73925b70..f6cd35a4e 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,6 +1,6 @@ # Product & Technical Gap Baseline -> Dashboard delivery snapshot: 2026-08-26 01:10 KST. Protected `main` was +> Dashboard delivery snapshot: 2026-08-26 (latest exact-head fetch). Protected `main` was > `04e6b610655d0db91d5f7ba9486bdda1440e0b19`. This local branch is not > protected-main release evidence. @@ -65,15 +65,15 @@ only aggregate, non-identifying evidence to this repository. ### Exact open-PR boundary -At this snapshot there were 7 open PRs and 10 open issues. Exact observed heads -were `#640 41527fa9` (this branch's observed parent), `#639 aee02dca`, `#636 eeeb23c6`, `#632 bfeaecd9`, +At this snapshot there were 8 open PRs and 10 open issues. Exact observed heads +were `#643 041ec13b`, `#640 f9a7a783`, `#639 aee02dca`, `#636 20d25fe6`, `#632 f3b5acfe`, `#631 c0022c97`, `#629 0f4665b5`, and `#579 689a21b6`. All remain blocked on hosted gates and/or independent review. These observations are not merge readiness. Re-fetch exact heads, unresolved threads, checks, approvals, rulesets, and merge SHA before any lifecycle claim. -> Audit snapshot: 2026-08-26 01:10 KST (refreshed by the autonomous merge +> Audit snapshot: 2026-08-26 (refreshed by the autonomous merge > loop). This repository records synthetic fixtures and aggregate, > non-identifying runtime evidence only. Open PRs and local checks are not > protected-default-branch release evidence. Identifying post identifiers, @@ -83,17 +83,18 @@ lifecycle claim. ## 1. Exact-head and governance evidence The protected default branch was `04e6b610655d0db91d5f7ba9486bdda1440e0b19` -when this baseline was refreshed. The live queue contained 7 open PRs and 10 +when this baseline was refreshed. The live queue contained 8 open PRs and 10 open issues. The exact-head inventory below supersedes older per-PR snapshots elsewhere in this document; those older rows remain useful historical delivery context only. | PR | Exact observed head | Merge/check state at this snapshot | | ---: | --- | --- | -| #640 | `41527fa9` (observed parent) | this row is updated by #640 itself, so its exact head advances after the snapshot is encoded; it quantifies dashboard case metrics, preserves confidence-ranked project labels, and keeps scoped external evidence honest; hosted gates and independent review remain required | +| #643 | `041ec13b` | shares token-backed success/unavailable/retry status notices for the Calendar surface; hosted gates and independent review remain required | +| #640 | `f9a7a783` | quantifies dashboard case metrics, preserves confidence-ranked project labels, and persists explicit missing required facts; hosted gates and independent review remain required | | #639 | `aee02dca` | repairs Running-action, Compose, and TEPP configuration contracts; hosted gates and independent review remain required | -| #636 | `eeeb23c6` | publishes the calibrated external-lineage contract without a redundant explicit-child filter; hosted gates and independent review remain required | -| #632 | `bfeaecd9` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required | +| #636 | `20d25fe6` | publishes the calibrated external-lineage contract without a redundant explicit-child filter; hosted gates and independent review remain required | +| #632 | `f3b5acfe` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required | | #631 | `c0022c97` | decomposes ADR gaps and queue baseline; hosted gates and independent review remain required | | #629 | `0f4665b5` | releases provider work from database leases and bounds landing reads; hosted gates and independent review remain required | | #579 | `689a21b6` | delegates leftover interaction-map arithmetic to fast-mlsirm and persists only the consumer projection; hosted gates and independent review remain required | From 7ec16daa2506927b3ad74540c6fba519c9145703 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 26 Aug 2026 00:26:04 +0900 Subject: [PATCH 010/393] docs: align baseline with current PR heads --- docs/product-technical-gap-baseline.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index f6cd35a4e..a9e404980 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -66,7 +66,7 @@ only aggregate, non-identifying evidence to this repository. ### Exact open-PR boundary At this snapshot there were 8 open PRs and 10 open issues. Exact observed heads -were `#643 041ec13b`, `#640 f9a7a783`, `#639 aee02dca`, `#636 20d25fe6`, `#632 f3b5acfe`, +were `#643 041ec13b`, `#640 a42cd443`, `#639 aee02dca`, `#636 20d25fe6`, `#632 e1ebe50a`, `#631 c0022c97`, `#629 0f4665b5`, and `#579 689a21b6`. All remain blocked on hosted gates and/or independent review. These observations are not merge readiness. Re-fetch exact heads, @@ -91,10 +91,10 @@ context only. | PR | Exact observed head | Merge/check state at this snapshot | | ---: | --- | --- | | #643 | `041ec13b` | shares token-backed success/unavailable/retry status notices for the Calendar surface; hosted gates and independent review remain required | -| #640 | `f9a7a783` | quantifies dashboard case metrics, preserves confidence-ranked project labels, and persists explicit missing required facts; hosted gates and independent review remain required | +| #640 | `a42cd443` | quantifies dashboard case metrics, preserves confidence-ranked project labels, and persists explicit missing required facts; hosted gates and independent review remain required | | #639 | `aee02dca` | repairs Running-action, Compose, and TEPP configuration contracts; hosted gates and independent review remain required | | #636 | `20d25fe6` | publishes the calibrated external-lineage contract without a redundant explicit-child filter; hosted gates and independent review remain required | -| #632 | `f3b5acfe` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required | +| #632 | `e1ebe50a` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required | | #631 | `c0022c97` | decomposes ADR gaps and queue baseline; hosted gates and independent review remain required | | #629 | `0f4665b5` | releases provider work from database leases and bounds landing reads; hosted gates and independent review remain required | | #579 | `689a21b6` | delegates leftover interaction-map arithmetic to fast-mlsirm and persists only the consumer projection; hosted gates and independent review remain required | From e6c0f2d3bdd0e28a5170a092bc2db49bffb60694 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 26 Aug 2026 00:28:36 +0900 Subject: [PATCH 011/393] docs: record exact Strix gate failure --- docs/product-technical-gap-baseline.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index a9e404980..01744a9d0 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -104,6 +104,14 @@ re-fetch the head, unresolved threads, formal reviews, rulesets, and same-head check conclusions. In particular, queued checks are infrastructure state and do not transfer evidence from an earlier SHA. +The exact-head Strix check for PR #631 (`c0022c97`) failed at run +`32855289561` with `STRIX_PROVIDER_UNAVAILABLE`; the check annotation reports +provider/backend unavailability rather than a repository vulnerability. The +historical workflow run is no longer retrievable through the Actions API, so it +cannot be rerun from that run id. This remains an unresolved hosted-gate +condition, not evidence that the PR is merge-ready; re-fetch a new exact-head +run before any merge claim. + PR #607 first merged as `61fd631c7bb3c57113fd19763c2c43161eeb2824` into #606's non-default branch. PR #606 subsequently passed the protected gate, so the combined TEPP-consumer and operations-dashboard implementation is now From 95491defb64effeb84fab1ad68e812005964f54e Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 26 Aug 2026 00:33:26 +0900 Subject: [PATCH 012/393] fix(dashboard): accept cited optional case facts --- lineageweave/operations_case_analysis.py | 4 +++- tests/test_operations_case_analysis.py | 25 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/lineageweave/operations_case_analysis.py b/lineageweave/operations_case_analysis.py index 5e4c46fde..73ac9f7c9 100644 --- a/lineageweave/operations_case_analysis.py +++ b/lineageweave/operations_case_analysis.py @@ -156,7 +156,9 @@ def parse_operations_case_response( any(not isinstance(code, str) or code not in FACT_TYPES for code in missing_fact_types) or len(set(missing_fact_types)) != len(missing_fact_types) or supported_types.intersection(missing_fact_types) - or supported_types.union(missing_fact_types) != REQUIRED_FACT_TYPES[item["case_kind_code"]] + or not REQUIRED_FACT_TYPES[item["case_kind_code"]].issubset( + supported_types.union(missing_fact_types) + ) ): return None cases.append(OperationsCase(item["case_kind_code"], summary.strip(), evidence, tuple(parsed_facts), evidence_source.post_id, evidence_source.input_sha256, tuple(missing_fact_types))) diff --git a/tests/test_operations_case_analysis.py b/tests/test_operations_case_analysis.py index ebfdb20fa..3a5cce2b4 100644 --- a/tests/test_operations_case_analysis.py +++ b/tests/test_operations_case_analysis.py @@ -95,3 +95,28 @@ def test_requires_each_case_question_to_be_supported_or_explicitly_missing() -> }] payload[0]["missing_fact_type_codes"] = ["external_relation"] assert parse_operations_case_response(json.dumps(payload), body) is None + + +def test_accepts_grounded_nonrequired_fact_after_required_questions_are_complete() -> None: + """A cited optional fact must not invalidate complete required answers.""" + body = "A public notice was published and assigned to the sales team." + payload = [{ + "case_kind_code": "external_information", + "summary_text": "External notice", + "evidence_text": "A public notice was published", + "facts": [ + { + "fact_type_code": "external_relation", + "value_text": "Sales opportunity", + "evidence_text": body, + }, + { + "fact_type_code": "our_owner", + "value_text": "Sales team", + "evidence_text": "assigned to the sales team", + }, + ], + "missing_fact_type_codes": [], + }] + + assert parse_operations_case_response(json.dumps(payload), body) is not None From 242b89e24a709b9f23ef133b4d45b1a54b8807ff Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 26 Aug 2026 00:35:59 +0900 Subject: [PATCH 013/393] test(load): exercise dashboard in authenticated k6 flow --- docs/operability/http-concurrency-evidence.md | 4 ++-- scripts/k6_http_e2e.js | 11 +++++++---- tests/test_k6_http_e2e_contract.py | 4 +++- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/operability/http-concurrency-evidence.md b/docs/operability/http-concurrency-evidence.md index ae3684142..1db0922a6 100644 --- a/docs/operability/http-concurrency-evidence.md +++ b/docs/operability/http-concurrency-evidence.md @@ -4,7 +4,7 @@ LineageWeave provides `scripts/k6_http_e2e.js` to measure the real Compose HTTP boundary while a synthetic Global Ask job is queued or running. It logs in through the seeded Keycloak realm, submits one non-identifying question to `POST /api/ask`, then drives concurrent authenticated requests to posts, -Event Lineage, and the Ask-status projection. +Event Lineage, the evidence Dashboard, and the Ask-status projection. This implements the measurement side of ADR 0204's resource-release decision: provider work is asynchronous, so ordinary readers should remain observable @@ -35,7 +35,7 @@ k6 reports observed request counts, failure rate, and duration distributions. The custom metrics separate: - `lineageweave_ask_enqueue_duration`: time to persist and acknowledge the job; -- `lineageweave_read_duration{endpoint:posts|lineage}`: ordinary reader paths; +- `lineageweave_read_duration{endpoint:posts|lineage|dashboard}`: ordinary reader paths; - `lineageweave_ask_poll_duration`: owner-scoped status polling. - `lineageweave_ask_state_observations{job_status:...}`: how many observations occurred while the one queued job was queued, running, or settled. diff --git a/scripts/k6_http_e2e.js b/scripts/k6_http_e2e.js index 737b2e09f..d5fac6a14 100644 --- a/scripts/k6_http_e2e.js +++ b/scripts/k6_http_e2e.js @@ -46,6 +46,7 @@ function readBatch(token, askJobId) { return http.batch([ ["GET", `${backendUrl}/api/posts`, null, { ...params, tags: { endpoint: "posts" } }], ["GET", `${backendUrl}/api/lineage`, null, { ...params, tags: { endpoint: "lineage" } }], + ["GET", `${backendUrl}/api/dashboard`, null, { ...params, tags: { endpoint: "dashboard" } }], [ "GET", `${backendUrl}/api/ask/jobs/${askJobId}`, @@ -80,13 +81,15 @@ export default function (data) { readDuration.add(responses[0].timings.duration, { endpoint: "posts" }); readDuration.add(responses[1].timings.duration, { endpoint: "lineage" }); - askPollDuration.add(responses[2].timings.duration); - if (responses[2].status === 200) { + readDuration.add(responses[2].timings.duration, { endpoint: "dashboard" }); + askPollDuration.add(responses[3].timings.duration); + if (responses[3].status === 200) { askStateObservations.add(1, { - job_status: String(responses[2].json("job_status_code") || "unknown"), + job_status: String(responses[3].json("job_status_code") || "unknown"), }); } check(responses[0], { "posts read succeeds": (response) => response.status === 200 }); check(responses[1], { "lineage read succeeds": (response) => response.status === 200 }); - check(responses[2], { "Ask poll succeeds": (response) => response.status === 200 }); + check(responses[2], { "dashboard read succeeds": (response) => response.status === 200 }); + check(responses[3], { "Ask poll succeeds": (response) => response.status === 200 }); } diff --git a/tests/test_k6_http_e2e_contract.py b/tests/test_k6_http_e2e_contract.py index f77d8f596..a825bd629 100644 --- a/tests/test_k6_http_e2e_contract.py +++ b/tests/test_k6_http_e2e_contract.py @@ -11,4 +11,6 @@ def test_k6_harness_renews_expired_auth_and_discloses_job_state() -> None: assert "responses.some((response) => response.status === 401)" in source assert source.count("responses = readBatch(vuToken, data.askJobId)") == 2 assert "lineageweave_ask_state_observations" in source - assert 'job_status: String(responses[2].json("job_status_code")' in source + assert 'job_status: String(responses[3].json("job_status_code")' in source + assert '["GET", `${backendUrl}/api/dashboard`' in source + assert 'endpoint: "dashboard"' in source From fae1d576d4d442faeb963f07d3595ece914ad4ff Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 26 Aug 2026 00:37:30 +0900 Subject: [PATCH 014/393] fix(dashboard): preserve event counts and external scope --- backend/app/main.py | 2 ++ backend/app/operations_dashboard.py | 26 ++++++++++++++++--- .../adr/0206-evidence-operations-dashboard.md | 9 +++++-- frontend/src/api.ts | 2 ++ .../components/OperationsDashboard.test.tsx | 13 ++++++++++ .../src/components/OperationsDashboard.tsx | 6 ++--- tests/test_operations_case_analysis.py | 21 +++++++++++++++ tests/test_operations_dashboard.py | 26 ++++++++++++++++--- 8 files changed, 92 insertions(+), 13 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index b53907977..8e2534017 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -776,6 +776,7 @@ async def read_me( async def operations_dashboard( period_start: date | None = Query(None), period_end: date | None = Query(None), + external_only: bool = Query(False), account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: @@ -789,6 +790,7 @@ async def operations_dashboard( account.process_unit_ids, period_start, period_end, + external_only, ) except ValueError as exc: raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py index af610cf43..a1007bcc9 100644 --- a/backend/app/operations_dashboard.py +++ b/backend/app/operations_dashboard.py @@ -60,11 +60,12 @@ async def fetch_operations_dashboard( process_unit_ids: tuple[str, ...] | list[str] = (), period_start: date | None = None, period_end: date | None = None, + external_only: bool = False, ) -> dict[str, Any]: """Return quantified cases and their persisted source evidence.""" if period_start and period_end and period_start > period_end: raise ValueError("period_start must not be after period_end") - args = (list(corporate_entity_ids), list(process_unit_ids), period_start, period_end) + args = (list(corporate_entity_ids), list(process_unit_ids), period_start, period_end, external_only) visible = _visible_period_sql() metrics = await conn.fetchrow( f""" @@ -72,13 +73,24 @@ async def fetch_operations_dashboard( select post.post_id from source_post post where {visible} + and ($5::boolean is false or exists ( + select 1 + from operations_case_classification scoped_classification + where scoped_classification.post_id = post.post_id + and scoped_classification.case_kind_code = 'external_information' + )) ), classified as ( select classification.post_id, classification.case_kind_code from operations_case_classification classification join visible_post on visible_post.post_id = classification.post_id ) select (select count(*) from visible_post) as total_post_count, - (select count(*) from classified) as total_event_count, + (select count(*) + from post_summary_event summary_event + where exists ( + select 1 from classified + where classified.post_id = summary_event.post_id + )) as total_event_count, (select count(distinct post_id) from classified where case_kind_code = 'external_information') as external_post_count, (select count(*) from visible_post @@ -107,7 +119,10 @@ async def fetch_operations_dashboard( coalesce(post.event_occurred_at, post.created_at) as occurred_at, coalesce(nullif(btrim(post.source_project_name), ''), project.primary_project_name) as project_name, - coalesce(project.project_names, array[]::text[]) as project_names + coalesce(project.project_names, array[]::text[]) as project_names, + (select count(*)::int + from post_summary_event summary_event + where summary_event.post_id = classification.post_id) as event_count from operations_case_classification classification join source_post post on post.post_id = classification.post_id left join lateral ( @@ -131,6 +146,7 @@ async def fetch_operations_dashboard( where names.project_name is not null ) project on true where {visible} + and ($5::boolean is false or classification.case_kind_code = 'external_information') order by coalesce(post.event_occurred_at, post.created_at) desc, classification.post_id, classification.case_kind_code """, @@ -144,6 +160,7 @@ async def fetch_operations_dashboard( from operations_case_fact fact join source_post post on post.post_id = fact.post_id where {visible} + and ($5::boolean is false or fact.case_kind_code = 'external_information') order by fact.post_id, fact.case_kind_code, fact.fact_ordinal """, *args, @@ -154,6 +171,7 @@ async def fetch_operations_dashboard( from operations_case_missing_fact missing join source_post post on post.post_id = missing.post_id where {visible} + and ($5::boolean is false or missing.case_kind_code = 'external_information') order by missing.post_id, missing.case_kind_code, missing.fact_type_code """, *args, @@ -186,7 +204,7 @@ async def fetch_operations_dashboard( for row in case_rows: kind = row["case_kind_code"] case_post_ids.setdefault(kind, set()).add(str(row["post_id"])) - case_event_counts[kind] = case_event_counts.get(kind, 0) + 1 + case_event_counts[kind] = case_event_counts.get(kind, 0) + int(row["event_count"]) return { "period_label": _period_label(period_start, period_end), "total_post_count": total, diff --git a/docs/adr/0206-evidence-operations-dashboard.md b/docs/adr/0206-evidence-operations-dashboard.md index ce6a8c2ee..3e10282d7 100644 --- a/docs/adr/0206-evidence-operations-dashboard.md +++ b/docs/adr/0206-evidence-operations-dashboard.md @@ -28,8 +28,11 @@ provenance. 2. Dashboard requests are bounded by an inclusive event-time period. `source_post.event_occurred_at` is the primary clock and `created_at` is the explicit fallback, matching ADR 0202. The response names that clock. -3. Every count is authorization-filtered before aggregation. The API returns - both event count and distinct post count; neither substitutes for the other. +3. Every count is authorization-filtered before aggregation. Event count is + the number of persisted `post_summary_event` rows for the classified, + visible posts; post count is the distinct count of those posts. Neither + substitutes for the other, and no event is invented when a summary event + row is absent. Analysis-pending and ingestion-failed post counts are disjoint: a failed current job is shown as retryable failure, never hidden inside the pending count or interpreted as a negative classification. @@ -50,6 +53,8 @@ provenance. visible posts in the same period. The stored `vom` source code is supplied to the orchestrator as labeled evidence, but does not replace semantic analysis. Zero total posts yields `0`. + The external destination passes an API scope so non-external counts and + case rows are excluded at the SQL boundary, not merely hidden in the UI. 7. Qualitative rows project only persisted evidence: project names and evidence spans, source sales-pool code/name, summary events, requester/processor action evidence, roles, and Event Lineage links. diff --git a/frontend/src/api.ts b/frontend/src/api.ts index c63436b9f..588d4ab3e 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -84,10 +84,12 @@ export function fetchOperationsDashboard( accessToken: string, periodStart = "", periodEnd = "", + externalOnly = false, ): Promise { const query = new URLSearchParams(); if (periodStart) query.set("period_start", periodStart); if (periodEnd) query.set("period_end", periodEnd); + if (externalOnly) query.set("external_only", "true"); const suffix = query.size ? `?${query}` : ""; return backendFetch(`/api/dashboard${suffix}`, accessToken); } diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx index a3947e2a2..bc7c561da 100644 --- a/frontend/src/components/OperationsDashboard.test.tsx +++ b/frontend/src/components/OperationsDashboard.test.tsx @@ -52,6 +52,12 @@ describe("OperationsDashboardView", () => { expect(screen.queryByText("분류 Event")).not.toBeInTheDocument(); }); + it("does not label a scoped external count with a corpus-wide rate", () => { + render( undefined} />); + expect(screen.getByText("5건")).toBeInTheDocument(); + expect(screen.queryByText("5건 · 25.0%")).not.toBeInTheDocument(); + }); + it("places multi-project evidence in every explicit journey and orders events oldest first", () => { const later = { ...data.cases[0], post_id: "post-later", occurred_at: "2026-08-20T00:00:00Z", project_names: ["Synthetic Grid Upgrade", "Synthetic Relay Renewal"] }; const earlier = { ...data.cases[0], post_id: "post-earlier", occurred_at: "2026-08-01T00:00:00Z", project_names: ["Synthetic Grid Upgrade"] }; @@ -81,4 +87,11 @@ describe("OperationsDashboardView", () => { expect(screen.getByLabelText("시작일")).toHaveValue("2026-08-01"); expect(screen.getByRole("status")).toHaveTextContent("불러오는 중"); }); + + it("requests the external scope at the API boundary", async () => { + vi.mocked(fetchOperationsDashboard).mockResolvedValue(data); + render( undefined} />); + await screen.findByText("5건"); + expect(fetchOperationsDashboard).toHaveBeenCalledWith("synthetic-token", "", "", true); + }); }); diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx index cd739c0d6..39fd75757 100644 --- a/frontend/src/components/OperationsDashboard.tsx +++ b/frontend/src/components/OperationsDashboard.tsx @@ -20,11 +20,11 @@ export function OperationsDashboard({ accessToken, externalOnly = false, onOpenP let active = true; setError(false); setData(null); - fetchOperationsDashboard(accessToken, ...submittedPeriod) + fetchOperationsDashboard(accessToken, ...submittedPeriod, externalOnly) .then((value) => active && setData(value)) .catch(() => active && setError(true)); return () => { active = false; }; - }, [accessToken, submittedPeriod, retryCount]); + }, [accessToken, externalOnly, submittedPeriod, retryCount]); return <>
{ @@ -68,7 +68,7 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
{!externalOnly ?
전체 글
{data.total_post_count}
: null} {!externalOnly ?
분류 Event
{data.total_event_count}
: null} -
외부 정보
{data.external_post_count}건 · {data.external_percent.toFixed(1)}%
+
외부 정보
{data.external_post_count}건{externalOnly ? "" : ` · ${data.external_percent.toFixed(1)}%`}
{!externalOnly ?
분석 대기
{data.pending_analysis_count}
: null} {!externalOnly ?
분석 실패
{data.failed_analysis_count}
: null}
diff --git a/tests/test_operations_case_analysis.py b/tests/test_operations_case_analysis.py index 3a5cce2b4..3aa3ebf1c 100644 --- a/tests/test_operations_case_analysis.py +++ b/tests/test_operations_case_analysis.py @@ -88,6 +88,27 @@ def test_requires_each_case_question_to_be_supported_or_explicitly_missing() -> body = "A public notice was published." assert parse_operations_case_response(json.dumps(payload), body) is None + +def test_accepts_additional_grounded_fact_beyond_required_questions() -> None: + """Optional grounded facts do not invalidate a complete required answer set.""" + body = "The claim changed after specification S2; the sales pool was North." + payload = [{ + "case_kind_code": "claim_investigation", + "summary_text": "Specification-linked claim", + "evidence_text": body, + "facts": [ + {"fact_type_code": "specification_change", "value_text": "S2", "evidence_text": "specification S2"}, + {"fact_type_code": "sales_pool", "value_text": "North", "evidence_text": "sales pool was North"}, + {"fact_type_code": "discussion", "value_text": "Claim discussion", "evidence_text": "claim changed"}, + ], + "missing_fact_type_codes": ["order", "originating_order"], + }] + result = parse_operations_case_response(json.dumps(payload), body) + assert result is not None + assert [fact.fact_type_code for fact in result[0].facts] == [ + "specification_change", "sales_pool", "discussion" + ] + payload[0]["facts"] = [{ "fact_type_code": "external_relation", "value_text": "Sales opportunity", diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py index d3a6ae43e..ab972bda7 100644 --- a/tests/test_operations_dashboard.py +++ b/tests/test_operations_dashboard.py @@ -51,8 +51,9 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: "evidence_text": "Synthetic cited sentence", "evidence_post_id": "00000000-0000-0000-0000-000000000002", "project_name": "Synthetic Project", - "project_names": ["Synthetic Project", "Synthetic Secondary Project"], - "occurred_at": datetime(2026, 8, 12, tzinfo=timezone.utc), + "project_names": ["Synthetic Project", "Synthetic Secondary Project"], + "occurred_at": datetime(2026, 8, 12, tzinfo=timezone.utc), + "event_count": 2, } ] @@ -77,7 +78,7 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None: { "case_kind_code": "claim_investigation", "case_kind_label": "클레임 원인 규명", - "event_count": 1, + "event_count": 2, "post_count": 1, }, { @@ -130,12 +131,29 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None: assert "corporate_entity_id::text = any($1::text[])" in query assert "process_unit_id::text = any($2::text[])" in query assert "coalesce(post.event_occurred_at, post.created_at)" in query - assert args[1:] == (["00000000-0000-0000-0000-000000000008"], date(2026, 8, 1), date(2026, 8, 31)) + assert args[1:] == ( + ["00000000-0000-0000-0000-000000000008"], + date(2026, 8, 1), + date(2026, 8, 31), + False, + ) case_query = conn.queries[1][0] assert "order by primary_mention.confidence desc" in case_query assert "coalesce(nullif(btrim(post.source_project_name), ''), project.primary_project_name)" in case_query +@pytest.mark.anyio +async def test_external_scope_is_bound_in_every_dashboard_query() -> None: + """The external destination restricts data at the API query boundary.""" + conn = _Connection() + await fetch_operations_dashboard( + conn, ["corp"], ["pu"], date(2026, 8, 1), date(2026, 8, 31), external_only=True + ) + assert conn.queries + assert all("$5::boolean" in query for query, _ in conn.queries) + assert all(args[-1] is True for _, args in conn.queries) + + @pytest.mark.anyio async def test_dashboard_zero_denominator_and_invalid_period() -> None: """An empty corpus has 0%, while an inverted interval fails closed.""" From 97c85794c7c2556fa62685e98e34f067c218bb37 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 26 Aug 2026 00:38:57 +0900 Subject: [PATCH 015/393] docs: record dashboard contract repairs --- docs/product-technical-gap-baseline.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 01744a9d0..b385ede90 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -66,7 +66,7 @@ only aggregate, non-identifying evidence to this repository. ### Exact open-PR boundary At this snapshot there were 8 open PRs and 10 open issues. Exact observed heads -were `#643 041ec13b`, `#640 a42cd443`, `#639 aee02dca`, `#636 20d25fe6`, `#632 e1ebe50a`, +were `#643 041ec13b`, `#640 fae1d576`, `#639 aee02dca`, `#636 20d25fe6`, `#632 e1ebe50a`, `#631 c0022c97`, `#629 0f4665b5`, and `#579 689a21b6`. All remain blocked on hosted gates and/or independent review. These observations are not merge readiness. Re-fetch exact heads, @@ -91,7 +91,7 @@ context only. | PR | Exact observed head | Merge/check state at this snapshot | | ---: | --- | --- | | #643 | `041ec13b` | shares token-backed success/unavailable/retry status notices for the Calendar surface; hosted gates and independent review remain required | -| #640 | `a42cd443` | quantifies dashboard case metrics, preserves confidence-ranked project labels, and persists explicit missing required facts; hosted gates and independent review remain required | +| #640 | `fae1d576` | quantifies dashboard case metrics, preserves confidence-ranked project labels, persists explicit missing required facts, and enforces distinct event counts plus SQL-level external scoping; hosted gates and independent review remain required | | #639 | `aee02dca` | repairs Running-action, Compose, and TEPP configuration contracts; hosted gates and independent review remain required | | #636 | `20d25fe6` | publishes the calibrated external-lineage contract without a redundant explicit-child filter; hosted gates and independent review remain required | | #632 | `e1ebe50a` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required | From 985b4492cfd8d9e9154d8940bc18f3e9bcc8e57d Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 26 Aug 2026 00:41:29 +0900 Subject: [PATCH 016/393] docs: record repaired dashboard UI audit --- docs/product-technical-gap-baseline.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index b385ede90..38d7975c6 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -63,6 +63,13 @@ committed. Authenticated authorized-corpus acceptance remains separate and may return only aggregate, non-identifying evidence to this repository. +At the repaired dashboard head, the `EvidenceReady` and `NarrowViewport` +stories were re-rendered locally with synthetic data at desktop and iPhone +13 viewports. The desktop shows separate Event/post values and evidence +actions; the narrow view preserves readable cards and 44px-class actions while +keeping the multi-step project journey horizontally scrollable. These images +remain local audit evidence and are not committed. + ### Exact open-PR boundary At this snapshot there were 8 open PRs and 10 open issues. Exact observed heads From 34bd3f3b2cfebb35c154d4b84726f14694770b92 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 26 Aug 2026 00:44:47 +0900 Subject: [PATCH 017/393] fix(frontend): keep host artifacts out of image build --- frontend/.dockerignore | 3 +++ frontend/Dockerfile | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 frontend/.dockerignore diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 000000000..0577592bb --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,3 @@ +node_modules +dist +storybook-static diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 30420c475..5f2e1eb9a 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,7 +1,7 @@ FROM node:24-slim@sha256:3638d9a6fe4030bd716be989438248074489337ba3275657f93595428be4fc03 AS build WORKDIR /app RUN corepack enable -COPY package.json pnpm-lock.yaml ./ +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ RUN pnpm install --frozen-lockfile COPY . . # Vite bakes VITE_* vars in at build time, not runtime -- build args let From 9ec61156a3ae3d44cad90daefbea69756929a2a8 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 26 Aug 2026 00:45:25 +0900 Subject: [PATCH 018/393] docs: refresh current queue heads --- docs/product-technical-gap-baseline.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 38d7975c6..4f6fc77a3 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -73,8 +73,8 @@ remain local audit evidence and are not committed. ### Exact open-PR boundary At this snapshot there were 8 open PRs and 10 open issues. Exact observed heads -were `#643 041ec13b`, `#640 fae1d576`, `#639 aee02dca`, `#636 20d25fe6`, `#632 e1ebe50a`, -`#631 c0022c97`, `#629 0f4665b5`, and `#579 689a21b6`. All remain blocked on +were `#643 041ec13b`, `#640 985b4492`, `#639 aee02dca`, `#636 f7b9a65f`, `#632 e1ebe50a`, +`#631 c0022c97`, `#629 74823e99`, and `#579 689a21b6`. All remain blocked on hosted gates and/or independent review. These observations are not merge readiness. Re-fetch exact heads, unresolved threads, checks, approvals, rulesets, and merge SHA before any @@ -98,12 +98,12 @@ context only. | PR | Exact observed head | Merge/check state at this snapshot | | ---: | --- | --- | | #643 | `041ec13b` | shares token-backed success/unavailable/retry status notices for the Calendar surface; hosted gates and independent review remain required | -| #640 | `fae1d576` | quantifies dashboard case metrics, preserves confidence-ranked project labels, persists explicit missing required facts, and enforces distinct event counts plus SQL-level external scoping; hosted gates and independent review remain required | +| #640 | `985b4492` | quantifies dashboard case metrics, preserves confidence-ranked project labels, persists explicit missing required facts, and enforces distinct event counts plus SQL-level external scoping; hosted gates and independent review remain required | | #639 | `aee02dca` | repairs Running-action, Compose, and TEPP configuration contracts; hosted gates and independent review remain required | -| #636 | `20d25fe6` | publishes the calibrated external-lineage contract without a redundant explicit-child filter; hosted gates and independent review remain required | +| #636 | `f7b9a65f` | publishes the calibrated external-lineage contract without a redundant explicit-child filter and repairs test import hygiene; hosted gates and independent review remain required | | #632 | `e1ebe50a` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required | | #631 | `c0022c97` | decomposes ADR gaps and queue baseline; hosted gates and independent review remain required | -| #629 | `0f4665b5` | releases provider work from database leases and bounds landing reads; hosted gates and independent review remain required | +| #629 | `74823e99` | releases provider work from database leases and bounds landing reads; hosted gates and independent review remain required | | #579 | `689a21b6` | delegates leftover interaction-map arithmetic to fast-mlsirm and persists only the consumer projection; hosted gates and independent review remain required | No row above is merge evidence. Immediately before any lifecycle action, From 4678be28075a3642813d7220e90376c4ffd50fd0 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 26 Aug 2026 00:49:05 +0900 Subject: [PATCH 019/393] docs(storybook): inventory external empty state --- docs/storybook-inventory.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 3559cf139..cc9d3edc1 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -5,7 +5,7 @@ operator-facing control you can click before changing product CSS. | Story | Operator next action | Token / module | |---|---|---| -| `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, or repeat-issue fact. `EvidenceReady`, `NarrowViewport`, `RequiredFactMissing`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover populated, mobile, explicit evidence-absence, analysis-pending, retryable failure, and transport-error states. | `--color-dashboard-*`, `OperationsDashboard` | +| `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, or repeat-issue fact. `EvidenceReady`, `NarrowViewport`, `ExternalInformationEmpty`, `RequiredFactMissing`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover populated, mobile, scoped-empty, explicit evidence-absence, analysis-pending, retryable failure, and transport-error states. | `--color-dashboard-*`, `OperationsDashboard` | | `Post/SimilarVocPanel` | Compare ontology/semantic similar VOC and prior action evidence, then open the source; unavailable states show no fabricated TEPP theta or weight. | `SimilarVocPanel.css`, `SimilarVocPanel` | | `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` | | `Evidence/OrganizationAliasChip` | Click a cataloged org; the parenthetical is the unique corroborated SKOS companion. | `--color-chip-border`, `--radius-chip`, `OrganizationAliasChip` | From 6d4a0b7640e4a923c5b6090142162dec547056c4 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 26 Aug 2026 00:57:58 +0900 Subject: [PATCH 020/393] fix(backend): restore TEPP API key setting --- backend/app/config.py | 2 ++ backend/tests/test_config.py | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 4dba383e8..827441648 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -59,6 +59,7 @@ class Settings: valkey_url: str searxng_base_url: str tepp_transport_url: str + tepp_api_key: str caldav_base_url: str naruon_calendar_base_url: str naruon_calendar_service_token: str @@ -171,6 +172,7 @@ def load_settings() -> Settings: valkey_url=os.environ.get("VALKEY_URL", "redis://localhost:16379/0"), searxng_base_url=os.environ.get("SEARXNG_BASE_URL", ""), tepp_transport_url=os.environ.get("TEPP_TRANSPORT_URL", ""), + tepp_api_key=os.environ.get("TEPP_API_KEY", ""), caldav_base_url=os.environ.get("CALDAV_BASE_URL", "").strip(), naruon_calendar_base_url=os.environ.get("NARUON_CALENDAR_BASE_URL", "").strip(), naruon_calendar_service_token=os.environ.get( diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index 2a80f1fa4..e3c182a0b 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -41,7 +41,10 @@ def test_tepp_transport_url_defaults_empty_and_is_not_a_score(monkeypatch) -> No monkeypatch.delenv("TEPP_TRANSPORT_URL", raising=False) assert load_settings().tepp_transport_url == "" monkeypatch.setenv("TEPP_TRANSPORT_URL", "https://tepp.example/v1/analysis-runs") - assert load_settings().tepp_transport_url == "https://tepp.example/v1/analysis-runs" + monkeypatch.setenv("TEPP_API_KEY", "runtime-only-secret") + settings = load_settings() + assert settings.tepp_transport_url == "https://tepp.example/v1/analysis-runs" + assert settings.tepp_api_key == "runtime-only-secret" def test_keyverse_issuer_overrides_local_keycloak_and_uses_oidc_discovery(monkeypatch) -> None: From 114c5be7959c85ca74451a195dc367108ca6c2af Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 26 Aug 2026 01:14:47 +0900 Subject: [PATCH 021/393] fix(db): make Global Ask queue migration replay-safe --- migrations/0165_global_ask_job.sql | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/migrations/0165_global_ask_job.sql b/migrations/0165_global_ask_job.sql index 266b77979..37fa4b568 100644 --- a/migrations/0165_global_ask_job.sql +++ b/migrations/0165_global_ask_job.sql @@ -7,7 +7,7 @@ -- Mirrors the durable-row-plus-stream design post_content_job already -- uses, so a lost stream entry is recovered from the queued rows. -create table global_ask_job ( +create table if not exists global_ask_job ( global_ask_job_id uuid primary key default uuid_generate_v4(), requesting_account_id uuid not null references user_account (user_account_id), question_text text not null, @@ -23,9 +23,9 @@ comment on table global_ask_job is 'One asynchronous Global Ask request: queued by POST /api/ask, ' 'processed by the Valkey-stream worker, polled by the reader.'; -create index global_ask_job_account_idx +create index if not exists global_ask_job_account_idx on global_ask_job (requesting_account_id, created_at desc); -create index global_ask_job_queued_idx +create index if not exists global_ask_job_queued_idx on global_ask_job (created_at) where job_status_code = 'queued'; From 88880f12216192c4bffe39a71a9cbe05b95e9f2e Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 26 Aug 2026 01:16:26 +0900 Subject: [PATCH 022/393] fix(db): replay Global Ask scope migration safely --- migrations/0203_global_ask_authorization_scope.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/migrations/0203_global_ask_authorization_scope.sql b/migrations/0203_global_ask_authorization_scope.sql index 17d23a4f3..078a9d822 100644 --- a/migrations/0203_global_ask_authorization_scope.sql +++ b/migrations/0203_global_ask_authorization_scope.sql @@ -1,12 +1,12 @@ -- Persist the exact authorization scope carried by the request token. -create table global_ask_job_corporate_entity_scope ( +create table if not exists global_ask_job_corporate_entity_scope ( global_ask_job_id uuid not null references global_ask_job (global_ask_job_id) on delete cascade, corporate_entity_id uuid not null references corporate_entity (corporate_entity_id), primary key (global_ask_job_id, corporate_entity_id) ); -create table global_ask_job_process_unit_scope ( +create table if not exists global_ask_job_process_unit_scope ( global_ask_job_id uuid not null references global_ask_job (global_ask_job_id) on delete cascade, process_unit_id uuid not null references process_unit (process_unit_id), primary key (global_ask_job_id, process_unit_id) From b44f862e65d86bc6afa12dfebb04a65b4164f396 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 26 Aug 2026 01:22:42 +0900 Subject: [PATCH 023/393] fix(dashboard): limit missing facts to required questions --- lineageweave/operations_case_analysis.py | 11 ++++++----- tests/test_operations_case_analysis.py | 3 +++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/lineageweave/operations_case_analysis.py b/lineageweave/operations_case_analysis.py index 73ac9f7c9..39254f666 100644 --- a/lineageweave/operations_case_analysis.py +++ b/lineageweave/operations_case_analysis.py @@ -152,13 +152,14 @@ def parse_operations_case_response( return None parsed_facts.append(OperationsCaseFact(fact["fact_type_code"], value.strip(), fact_evidence, fact_source.post_id, fact_source.input_sha256)) supported_types = {fact.fact_type_code for fact in parsed_facts} + missing_types = set(missing_fact_types) + required_types = REQUIRED_FACT_TYPES[item["case_kind_code"]] if ( any(not isinstance(code, str) or code not in FACT_TYPES for code in missing_fact_types) - or len(set(missing_fact_types)) != len(missing_fact_types) - or supported_types.intersection(missing_fact_types) - or not REQUIRED_FACT_TYPES[item["case_kind_code"]].issubset( - supported_types.union(missing_fact_types) - ) + or len(missing_types) != len(missing_fact_types) + or not missing_types.issubset(required_types) + or supported_types.intersection(missing_types) + or not required_types.issubset(supported_types.union(missing_types)) ): return None cases.append(OperationsCase(item["case_kind_code"], summary.strip(), evidence, tuple(parsed_facts), evidence_source.post_id, evidence_source.input_sha256, tuple(missing_fact_types))) diff --git a/tests/test_operations_case_analysis.py b/tests/test_operations_case_analysis.py index 3aa3ebf1c..7167420a2 100644 --- a/tests/test_operations_case_analysis.py +++ b/tests/test_operations_case_analysis.py @@ -141,3 +141,6 @@ def test_accepts_grounded_nonrequired_fact_after_required_questions_are_complete }] assert parse_operations_case_response(json.dumps(payload), body) is not None + + payload[0]["missing_fact_type_codes"] = ["our_owner"] + assert parse_operations_case_response(json.dumps(payload), body) is None From 2165a5d10451b49a7bbdf8cf80b8d00a2ed719bd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 09:26:09 -0700 Subject: [PATCH 024/393] feat(dashboard): project typed operational evidence (#649) * feat(dashboard): project typed operational evidence * fix(dashboard): scope semantic relation targets to cited facts --------- Co-authored-by: seonghobae Co-authored-by: Codex --- backend/app/operations_case_ingestion.py | 4 +- backend/app/operations_dashboard.py | 110 ++++++++++++++++-- .../adr/0206-evidence-operations-dashboard.md | 8 ++ docs/ontology/lineageweave-kg-shapes.ttl | 20 ++++ docs/ontology/lineageweave-kg.ttl | 58 +++++++++ frontend/src/api.ts | 9 ++ .../OperationsDashboard.stories.tsx | 2 +- .../components/OperationsDashboard.test.tsx | 17 +++ .../src/components/OperationsDashboard.tsx | 2 +- lineageweave/operations_case_analysis.py | 36 +++++- ...13_operations_external_relation_target.sql | 15 +++ scripts/publish_ontology_site.py | 9 +- tests/test_ontology.py | 20 +++- tests/test_operations_case_analysis.py | 48 +++++++- tests/test_operations_case_ingestion.py | 2 +- tests/test_operations_dashboard.py | 61 ++++++++++ tests/test_schema.py | 6 + 17 files changed, 407 insertions(+), 20 deletions(-) create mode 100644 migrations/0213_operations_external_relation_target.sql diff --git a/backend/app/operations_case_ingestion.py b/backend/app/operations_case_ingestion.py index 92cfc1fc8..ea92c4f22 100644 --- a/backend/app/operations_case_ingestion.py +++ b/backend/app/operations_case_ingestion.py @@ -55,9 +55,9 @@ async def persist_operations_cases( ) if case.facts: await conn.executemany( - "insert into operations_case_fact (post_id, case_kind_code, fact_ordinal, fact_type_code, value_text, evidence_text, evidence_post_id, evidence_input_sha256) values ($1, $2, $3, $4, $5, $6, $7, $8)", + "insert into operations_case_fact (post_id, case_kind_code, fact_ordinal, fact_type_code, value_text, evidence_text, evidence_post_id, evidence_input_sha256, relation_target_kind_code) values ($1, $2, $3, $4, $5, $6, $7, $8, $9)", [ - (post_id, case.case_kind_code, ordinal, fact.fact_type_code, fact.value_text, fact.evidence_text, fact.evidence_post_id, fact.evidence_input_sha256) + (post_id, case.case_kind_code, ordinal, fact.fact_type_code, fact.value_text, fact.evidence_text, fact.evidence_post_id, fact.evidence_input_sha256, fact.relation_target_kind_code) for ordinal, fact in enumerate(case.facts) ], ) diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py index a1007bcc9..552bf094a 100644 --- a/backend/app/operations_dashboard.py +++ b/backend/app/operations_dashboard.py @@ -6,6 +6,8 @@ from typing import Any, Protocol from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL +from lineageweave.ontology import LW +from lineageweave.prov_o import PROV_RELATIONS CASE_KIND_LABELS = { @@ -27,6 +29,78 @@ "issue_pattern": "반복 유형", "improvement_action": "개선 조치", } +CASE_KIND_ONTOLOGY_CLASSES = { + "claim_investigation": str(LW.ClaimInvestigation), + "rebid_handover": str(LW.RebidHandover), + "external_information": str(LW.ExternalInformation), + "repeat_issue": str(LW.RepeatIssue), +} +EXTERNAL_RELATION_TARGETS = { + "order": ("수주", str(LW.Order), str(LW.relatesToOrder)), + "project": ("프로젝트", str(LW.Project), str(LW.relatesToProject)), + "sales": ("영업", str(LW.SalesContext), str(LW.relatesToSales)), + "business_management": ( + "사업 관리", + str(LW.BusinessManagementContext), + str(LW.relatesToBusinessManagement), + ), +} +PROV_WAS_DERIVED_FROM = PROV_RELATIONS["wasDerivedFrom"].iri + + +def _operations_case_jsonld( + post_id: str, + case_kind_code: str, + evidence_post_id: str, + case_facts: list[dict[str, str]], +) -> dict[str, Any]: + """Project one persisted case and its cited facts as bounded JSON-LD.""" + case_id = f"urn:lineageweave:operations-case:{post_id}:{case_kind_code}" + statements: list[dict[str, Any]] = [] + for ordinal, fact in enumerate(case_facts): + statement: dict[str, Any] = { + "@id": f"{case_id}:fact:{ordinal}", + "@type": [str(LW.OperationsCaseFact), "http://www.w3.org/ns/prov#Entity"], + str(LW.factTypeCode): fact["fact_type_code"], + str(LW.factValue): fact["value_text"], + PROV_WAS_DERIVED_FROM: { + "@id": f"urn:lineageweave:post:{fact['evidence_post_id']}", + "@type": [str(LW.Post), "http://www.w3.org/ns/prov#Entity"], + }, + } + predicate = fact.get("relation_predicate_iri") + target_class = fact.get("relation_target_class_iri") + if predicate and target_class: + statement.update( + { + "http://www.w3.org/1999/02/22-rdf-syntax-ns#subject": { + "@id": case_id + }, + "http://www.w3.org/1999/02/22-rdf-syntax-ns#predicate": { + "@id": predicate + }, + "http://www.w3.org/1999/02/22-rdf-syntax-ns#object": { + "@id": f"{case_id}:fact:{ordinal}:target", + "@type": target_class, + "http://www.w3.org/2000/01/rdf-schema#label": fact["value_text"], + }, + } + ) + statements.append(statement) + return { + "@context": { + "lw": str(LW), + "prov": "http://www.w3.org/ns/prov#", + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + }, + "@id": case_id, + "@type": [CASE_KIND_ONTOLOGY_CLASSES[case_kind_code], "prov:Entity"], + "prov:wasDerivedFrom": { + "@id": f"urn:lineageweave:post:{evidence_post_id}", + "@type": [str(LW.Post), "prov:Entity"], + }, + str(LW.hasOperationsFact): statements, + } class _Connection(Protocol): @@ -156,7 +230,7 @@ async def fetch_operations_dashboard( f""" select fact.post_id, fact.case_kind_code, fact.fact_type_code, fact.value_text, fact.evidence_text, fact.evidence_post_id, - fact.fact_ordinal + fact.fact_ordinal, fact.relation_target_kind_code from operations_case_fact fact join source_post post on post.post_id = fact.post_id where {visible} @@ -179,15 +253,23 @@ async def fetch_operations_dashboard( facts: dict[tuple[str, str], list[dict[str, str]]] = {} for row in fact_rows: key = (str(row["post_id"]), row["case_kind_code"]) - facts.setdefault(key, []).append( - { - "fact_type_code": row["fact_type_code"], - "fact_type_label": FACT_TYPE_LABELS[row["fact_type_code"]], - "value_text": row["value_text"], - "evidence_text": row["evidence_text"], - "evidence_post_id": str(row["evidence_post_id"]), - } - ) + projected_fact = { + "fact_type_code": row["fact_type_code"], + "fact_type_label": FACT_TYPE_LABELS[row["fact_type_code"]], + "value_text": row["value_text"], + "evidence_text": row["evidence_text"], + "evidence_post_id": str(row["evidence_post_id"]), + "ontology_class_iri": str(LW.OperationsCaseFact), + "provenance_relation_iri": PROV_WAS_DERIVED_FROM, + } + target_kind = row["relation_target_kind_code"] + if target_kind in EXTERNAL_RELATION_TARGETS: + target_label, target_class, predicate = EXTERNAL_RELATION_TARGETS[target_kind] + projected_fact["relation_target_kind_code"] = target_kind + projected_fact["relation_target_kind_label"] = target_label + projected_fact["relation_target_class_iri"] = target_class + projected_fact["relation_predicate_iri"] = predicate + facts.setdefault(key, []).append(projected_fact) missing_facts: dict[tuple[str, str], list[dict[str, str]]] = {} for row in missing_rows: key = (str(row["post_id"]), row["case_kind_code"]) @@ -232,9 +314,17 @@ async def fetch_operations_dashboard( "summary_text": row["summary_text"], "evidence_text": row["evidence_text"], "evidence_post_id": str(row["evidence_post_id"]), + "ontology_class_iri": CASE_KIND_ONTOLOGY_CLASSES[row["case_kind_code"]], + "provenance_relation_iri": PROV_WAS_DERIVED_FROM, "occurred_at": row["occurred_at"].isoformat(), "facts": facts.get((str(row["post_id"]), row["case_kind_code"]), []), "missing_facts": missing_facts.get((str(row["post_id"]), row["case_kind_code"]), []), + "semantic_projection": _operations_case_jsonld( + str(row["post_id"]), + row["case_kind_code"], + str(row["evidence_post_id"]), + facts.get((str(row["post_id"]), row["case_kind_code"]), []), + ), } for row in case_rows ], diff --git a/docs/adr/0206-evidence-operations-dashboard.md b/docs/adr/0206-evidence-operations-dashboard.md index 3e10282d7..21d923fd3 100644 --- a/docs/adr/0206-evidence-operations-dashboard.md +++ b/docs/adr/0206-evidence-operations-dashboard.md @@ -114,6 +114,14 @@ provenance. authorized, and that rank is never a psychometric measure or substitute for TEPP. Missing estimates remain unavailable; no hand-picked weight is introduced. +15. Operations classifications and facts have a governed OWL/JSON-LD read + projection. Each case is a `prov:Entity`; each fact is an RDF-reified + `prov:Entity` linked to its exact cited Post by `prov:wasDerivedFrom`. + External-information relations carry a provider-returned, closed semantic + target type (`order`, `project`, `sales`, or `business_management`) and map + to typed ontology properties. This is not a `knowledge_graph_edge` alias: + PostgreSQL operations tables remain authoritative, and an older untyped + relation remains absent from the typed projection until re-analysis. ## Consequences diff --git a/docs/ontology/lineageweave-kg-shapes.ttl b/docs/ontology/lineageweave-kg-shapes.ttl index 436eb401f..187ebb0f1 100644 --- a/docs/ontology/lineageweave-kg-shapes.ttl +++ b/docs/ontology/lineageweave-kg-shapes.ttl @@ -1,6 +1,7 @@ @prefix : . @prefix dcterms: . @prefix owl: . +@prefix prov: . @prefix rdf: . @prefix rdfs: . @prefix sh: . @@ -166,6 +167,25 @@ sh:datatype xsd:string ; ] . +:OperationsCaseFactShape a sh:NodeShape ; + rdfs:label "Operations case fact shape" ; + sh:targetClass :OperationsCaseFact ; + sh:property [ + sh:path :factTypeCode ; + sh:minCount 1 ; sh:maxCount 1 ; + sh:datatype xsd:string ; sh:minLength 1 ; + ] ; + sh:property [ + sh:path :factValue ; + sh:minCount 1 ; sh:maxCount 1 ; + sh:datatype xsd:string ; sh:minLength 1 ; + ] ; + sh:property [ + sh:path prov:wasDerivedFrom ; + sh:minCount 1 ; sh:maxCount 1 ; + sh:class :Post ; + ] . + :OurSidePersonShape a sh:NodeShape ; rdfs:label "Our-side person shape" ; sh:comment "Closed-world complement of :OurSidePerson owl:disjointWith :CounterpartyPerson: an instance of one can never be typed as the other." ; diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl index 24aeabdba..90ef1c4cb 100644 --- a/docs/ontology/lineageweave-kg.ttl +++ b/docs/ontology/lineageweave-kg.ttl @@ -433,3 +433,61 @@ :semanticConfidence a owl:DatatypeProperty ; rdfs:domain :ProjectMention ; rdfs:range xsd:decimal . + +################################################################# +# Evidence-grounded operations Dashboard (ADR 0206). +# +# These are governed read-projection terms over operations_case_* rows, +# not knowledge_graph_edge aliases. Each reified fact retains its cited +# source post through prov:wasDerivedFrom. Untyped legacy external-relation +# rows stay outside the typed relation projection. +################################################################# + +:OperationsCase a owl:Class ; + rdfs:subClassOf prov:Entity ; + rdfs:label "Operations case"@en . + +:ClaimInvestigation a owl:Class ; + rdfs:subClassOf :OperationsCase ; + rdfs:label "Claim investigation"@en . + +:RebidHandover a owl:Class ; + rdfs:subClassOf :OperationsCase ; + rdfs:label "Rebid or handover"@en . + +:ExternalInformation a owl:Class ; + rdfs:subClassOf :OperationsCase ; + rdfs:label "External information"@en . + +:RepeatIssue a owl:Class ; + rdfs:subClassOf :OperationsCase ; + rdfs:label "Repeat issue"@en . + +:OperationsCaseFact a owl:Class ; + rdfs:subClassOf rdf:Statement, prov:Entity ; + rdfs:label "Operations case fact"@en . + +:Order a owl:Class ; rdfs:label "Order"@en . +:SalesContext a owl:Class ; rdfs:label "Sales context"@en . +:BusinessManagementContext a owl:Class ; rdfs:label "Business-management context"@en . + +:relatesToOrder a owl:ObjectProperty ; + rdfs:domain :ExternalInformation ; rdfs:range :Order . + +:relatesToProject a owl:ObjectProperty ; + rdfs:domain :ExternalInformation ; rdfs:range :Project . + +:relatesToSales a owl:ObjectProperty ; + rdfs:domain :ExternalInformation ; rdfs:range :SalesContext . + +:relatesToBusinessManagement a owl:ObjectProperty ; + rdfs:domain :ExternalInformation ; rdfs:range :BusinessManagementContext . + +:hasOperationsFact a owl:ObjectProperty ; + rdfs:domain :OperationsCase ; rdfs:range :OperationsCaseFact . + +:factTypeCode a owl:DatatypeProperty ; + rdfs:domain :OperationsCaseFact ; rdfs:range xsd:string . + +:factValue a owl:DatatypeProperty ; + rdfs:domain :OperationsCaseFact ; rdfs:range xsd:string . diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 588d4ab3e..0b5002cc6 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -47,6 +47,12 @@ export interface OperationsDashboardFact { value_text: string; evidence_text: string; evidence_post_id: string; + ontology_class_iri?: string; + provenance_relation_iri?: string; + relation_target_kind_code?: "order" | "project" | "sales" | "business_management"; + relation_target_kind_label?: string; + relation_target_class_iri?: string; + relation_predicate_iri?: string; } export interface OperationsDashboardCase { @@ -61,6 +67,9 @@ export interface OperationsDashboardCase { occurred_at: string; facts: OperationsDashboardFact[]; missing_facts: Array<{ fact_type_code: string; fact_type_label: string }>; + ontology_class_iri?: string; + provenance_relation_iri?: string; + semantic_projection?: Record; } export interface OperationsDashboardResponse { diff --git a/frontend/src/components/OperationsDashboard.stories.tsx b/frontend/src/components/OperationsDashboard.stories.tsx index 560bbbe33..1ca026c82 100644 --- a/frontend/src/components/OperationsDashboard.stories.tsx +++ b/frontend/src/components/OperationsDashboard.stories.tsx @@ -22,7 +22,7 @@ export const EvidenceReady: Story = { cases: [ { post_id: "synthetic-post-1", case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 역추적", project_name: "Synthetic Transformer Renewal", summary_text: "사양 변경 이후 원인 수주와 Pool을 확인", evidence_text: "Revision B originated in order SO-100 from pool SP-20.", evidence_post_id: "synthetic-post-1", occurred_at: "2026-08-04T00:00:00Z", facts: [{ fact_type_code: "originating_order", fact_type_label: "원인 수주", value_text: "SO-100 · SP-20", evidence_text: "order SO-100 from pool SP-20", evidence_post_id: "synthetic-post-1" }], missing_facts: [{ fact_type_code: "order", fact_type_label: "발생 수주" }, { fact_type_code: "specification_change", fact_type_label: "사양 변경" }, { fact_type_code: "sales_pool", fact_type_label: "수주 Pool" }] }, { post_id: "synthetic-post-2", case_kind_code: "rebid_handover", case_kind_label: "재입찰 · 인수인계", project_name: "Synthetic Transformer Renewal", summary_text: "담당자 교체 전 협의와 후속 결정을 연결", evidence_text: "The account owner and design lead agreed to submit the revised proposal.", evidence_post_id: "synthetic-post-2", occurred_at: "2026-08-11T00:00:00Z", facts: [{ fact_type_code: "decision", fact_type_label: "이어진 결정", value_text: "수정 제안 제출", evidence_text: "submit the revised proposal", evidence_post_id: "synthetic-post-2" }], missing_facts: [{ fact_type_code: "discussion", fact_type_label: "협의 내용" }, { fact_type_code: "counterparty", fact_type_label: "협의 상대" }, { fact_type_code: "our_owner", fact_type_label: "우리측 담당자" }] }, - { post_id: "synthetic-post-3", case_kind_code: "external_information", case_kind_label: "외부 정보", project_name: "Synthetic Transformer Renewal", summary_text: "시장 공고를 영업 기회와 연결", evidence_text: "The public procurement notice opened on August 15.", evidence_post_id: "synthetic-post-3", occurred_at: "2026-08-15T00:00:00Z", facts: [{ fact_type_code: "external_relation", fact_type_label: "업무 관계", value_text: "갱신 제안 준비", evidence_text: "procurement notice", evidence_post_id: "synthetic-post-3" }], missing_facts: [] }, + { post_id: "synthetic-post-3", case_kind_code: "external_information", case_kind_label: "외부 정보", project_name: "Synthetic Transformer Renewal", summary_text: "시장 공고를 영업 기회와 연결", evidence_text: "The public procurement notice opened on August 15.", evidence_post_id: "synthetic-post-3", occurred_at: "2026-08-15T00:00:00Z", facts: [{ fact_type_code: "external_relation", fact_type_label: "업무 관계", value_text: "갱신 제안 준비", evidence_text: "procurement notice", evidence_post_id: "synthetic-post-3", relation_target_kind_code: "project", relation_target_kind_label: "프로젝트" }], missing_facts: [] }, { post_id: "synthetic-post-4", case_kind_code: "repeat_issue", case_kind_label: "반복 이슈 반영", project_name: "Synthetic Transformer Renewal", summary_text: "동일 유형 이슈를 설계 개선으로 환류", evidence_text: "The same enclosure issue recurred after Revision B.", evidence_post_id: "synthetic-post-4", occurred_at: "2026-08-18T00:00:00Z", facts: [{ fact_type_code: "improvement_action", fact_type_label: "개선 과제", value_text: "표준 사양 개정", evidence_text: "Update the standard enclosure specification.", evidence_post_id: "synthetic-post-4" }], missing_facts: [{ fact_type_code: "issue_pattern", fact_type_label: "반복 유형" }] }, ], }, diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx index bc7c561da..e0093ff24 100644 --- a/frontend/src/components/OperationsDashboard.test.tsx +++ b/frontend/src/components/OperationsDashboard.test.tsx @@ -58,6 +58,23 @@ describe("OperationsDashboardView", () => { expect(screen.queryByText("5건 · 25.0%")).not.toBeInTheDocument(); }); + it("labels a source-backed external relation by its semantic target", () => { + const externalCase = { + ...data.cases[0], + case_kind_code: "external_information", + facts: [{ + ...data.cases[0].facts[0], + fact_type_code: "external_relation", + fact_type_label: "업무 관계", + relation_target_kind_code: "project" as const, + relation_target_kind_label: "프로젝트", + }], + missing_facts: [], + }; + render( undefined} />); + expect(screen.getByText("업무 관계 · 프로젝트")).toBeInTheDocument(); + }); + it("places multi-project evidence in every explicit journey and orders events oldest first", () => { const later = { ...data.cases[0], post_id: "post-later", occurred_at: "2026-08-20T00:00:00Z", project_names: ["Synthetic Grid Upgrade", "Synthetic Relay Renewal"] }; const earlier = { ...data.cases[0], post_id: "post-earlier", occurred_at: "2026-08-01T00:00:00Z", project_names: ["Synthetic Grid Upgrade"] }; diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx index 39fd75757..726ba4117 100644 --- a/frontend/src/components/OperationsDashboard.tsx +++ b/frontend/src/components/OperationsDashboard.tsx @@ -111,7 +111,7 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
{item.case_kind_label}{item.project_name ?? "프로젝트 연결 분석 중"}

{item.summary_text}

{item.evidence_text}
-
{item.facts.map((fact) =>
{fact.fact_type_label}
{fact.value_text}
)}
+
{item.facts.map((fact) =>
{fact.fact_type_label}{fact.relation_target_kind_label ? ` · ${fact.relation_target_kind_label}` : ""}
{fact.value_text}
)}
{item.missing_facts.length ? (

추가 확인 필요

diff --git a/lineageweave/operations_case_analysis.py b/lineageweave/operations_case_analysis.py index 39254f666..3c054cc59 100644 --- a/lineageweave/operations_case_analysis.py +++ b/lineageweave/operations_case_analysis.py @@ -19,6 +19,9 @@ "issue_pattern", "improvement_action", } ) +EXTERNAL_RELATION_TARGET_KINDS = frozenset( + {"order", "project", "sales", "business_management"} +) REQUIRED_FACT_TYPES = { "claim_investigation": frozenset({"order", "specification_change", "originating_order", "sales_pool"}), "rebid_handover": frozenset({"discussion", "counterparty", "our_owner", "decision"}), @@ -36,6 +39,7 @@ class OperationsCaseFact: evidence_text: str evidence_post_id: str = "" evidence_input_sha256: str = "" + relation_target_kind_code: str | None = None @dataclass(frozen=True) @@ -96,6 +100,9 @@ def analyze( fact_type_code (one of order, specification_change, originating_order, sales_pool, discussion, counterparty, our_owner, decision, external_relation, issue_pattern, improvement_action), value_text, evidence_post_id, and evidence_text (a verbatim span from that source). +An external_relation fact must also have relation_target_kind_code (one of order, +project, sales, business_management). Other facts must use null. Classify this +semantically from the cited span; never infer it from keywords. Each item must also have missing_fact_type_codes. Put every required fact type for that case that is not supported anywhere in the authorized sources in this array; never invent a value or evidence span for it. Required types are: claim_investigation = order, @@ -148,9 +155,34 @@ def parse_operations_case_response( fact_evidence = fact.get("evidence_text") fact_post_id = fact.get("evidence_post_id") or ("focal" if legacy_focal else None) fact_source = sources_by_id.get(fact_post_id) - if not isinstance(value, str) or not value.strip() or not isinstance(fact_evidence, str) or not fact_evidence.strip() or fact_source is None or fact_evidence not in fact_source.text: + relation_target_kind = fact.get("relation_target_kind_code") + if ( + not isinstance(value, str) + or not value.strip() + or not isinstance(fact_evidence, str) + or not fact_evidence.strip() + or fact_source is None + or fact_evidence not in fact_source.text + or ( + fact["fact_type_code"] == "external_relation" + and relation_target_kind not in EXTERNAL_RELATION_TARGET_KINDS + ) + or ( + fact["fact_type_code"] != "external_relation" + and relation_target_kind is not None + ) + ): return None - parsed_facts.append(OperationsCaseFact(fact["fact_type_code"], value.strip(), fact_evidence, fact_source.post_id, fact_source.input_sha256)) + parsed_facts.append( + OperationsCaseFact( + fact["fact_type_code"], + value.strip(), + fact_evidence, + fact_source.post_id, + fact_source.input_sha256, + relation_target_kind, + ) + ) supported_types = {fact.fact_type_code for fact in parsed_facts} missing_types = set(missing_fact_types) required_types = REQUIRED_FACT_TYPES[item["case_kind_code"]] diff --git a/migrations/0213_operations_external_relation_target.sql b/migrations/0213_operations_external_relation_target.sql new file mode 100644 index 000000000..eb059744d --- /dev/null +++ b/migrations/0213_operations_external_relation_target.sql @@ -0,0 +1,15 @@ +-- ADR 0206: source-backed external-information relation target type. +alter table operations_case_fact + add column if not exists relation_target_kind_code text; + +alter table operations_case_fact + drop constraint if exists operations_case_fact_relation_target_kind_check, + add constraint operations_case_fact_relation_target_kind_check check ( + (fact_type_code = 'external_relation' + and (relation_target_kind_code is null or relation_target_kind_code in + ('order', 'project', 'sales', 'business_management'))) + or (fact_type_code <> 'external_relation' and relation_target_kind_code is null) + ); + +comment on column operations_case_fact.relation_target_kind_code is + 'Semantic target type supplied with cited external_relation evidence; null legacy rows are not projected as typed relations.'; diff --git a/scripts/publish_ontology_site.py b/scripts/publish_ontology_site.py index 71ba6918c..24494731d 100644 --- a/scripts/publish_ontology_site.py +++ b/scripts/publish_ontology_site.py @@ -33,7 +33,14 @@ #: lowercase form is the deprecated compatibility vocabulary. CANONICAL_NAMESPACE = "https://contextualwisdomlab.github.io/LineageWeave/ontology#" DEPRECATED_NAMESPACE = "https://contextualwisdomlab.github.io/lineageweave/ontology#" -STANDARD_SHACL_PATHS = frozenset({RDF.subject, RDF.predicate, RDF.object}) +STANDARD_SHACL_PATHS = frozenset( + { + RDF.subject, + RDF.predicate, + RDF.object, + URIRef("http://www.w3.org/ns/prov#wasDerivedFrom"), + } +) _MAPPING_FOR_KIND = { OWL.Class: OWL.equivalentClass, diff --git a/tests/test_ontology.py b/tests/test_ontology.py index e9514996c..0ef231bab 100644 --- a/tests/test_ontology.py +++ b/tests/test_ontology.py @@ -29,7 +29,7 @@ ontology_annotations, ) from rdflib import URIRef -from rdflib.namespace import OWL, RDF, RDFS, SKOS, XSD +from rdflib.namespace import OWL, PROV, RDF, RDFS, SKOS, XSD _SEED_SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "seed_demo_data.py" @@ -238,6 +238,24 @@ def test_semantic_project_terms_preserve_post_evidence_and_confidence() -> None: assert (LW.semanticConfidence, RDFS.domain, LW.ProjectMention) in graph +def test_operations_relations_are_typed_reified_projections() -> None: + """Dashboard facts reuse RDF reification and PROV-O, never KG aliases.""" + graph = load_ontology() + assert (LW.ExternalInformation, RDFS.subClassOf, LW.OperationsCase) in graph + assert (LW.OperationsCase, RDFS.subClassOf, PROV.Entity) in graph + assert (LW.OperationsCaseFact, RDFS.subClassOf, RDF.Statement) in graph + assert (LW.OperationsCaseFact, RDFS.subClassOf, PROV.Entity) in graph + assert (LW.relatesToOrder, RDFS.range, LW.Order) in graph + assert (LW.relatesToProject, RDFS.range, LW.Project) in graph + assert (LW.relatesToSales, RDFS.range, LW.SalesContext) in graph + assert ( + LW.relatesToBusinessManagement, + RDFS.range, + LW.BusinessManagementContext, + ) in graph + assert graph.value(LW.relatesToProject, LW.lookupCode) is None + + def test_ontology_iri_is_repository_case_canonical() -> None: """ADR 0207: the ontology IRI and every term IRI use the repository-case namespace -- the exact path GitHub Pages serves -- diff --git a/tests/test_operations_case_analysis.py b/tests/test_operations_case_analysis.py index 7167420a2..d5df88c2d 100644 --- a/tests/test_operations_case_analysis.py +++ b/tests/test_operations_case_analysis.py @@ -130,6 +130,7 @@ def test_accepts_grounded_nonrequired_fact_after_required_questions_are_complete "fact_type_code": "external_relation", "value_text": "Sales opportunity", "evidence_text": body, + "relation_target_kind_code": "sales", }, { "fact_type_code": "our_owner", @@ -142,5 +143,50 @@ def test_accepts_grounded_nonrequired_fact_after_required_questions_are_complete assert parse_operations_case_response(json.dumps(payload), body) is not None - payload[0]["missing_fact_type_codes"] = ["our_owner"] +def test_external_relation_requires_a_semantic_target_type() -> None: + """Only source-backed typed external links enter the ontology projection.""" + body = "The public tender applies to Synthetic Project A." + fact = { + "fact_type_code": "external_relation", + "value_text": "Synthetic Project A", + "evidence_text": body, + "relation_target_kind_code": "project", + } + payload = [{ + "case_kind_code": "external_information", + "summary_text": "Tender relates to a project", + "evidence_text": body, + "facts": [fact], + "missing_fact_type_codes": [], + }] + + result = parse_operations_case_response(json.dumps(payload), body) + + assert result is not None + assert result[0].facts[0].relation_target_kind_code == "project" + del fact["relation_target_kind_code"] + assert parse_operations_case_response(json.dumps(payload), body) is None + fact["relation_target_kind_code"] = "guessed" + assert parse_operations_case_response(json.dumps(payload), body) is None + + +def test_optional_fact_cannot_be_marked_missing() -> None: + """A cited optional fact cannot simultaneously be declared missing.""" + body = "A public notice was published and assigned to the sales team." + payload = [{ + "case_kind_code": "external_information", + "summary_text": "External notice", + "evidence_text": "A public notice was published", + "facts": [{ + "fact_type_code": "external_relation", + "value_text": "Sales opportunity", + "evidence_text": body, + "relation_target_kind_code": "sales", + }, { + "fact_type_code": "our_owner", + "value_text": "Sales team", + "evidence_text": "assigned to the sales team", + }], + "missing_fact_type_codes": ["our_owner"], + }] assert parse_operations_case_response(json.dumps(payload), body) is None diff --git a/tests/test_operations_case_ingestion.py b/tests/test_operations_case_ingestion.py index a1d6f63f5..e6df5b1b2 100644 --- a/tests/test_operations_case_ingestion.py +++ b/tests/test_operations_case_ingestion.py @@ -47,7 +47,7 @@ def test_digest_and_atomic_normalized_persistence() -> None: assert len(source_body_digest("source")) == 64 assert "delete from operations_case_analysis" in conn.calls[0][0] assert conn.batches == [ - [("post-1", "claim_investigation", 0, "order", "A-1", "source", "post-1", digest)] + [("post-1", "claim_investigation", 0, "order", "A-1", "source", "post-1", digest, None)] ] diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py index ab972bda7..25631b0ba 100644 --- a/tests/test_operations_dashboard.py +++ b/tests/test_operations_dashboard.py @@ -35,6 +35,7 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: "evidence_text": "Synthetic cited sentence", "evidence_post_id": "00000000-0000-0000-0000-000000000002", "fact_ordinal": 0, + "relation_target_kind_code": None, } ] if "operations_case_missing_fact missing" in query: @@ -100,6 +101,11 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None: "post_count": 0, }, ] + semantic_projection = result["cases"][0].pop("semantic_projection") + assert semantic_projection["@type"][0].endswith("#ClaimInvestigation") + assert semantic_projection["prov:wasDerivedFrom"]["@id"].endswith( + "00000000-0000-0000-0000-000000000002" + ) assert result["cases"] == [ { "post_id": "00000000-0000-0000-0000-000000000001", @@ -110,6 +116,8 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None: "summary_text": "원인 수주가 연결됨", "evidence_text": "Synthetic cited sentence", "evidence_post_id": "00000000-0000-0000-0000-000000000002", + "ontology_class_iri": "https://contextualwisdomlab.github.io/LineageWeave/ontology#ClaimInvestigation", + "provenance_relation_iri": "http://www.w3.org/ns/prov#wasDerivedFrom", "occurred_at": "2026-08-12T00:00:00+00:00", "facts": [ { @@ -118,6 +126,8 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None: "value_text": "Synthetic order 7", "evidence_text": "Synthetic cited sentence", "evidence_post_id": "00000000-0000-0000-0000-000000000002", + "ontology_class_iri": "https://contextualwisdomlab.github.io/LineageWeave/ontology#OperationsCaseFact", + "provenance_relation_iri": "http://www.w3.org/ns/prov#wasDerivedFrom", } ], "missing_facts": [ @@ -179,6 +189,57 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: ) +@pytest.mark.anyio +async def test_external_information_projects_a_typed_prov_o_relation() -> None: + """A cited semantic target becomes RDF reification, never a KG alias.""" + + class ExternalConnection(_Connection): + async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: + self.queries.append((query, args)) + if "operations_case_fact fact" in query: + return [{ + "post_id": "00000000-0000-0000-0000-000000000001", + "case_kind_code": "external_information", + "fact_type_code": "external_relation", + "value_text": "Synthetic Project", + "evidence_text": "Synthetic tender evidence", + "evidence_post_id": "00000000-0000-0000-0000-000000000002", + "fact_ordinal": 0, + "relation_target_kind_code": "project", + }] + if "operations_case_missing_fact missing" in query: + return [] + return [{ + "post_id": "00000000-0000-0000-0000-000000000001", + "case_kind_code": "external_information", + "summary_text": "External tender", + "evidence_text": "Synthetic tender evidence", + "evidence_post_id": "00000000-0000-0000-0000-000000000002", + "project_name": "Synthetic Project", + "project_names": ["Synthetic Project"], + "occurred_at": datetime(2026, 8, 12, tzinfo=timezone.utc), + "event_count": 1, + }] + + result = await fetch_operations_dashboard(ExternalConnection(), []) + + fact = result["cases"][0]["facts"][0] + assert fact["relation_target_kind_code"] == "project" + assert fact["relation_predicate_iri"].endswith("#relatesToProject") + statement = result["cases"][0]["semantic_projection"][ + "https://contextualwisdomlab.github.io/LineageWeave/ontology#hasOperationsFact" + ][0] + assert statement["http://www.w3.org/1999/02/22-rdf-syntax-ns#predicate"] == { + "@id": fact["relation_predicate_iri"] + } + assert statement["http://www.w3.org/ns/prov#wasDerivedFrom"]["@id"].endswith( + "00000000-0000-0000-0000-000000000002" + ) + target = statement["http://www.w3.org/1999/02/22-rdf-syntax-ns#object"] + assert target["@id"].endswith(":fact:0:target") + assert target["@type"].endswith("#Project") + + @pytest.fixture def anyio_backend() -> str: """Use the installed asyncio backend for async projection tests.""" diff --git a/tests/test_schema.py b/tests/test_schema.py index ab0a2818c..0b4aaae69 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -95,6 +95,11 @@ _OPERATIONS_CASE_MISSING_MIGRATION = ( Path(__file__).resolve().parents[1] / "migrations" / "0211_operations_case_missing_fact.sql" ) +_OPERATIONS_EXTERNAL_RELATION_MIGRATION = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0213_operations_external_relation_target.sql" +) def _postgres_available() -> bool: @@ -143,6 +148,7 @@ def schema_db(): cur.execute(_OPERATIONS_CASE_MIGRATION.read_text()) cur.execute(_OPERATIONS_CASE_EVIDENCE_MIGRATION.read_text()) cur.execute(_OPERATIONS_CASE_MISSING_MIGRATION.read_text()) + cur.execute(_OPERATIONS_EXTERNAL_RELATION_MIGRATION.read_text()) conn.commit() yield conn finally: From 42492e30256ab03e6d316c6e218b1ede22487345 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 09:30:37 -0700 Subject: [PATCH 025/393] feat(dashboard): consume TEPP topic influence evidence (#650) * feat(dashboard): consume topic influence evidence * test(dashboard): keep topic unavailable mocks explicit --------- Co-authored-by: Codex --- CHANGELOG.md | 7 + backend/app/operations_dashboard.py | 283 +++++++++++++++++- ...poral-topic-context-influence-dashboard.md | 11 +- docs/product-technical-gap-baseline.md | 10 +- docs/storybook-inventory.md | 2 +- frontend/src/App.css | 108 +++++++ frontend/src/api.ts | 61 ++++ .../OperationsDashboard.stories.tsx | 61 ++++ .../components/OperationsDashboard.test.tsx | 58 +++- .../src/components/OperationsDashboard.tsx | 93 ++++++ ...214_topic_context_influence_projection.sql | 208 +++++++++++++ tests/test_operations_dashboard.py | 121 +++++++- tests/test_schema.py | 150 ++++++++++ 13 files changed, 1164 insertions(+), 9 deletions(-) create mode 100644 migrations/0214_topic_context_influence_projection.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cf0eb0f8..8fe0fe635 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ All notable changes to this project are documented here. Format follows ### Added +- ADR 0210's Dashboard consumer now persists a normalized, exact-provenance + projection for TEPP temporal topics and fast-mlsirm case-deletion model + influence. The API authorizes the fitted analysis scope before returning + rows; the UI preserves ties, multiple membership, uncertainty, time states, + and source links, and otherwise names the missing producer contract without + calculating a local score. + - Event Lineage now persists each reconstructed connection's independent channel scores, the normalized weights actually used, and their contributions. The Event Lineage DAG discloses those exact values as inferred diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py index 552bf094a..74ceece09 100644 --- a/backend/app/operations_dashboard.py +++ b/backend/app/operations_dashboard.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import date +import json from typing import Any, Protocol from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL @@ -106,11 +107,11 @@ def _operations_case_jsonld( class _Connection(Protocol): async def fetchrow(self, query: str, *args: object) -> Any: """Fetch one projected row.""" - pass + pass # pragma: no cover - structural Protocol member async def fetch(self, query: str, *args: object) -> list[Any]: """Fetch projected rows.""" - pass + pass # pragma: no cover - structural Protocol member def _visible_period_sql(alias: str = "post") -> str: @@ -250,6 +251,18 @@ async def fetch_operations_dashboard( """, *args, ) + topic_context = ( + { + "status_code": "not_applicable", + "reason_code": "external_information_view", + "next_action": "전체 Dashboard로 전환해 Topic model influence를 확인하세요.", + "required_contracts": [], + "model_run": None, + "topics": [], + } + if external_only + else await _fetch_topic_context_dashboard(conn, visible, args) + ) facts: dict[tuple[str, str], list[dict[str, str]]] = {} for row in fact_rows: key = (str(row["post_id"]), row["case_kind_code"]) @@ -304,6 +317,7 @@ async def fetch_operations_dashboard( } for kind, label in CASE_KIND_LABELS.items() ], + "topic_context": topic_context, "cases": [ { "post_id": str(row["post_id"]), @@ -331,6 +345,271 @@ async def fetch_operations_dashboard( } +async def _fetch_topic_context_dashboard( + conn: _Connection, + visible_post_sql: str, + args: tuple[object, ...], +) -> dict[str, Any]: + """Project exact accepted producer rows or an actionable unavailable state.""" + authorized_model_scope = """ + ((scope.scope_kind_code = 'analysis_scope_corporate_entity' + and scope.corporate_entity_id::text = any($1::text[]) + and cardinality($2::text[]) = 0) + or + (scope.scope_kind_code = 'analysis_scope_process_unit' + and scope.process_unit_id::text = any($2::text[]))) + """ + readiness = await conn.fetchrow( + f""" + with visible_post as ( + select post.post_id + from source_post post + where {visible_post_sql} + ) + select exists ( + select 1 + from topic_context_membership membership + join topic_model_run model + on model.topic_model_run_id = membership.topic_model_run_id + join analysis_run analysis on analysis.analysis_run_id = model.analysis_run_id + join analysis_run_scope scope on scope.analysis_run_id = analysis.analysis_run_id + join visible_post on visible_post.post_id = membership.source_post_id + where {authorized_model_scope} + ) as tepp_posterior_persisted, + exists ( + select 1 + from topic_post_context_influence influence + join topic_context_membership membership + on membership.topic_model_run_id = influence.topic_model_run_id + and membership.topic_context_membership_id = influence.topic_context_membership_id + join topic_model_run model + on model.topic_model_run_id = influence.topic_model_run_id + join analysis_run analysis on analysis.analysis_run_id = model.analysis_run_id + join analysis_run_scope scope on scope.analysis_run_id = analysis.analysis_run_id + join visible_post on visible_post.post_id = membership.source_post_id + where {authorized_model_scope} + ) as fast_mlsirm_influence_persisted + """, + *args, + ) + rows = await conn.fetch( + f""" + with visible_post as ( + select post.post_id, + coalesce(post.event_occurred_at, post.created_at) as occurred_at + from source_post post + where {visible_post_sql} + ), eligible as ( + select model.topic_model_run_id, model.tepp_run_id, model.tepp_snapshot_id, + model.tepp_schema_version, model.tepp_model_contract_version, + model.tepp_artifact_sha256, model.posterior_draw_set_id, + model.posterior_draw_count, model.topic_count, + snapshot.snapshot_sha256 as source_snapshot_sha256, + analysis.knowledge_cutoff, + influence_run.topic_influence_run_id, + influence_run.fast_mlsirm_schema_version, + influence_run.fast_mlsirm_version, + influence_run.fast_mlsirm_code_revision, + influence_run.fast_mlsirm_artifact_sha256, + influence_run.compute_backend_code, + influence_run.precision_code, + influence_run.membership_fingerprint_sha256, + influence.topic_index, activity.state_code, + activity.valid_from as activity_valid_from, + activity.valid_to as activity_valid_to, + membership.dimension_code, membership.context_id, + context.context_label, membership.membership_weight, + membership.evidence_sha256 as membership_evidence_sha256, + membership.source_post_id, visible_post.occurred_at, + influence.influence_value, + influence.uncertainty_method_code, + influence.uncertainty_lower_value, + influence.uncertainty_upper_value, + influence.diagnostic_status_code, + influence_run.accepted_at + from topic_post_context_influence influence + join topic_influence_run influence_run + on influence_run.topic_model_run_id = influence.topic_model_run_id + and influence_run.topic_influence_run_id = influence.topic_influence_run_id + join topic_model_run model + on model.topic_model_run_id = influence.topic_model_run_id + join analysis_run analysis on analysis.analysis_run_id = model.analysis_run_id + join analysis_run_scope scope on scope.analysis_run_id = analysis.analysis_run_id + join analysis_source_snapshot snapshot + on snapshot.analysis_source_snapshot_id = analysis.analysis_source_snapshot_id + join topic_context_membership membership + on membership.topic_model_run_id = influence.topic_model_run_id + and membership.topic_context_membership_id = influence.topic_context_membership_id + join topic_context_definition context + on context.topic_model_run_id = membership.topic_model_run_id + and context.dimension_code = membership.dimension_code + and context.context_id = membership.context_id + join visible_post on visible_post.post_id = membership.source_post_id + join topic_activity_interval activity + on activity.topic_model_run_id = influence.topic_model_run_id + and activity.topic_index = influence.topic_index + and visible_post.occurred_at >= activity.valid_from + and visible_post.occurred_at < activity.valid_to + where visible_post.occurred_at >= membership.valid_from + and visible_post.occurred_at < membership.valid_to + and {authorized_model_scope} + ), selected as ( + select topic_model_run_id, topic_influence_run_id + from eligible + order by accepted_at desc, topic_model_run_id, topic_influence_run_id + limit 1 + ) + select eligible.*, + coalesce(( + select jsonb_agg(jsonb_build_object( + 'event_code', relation.event_code, + 'source_topic_index', relation.source_topic_index, + 'target_topic_index', relation.target_topic_index, + 'event_time', relation.event_time, + 'evidence_sha256', relation.evidence_sha256 + ) order by relation.event_time, relation.relation_ordinal) + from topic_lineage_relation relation + where relation.topic_model_run_id = eligible.topic_model_run_id + and (relation.source_topic_index = eligible.topic_index + or relation.target_topic_index = eligible.topic_index) + ), '[]'::jsonb) as lineage_events + from eligible + join selected using (topic_model_run_id, topic_influence_run_id) + order by eligible.topic_index, + case eligible.dimension_code + when 'business_unit' then 0 + when 'process_unit' then 1 + when 'team' then 2 + else 3 + end, + eligible.context_label, + eligible.influence_value desc, + eligible.occurred_at, + eligible.source_post_id + """, + *args, + ) + if not rows: + tepp_ready = bool(readiness and readiness["tepp_posterior_persisted"]) + return { + "status_code": "unavailable", + "reason_code": ( + "fast_mlsirm_influence_not_persisted" + if tepp_ready + else "tepp_topic_posterior_not_persisted" + ), + "next_action": ( + "동일 TEPP run·snapshot·cutoff에 결합된 fast-mlsirm 결과를 완료하세요." + if tepp_ready + else "TEPP posterior topic 계약 결과를 먼저 완료하세요." + ), + "required_contracts": [ + { + "authority": "TEPP", + "schema_version": "tepp.topic_context_posterior.v1", + "state_code": "persisted" if tepp_ready else "not_persisted", + }, + { + "authority": "fast-mlsirm", + "schema_version": "fast_mlsirm.topic_context_influence.v1", + "state_code": ( + "persisted" + if readiness and readiness["fast_mlsirm_influence_persisted"] + else "not_persisted" + ), + }, + ], + "model_run": None, + "topics": [], + } + + first = rows[0] + topics: dict[int, dict[str, Any]] = {} + for row in rows: + topic_index = int(row["topic_index"]) + raw_lineage_events = row["lineage_events"] + lineage_events = ( + json.loads(raw_lineage_events) + if isinstance(raw_lineage_events, str) + else list(raw_lineage_events) + ) + topic = topics.setdefault( + topic_index, + { + "topic_index": topic_index, + "activity_intervals": [], + "lineage_events": lineage_events, + "contexts": [], + }, + ) + interval = { + "state_code": row["state_code"], + "valid_from": row["activity_valid_from"].isoformat(), + "valid_to": row["activity_valid_to"].isoformat(), + } + if interval not in topic["activity_intervals"]: + topic["activity_intervals"].append(interval) + context_key = (row["dimension_code"], row["context_id"]) + context = next( + ( + item + for item in topic["contexts"] + if (item["dimension_code"], item["context_id"]) == context_key + ), + None, + ) + if context is None: + context = { + "dimension_code": row["dimension_code"], + "context_id": row["context_id"], + "context_label": row["context_label"], + "influences": [], + } + topic["contexts"].append(context) + context["influences"].append( + { + "post_id": str(row["source_post_id"]), + "occurred_at": row["occurred_at"].isoformat(), + "topic_state_code": row["state_code"], + "model_influence": float(row["influence_value"]), + "uncertainty_method_code": row["uncertainty_method_code"], + "uncertainty_lower_value": float(row["uncertainty_lower_value"]), + "uncertainty_upper_value": float(row["uncertainty_upper_value"]), + "diagnostic_status_code": row["diagnostic_status_code"], + "membership_weight": float(row["membership_weight"]), + "membership_evidence_sha256": row["membership_evidence_sha256"], + } + ) + + return { + "status_code": "accepted", + "reason_code": None, + "next_action": "Topic과 조직 수준을 선택해 model influence와 근거 글을 확인하세요.", + "required_contracts": [ + {"authority": "TEPP", "schema_version": first["tepp_schema_version"], "state_code": "persisted"}, + {"authority": "fast-mlsirm", "schema_version": first["fast_mlsirm_schema_version"], "state_code": "persisted"}, + ], + "model_run": { + "tepp_run_id": first["tepp_run_id"], + "tepp_snapshot_id": first["tepp_snapshot_id"], + "source_snapshot_sha256": first["source_snapshot_sha256"], + "knowledge_cutoff": first["knowledge_cutoff"].isoformat(), + "tepp_model_contract_version": first["tepp_model_contract_version"], + "tepp_artifact_sha256": first["tepp_artifact_sha256"], + "posterior_draw_set_id": first["posterior_draw_set_id"], + "posterior_draw_count": int(first["posterior_draw_count"]), + "topic_count": int(first["topic_count"]), + "fast_mlsirm_version": first["fast_mlsirm_version"], + "fast_mlsirm_code_revision": first["fast_mlsirm_code_revision"], + "fast_mlsirm_artifact_sha256": first["fast_mlsirm_artifact_sha256"], + "compute_backend_code": first["compute_backend_code"], + "precision_code": first["precision_code"], + "membership_fingerprint_sha256": first["membership_fingerprint_sha256"], + }, + "topics": list(topics.values()), + } + + def _period_label(period_start: date | None, period_end: date | None) -> str: """Format the exact event-time interval represented by the projection.""" if period_start and period_end: diff --git a/docs/adr/0210-temporal-topic-context-influence-dashboard.md b/docs/adr/0210-temporal-topic-context-influence-dashboard.md index e9be55d48..0cfdbe1d8 100644 --- a/docs/adr/0210-temporal-topic-context-influence-dashboard.md +++ b/docs/adr/0210-temporal-topic-context-influence-dashboard.md @@ -1,7 +1,7 @@ # ADR 0210: TEPP temporal topics and fast-mlsirm context influence - Status: Accepted -- Implementation maturity: producer-contract required; consumer projection not yet shipped +- Implementation maturity: consumer projection candidate; accepted producer result unavailable - Date: 2026-08-25 - Depends on: ADR 0132 (TEPP topic-lineage boundary), ADR 0206 (operations Dashboard) - Upstream authorities: TEPP ADR 0012; fast-mlsirm ADR 0002 and ADR 0007 @@ -116,7 +116,7 @@ another dimension. ### LineageWeave consumer and persistence -Use normalized objects such as `topic_model_run`, `topic_definition`, +Use normalized objects `topic_model_run`, `topic_definition`, `topic_activity_interval`, `topic_lineage_relation`, `topic_post_coordinate`, `topic_context_membership`, `topic_influence_run`, and `topic_post_context_influence`. Large result tables are partitioned by tenant @@ -129,6 +129,13 @@ renormalizing scores. The frontend renders an exact-value table alongside the temporal topic view, uses text/pattern as well as color for topic state, and supports keyboard, touch, reduced motion, narrow viewports, and screen readers. +The LineageWeave consumer projection is allowed to land before activation. In +that state, it reports which exact producer contract is not persisted and +returns no topic, influence, rank, or fallback value. An accepted result is +readable only when its analysis-run scope is wholly authorized for the caller; +filtering individual result rows after a broader fit is insufficient because +the fitted value would still include hidden observations. + ```mermaid sequenceDiagram participant Source as Authorized source snapshot diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 4f6fc77a3..5ed4f5387 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -16,7 +16,7 @@ | Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback; authenticated runtime acceptance pending | | Similar VOC, customer cohort, prior action | Persisted repeat-issue candidate semantics plus orchestrator pair adjudication and extractive evidence | Candidate live post endpoint and post-detail UI implemented; authenticated runtime acceptance pending | | TEPP independent Event Lineage anchor | Accepted, persisted TEPP criterion bound to exact snapshot/cutoff before fast-mlsirm activation | Consumer PR #606 is on protected main; TEPP producer PR #237 remains open, so no end-to-end accepted artifact is release evidence yet | -| Temporal Lineage topics and multilevel important posts | ADR 0210; TEPP posterior topic/plausible-value contract followed by fast-mlsirm observed-information case-deletion influence | Product/technical contract is protected on `main`; neither required Rust CPU/GPU producer envelope is shipped, so the Dashboard surface remains unavailable (ADR 0208: no local Python substitute) | +| Temporal Lineage topics and multilevel important posts | ADR 0210; TEPP posterior topic/plausible-value contract followed by fast-mlsirm observed-information case-deletion influence | This stacked candidate adds normalized persistence, exact run/snapshot/cutoff binding, pre-aggregation scope authorization, API diagnostics, and populated/unavailable Storybook surfaces. TEPP PR #247 remains open, #248 exports Laplace moments explicitly short of plausible values, and fast-mlsirm #1395 closed unmerged without a result envelope; runtime therefore remains honestly unavailable with no local Python or fallback score. | ### Technical contract and flow @@ -60,6 +60,14 @@ build was inspected at 1440×1000 and 390×844 and exposes neither corpus-wide total/pending/failed counts nor a misleading corpus failure alert in that scoped destination. Screenshots remain local synthetic audit evidence and are not committed. +The stacked topic-context consumer adds `TopicInfluenceAccepted` and the +unavailable topic section in `EvidenceReady`. Synthetic screenshots were +inspected at 1440×1200 and 390×844. At 390px the page had zero document-level +horizontal overflow while each exact-value table retained its named, +keyboard-focusable 332px viewport over 784px of table content. The new source +actions measured 54px high; sampled heading, caption, and table-header contrast +was 20.15:1, 5.73:1, and 18.62:1. Authenticated runtime evidence remains +required before protected delivery can be claimed. Authenticated authorized-corpus acceptance remains separate and may return only aggregate, non-identifying evidence to this repository. diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index cc9d3edc1..3174512d3 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -5,7 +5,7 @@ operator-facing control you can click before changing product CSS. | Story | Operator next action | Token / module | |---|---|---| -| `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, or repeat-issue fact. `EvidenceReady`, `NarrowViewport`, `ExternalInformationEmpty`, `RequiredFactMissing`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover populated, mobile, scoped-empty, explicit evidence-absence, analysis-pending, retryable failure, and transport-error states. | `--color-dashboard-*`, `OperationsDashboard` | +| `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, repeat issue, or topic-context influence. `TopicInfluenceAccepted` preserves exact ties, multiple membership, time states, uncertainty, and source actions; `EvidenceReady` shows the producer-contract unavailable state. `NarrowViewport`, `ExternalInformationEmpty`, `RequiredFactMissing`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover mobile, scoped-empty, explicit evidence-absence, analysis-pending, retryable failure, and transport-error states. | `--color-dashboard-*`, `OperationsDashboard`, `TopicContextInfluence` | | `Post/SimilarVocPanel` | Compare ontology/semantic similar VOC and prior action evidence, then open the source; unavailable states show no fabricated TEPP theta or weight. | `SimilarVocPanel.css`, `SimilarVocPanel` | | `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` | | `Evidence/OrganizationAliasChip` | Click a cataloged org; the parenthetical is the unique corroborated SKOS companion. | `--color-chip-border`, `--radius-chip`, `OrganizationAliasChip` | diff --git a/frontend/src/App.css b/frontend/src/App.css index 79aa1c852..cf34add79 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1350,12 +1350,120 @@ .dashboard-case-card dd { margin: 0; font-weight: 600; } .dashboard-case-card button { margin-top: auto; } +.dashboard-topic-context { + min-width: 0; + margin: 1.5rem 0; + border-top: 2px solid var(--color-dashboard-ink); + padding-top: 1rem; +} + +.dashboard-topic-context > header { + display: flex; + align-items: end; + justify-content: space-between; + gap: 1rem; +} + +.dashboard-topic-context > header p:last-child { max-width: 44rem; } + +.dashboard-topic-unavailable { + margin-top: 1rem; + border-left: 0.25rem solid var(--color-dashboard-ink); + padding: 1rem; + background: var(--color-dashboard-surface); +} + +.dashboard-topic-unavailable ul { margin-bottom: 0; } + +.dashboard-topic-list { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 1rem; +} + +.dashboard-topic { + min-width: 0; + border: 1px solid var(--color-border); + background: var(--color-background); +} + +.dashboard-topic > summary, +.dashboard-topic-provenance > summary { + min-height: 44px; + padding: 0.75rem 1rem; + color: var(--color-text-heading); + font-weight: 700; + cursor: pointer; +} + +.dashboard-topic > summary:focus-visible, +.dashboard-topic-provenance > summary:focus-visible, +.dashboard-topic-table-scroll:focus-visible { + outline: 3px solid var(--color-primary); + outline-offset: 2px; +} + +.dashboard-topic-timeline, +.dashboard-topic-lineage { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1rem; + margin: 0; + padding: 0.75rem 1rem; + border-top: 1px solid var(--color-border); + list-style: none; +} + +.dashboard-topic-context-group { + min-width: 0; + padding: 1rem; + border-top: 1px solid var(--color-border); +} + +.dashboard-topic-table-scroll { + overflow-x: auto; +} + +.dashboard-topic-table-scroll table { + width: 100%; + min-width: 56rem; + border-collapse: collapse; + text-align: left; +} + +.dashboard-topic-table-scroll caption { + padding-bottom: 0.5rem; + color: var(--color-text); + text-align: left; +} + +.dashboard-topic-table-scroll th, +.dashboard-topic-table-scroll td { + padding: 0.75rem; + border: 1px solid var(--color-border); + vertical-align: top; +} + +.dashboard-topic-table-scroll th { background: var(--color-dashboard-surface); } +.dashboard-topic-table-scroll code { overflow-wrap: anywhere; } + +.dashboard-topic-provenance { + margin-top: 1rem; + border: 1px solid var(--color-border); +} + +.dashboard-topic-provenance dl { margin: 0; padding: 0 1rem 1rem; } +.dashboard-topic-provenance dl div { display: grid; grid-template-columns: 10rem 1fr; gap: 1rem; padding: 0.5rem 0; border-top: 1px solid var(--color-border); } +.dashboard-topic-provenance dd { margin: 0; overflow-wrap: anywhere; } + @media (max-width: 900px) { .operations-dashboard { padding: 1rem; } .operations-dashboard-heading { align-items: start; flex-direction: column; } .dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .dashboard-case-metrics .dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .dashboard-case-grid { grid-template-columns: 1fr; } + .dashboard-topic-context > header { align-items: start; flex-direction: column; } + .dashboard-topic-provenance dl div { grid-template-columns: 1fr; gap: 0.25rem; } } @media (prefers-color-scheme: dark) { diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 0b5002cc6..1eb85a5d5 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -86,9 +86,70 @@ export interface OperationsDashboardResponse { event_count: number; post_count: number; }>; + topic_context: TopicContextDashboard; cases: OperationsDashboardCase[]; } +export interface TopicContextDashboard { + status_code: "accepted" | "unavailable"; + reason_code: string | null; + next_action: string; + required_contracts: Array<{ + authority: "TEPP" | "fast-mlsirm"; + schema_version: string; + state_code: "persisted" | "not_persisted"; + }>; + model_run: null | { + tepp_run_id: string; + tepp_snapshot_id: string; + source_snapshot_sha256: string; + knowledge_cutoff: string; + tepp_model_contract_version: string; + tepp_artifact_sha256: string; + posterior_draw_set_id: string; + posterior_draw_count: number; + topic_count: number; + fast_mlsirm_version: string; + fast_mlsirm_code_revision: string; + fast_mlsirm_artifact_sha256: string; + compute_backend_code: "rust_cpu" | "rust_gpu"; + precision_code: "f64" | "f32"; + membership_fingerprint_sha256: string; + }; + topics: Array<{ + topic_index: number; + activity_intervals: Array<{ + state_code: "active" | "dormant" | "reactivated"; + valid_from: string; + valid_to: string; + }>; + lineage_events: Array<{ + event_code: "birth" | "split" | "merge" | "retirement"; + source_topic_index: number; + target_topic_index: number | null; + event_time: string; + evidence_sha256: string; + }>; + contexts: Array<{ + dimension_code: "business_unit" | "process_unit" | "team" | "person"; + context_id: string; + context_label: string; + influences: Array<{ + post_id: string; + occurred_at: string; + topic_state_code: "active" | "dormant" | "reactivated"; + model_influence: number; + uncertainty_method_code: string; + uncertainty_lower_value: number; + uncertainty_upper_value: number; + diagnostic_status_code: "accepted"; + membership_weight: number; + membership_evidence_sha256: string; + }>; + }>; + }>; +} + export function fetchOperationsDashboard( accessToken: string, periodStart = "", diff --git a/frontend/src/components/OperationsDashboard.stories.tsx b/frontend/src/components/OperationsDashboard.stories.tsx index 1ca026c82..6d813019f 100644 --- a/frontend/src/components/OperationsDashboard.stories.tsx +++ b/frontend/src/components/OperationsDashboard.stories.tsx @@ -18,6 +18,14 @@ export const EvidenceReady: Story = { { case_kind_code: "external_information", case_kind_label: "발주 공고 · 시장 동향", event_count: 9, post_count: 9 }, { case_kind_code: "repeat_issue", case_kind_label: "반복 이슈", event_count: 2, post_count: 2 }, ], + topic_context: { + status_code: "unavailable", reason_code: "tepp_topic_posterior_not_persisted", + next_action: "TEPP posterior topic 계약 결과를 먼저 완료하세요.", model_run: null, topics: [], + required_contracts: [ + { authority: "TEPP", schema_version: "tepp.topic_context_posterior.v1", state_code: "not_persisted" }, + { authority: "fast-mlsirm", schema_version: "fast_mlsirm.topic_context_influence.v1", state_code: "not_persisted" }, + ], + }, failed_analysis_count: 0, cases: [ { post_id: "synthetic-post-1", case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 역추적", project_name: "Synthetic Transformer Renewal", summary_text: "사양 변경 이후 원인 수주와 Pool을 확인", evidence_text: "Revision B originated in order SO-100 from pool SP-20.", evidence_post_id: "synthetic-post-1", occurred_at: "2026-08-04T00:00:00Z", facts: [{ fact_type_code: "originating_order", fact_type_label: "원인 수주", value_text: "SO-100 · SP-20", evidence_text: "order SO-100 from pool SP-20", evidence_post_id: "synthetic-post-1" }], missing_facts: [{ fact_type_code: "order", fact_type_label: "발생 수주" }, { fact_type_code: "specification_change", fact_type_label: "사양 변경" }, { fact_type_code: "sales_pool", fact_type_label: "수주 Pool" }] }, @@ -36,6 +44,59 @@ export const EvidenceReady: Story = { }, }; +export const TopicInfluenceAccepted: Story = { + args: { + ...EvidenceReady.args, + data: { + ...EvidenceReady.args!.data!, + topic_context: { + status_code: "accepted", reason_code: null, + next_action: "Topic과 조직 수준을 선택해 model influence와 근거 글을 확인하세요.", + required_contracts: [ + { authority: "TEPP", schema_version: "tepp.topic_context_posterior.v1", state_code: "persisted" }, + { authority: "fast-mlsirm", schema_version: "fast_mlsirm.topic_context_influence.v1", state_code: "persisted" }, + ], + model_run: { + tepp_run_id: "synthetic-tepp-run", tepp_snapshot_id: "synthetic-tepp-snapshot", source_snapshot_sha256: "a".repeat(64), + knowledge_cutoff: "2026-08-20T00:00:00Z", tepp_model_contract_version: "trsl-tm-1", + tepp_artifact_sha256: "b".repeat(64), posterior_draw_set_id: "synthetic-draws", + posterior_draw_count: 32, topic_count: 2, fast_mlsirm_version: "0.1.0", + fast_mlsirm_code_revision: "c".repeat(40), fast_mlsirm_artifact_sha256: "d".repeat(64), + compute_backend_code: "rust_gpu", precision_code: "f64", membership_fingerprint_sha256: "e".repeat(64), + }, + topics: [{ + topic_index: 0, + activity_intervals: [ + { state_code: "dormant", valid_from: "2026-08-01T00:00:00Z", valid_to: "2026-08-10T00:00:00Z" }, + { state_code: "reactivated", valid_from: "2026-08-10T00:00:00Z", valid_to: "2026-09-01T00:00:00Z" }, + ], + lineage_events: [{ event_code: "birth", source_topic_index: 0, target_topic_index: null, event_time: "2026-08-01T00:00:00Z", evidence_sha256: "f".repeat(64) }], + contexts: [ + { + dimension_code: "business_unit", context_id: "bu-synthetic", context_label: "Synthetic Energy Division", + influences: [{ post_id: "synthetic-post-1", occurred_at: "2026-08-12T00:00:00Z", topic_state_code: "reactivated", model_influence: 4.25, uncertainty_method_code: "posterior_interval", uncertainty_lower_value: 3.5, uncertainty_upper_value: 5, diagnostic_status_code: "accepted", membership_weight: 0.6, membership_evidence_sha256: "1".repeat(64) }], + }, + { + dimension_code: "team", context_id: "team-synthetic", context_label: "Synthetic Service Team", + influences: [ + { post_id: "synthetic-post-1", occurred_at: "2026-08-12T00:00:00Z", topic_state_code: "reactivated", model_influence: 4.25, uncertainty_method_code: "posterior_interval", uncertainty_lower_value: 3.5, uncertainty_upper_value: 5, diagnostic_status_code: "accepted", membership_weight: 0.4, membership_evidence_sha256: "2".repeat(64) }, + { post_id: "synthetic-post-2", occurred_at: "2026-08-13T00:00:00Z", topic_state_code: "reactivated", model_influence: 4.25, uncertainty_method_code: "posterior_interval", uncertainty_lower_value: 3.4, uncertainty_upper_value: 5.1, diagnostic_status_code: "accepted", membership_weight: 1, membership_evidence_sha256: "3".repeat(64) }, + ], + }, + ], + }], + }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole("heading", { name: "시간 흐름별 Topic model influence" })).toBeVisible(); + await expect(canvas.getByText(/휴면 \/ 재활성/)).toBeVisible(); + await expect(canvas.getAllByText("4.25")).toHaveLength(3); + await expect(canvas.getByText(/순번이나 임의 가중치를 추가하지 않습니다/)).toBeVisible(); + }, +}; + export const NarrowViewport: Story = { ...EvidenceReady, parameters: { viewport: { defaultViewport: "mobile1" } } }; export const ExternalInformationEmpty: Story = { diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx index e0093ff24..9328c1235 100644 --- a/frontend/src/components/OperationsDashboard.test.tsx +++ b/frontend/src/components/OperationsDashboard.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; -import { fetchOperationsDashboard } from "../api"; +import { fetchOperationsDashboard, type OperationsDashboardResponse } from "../api"; import { OperationsDashboard, OperationsDashboardView } from "./OperationsDashboard"; vi.mock("../api", async (importOriginal) => ({ @@ -9,7 +9,7 @@ vi.mock("../api", async (importOriginal) => ({ fetchOperationsDashboard: vi.fn(), })); -const data = { +const data: OperationsDashboardResponse = { period_label: "2026-08-01–2026-08-25 · Event time", total_post_count: 20, total_event_count: 8, @@ -21,6 +21,17 @@ const data = { { case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 규명", event_count: 3, post_count: 2 }, { case_kind_code: "rebid_handover", case_kind_label: "재입찰 · 인수인계", event_count: 2, post_count: 2 }, ], + topic_context: { + status_code: "unavailable", + reason_code: "tepp_topic_posterior_not_persisted", + next_action: "TEPP posterior topic 계약 결과를 먼저 완료하세요.", + required_contracts: [ + { authority: "TEPP", schema_version: "tepp.topic_context_posterior.v1", state_code: "not_persisted" }, + { authority: "fast-mlsirm", schema_version: "fast_mlsirm.topic_context_influence.v1", state_code: "not_persisted" }, + ], + model_run: null, + topics: [], + }, cases: [{ post_id: "post-1", case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 역추적", project_name: "Synthetic Grid Upgrade", summary_text: "사양 변경 이후 원인 수주를 확인했습니다.", evidence_text: "Revision B changed the enclosure.", evidence_post_id: "evidence-post-1", occurred_at: "2026-08-12T00:00:00Z", @@ -93,6 +104,49 @@ describe("OperationsDashboardView", () => { expect(screen.queryByText("분석 대기 건부터 처리하세요")).not.toBeInTheDocument(); }); + it("keeps unavailable topic measurement actionable without a fallback score", () => { + render( undefined} />); + expect(screen.getByText("Topic model influence를 아직 표시할 수 없습니다.")).toBeInTheDocument(); + expect(screen.getByText("TEPP posterior topic 계약 결과를 먼저 완료하세요.")).toBeInTheDocument(); + expect(screen.queryByText(/추정 점수/)).not.toBeInTheDocument(); + }); + + it("opens accepted exact influence evidence and retains equal values", async () => { + const onOpenPost = vi.fn(); + const influence = { + post_id: "post-1", occurred_at: "2026-08-12T00:00:00Z", topic_state_code: "active" as const, + model_influence: 4.25, uncertainty_method_code: "posterior_interval", + uncertainty_lower_value: 3.5, uncertainty_upper_value: 5, + diagnostic_status_code: "accepted" as const, membership_weight: 0.5, + membership_evidence_sha256: "a".repeat(64), + }; + const accepted: OperationsDashboardResponse = { + ...data, + topic_context: { + status_code: "accepted", reason_code: null, next_action: "근거 글을 확인하세요.", + required_contracts: [ + { authority: "TEPP", schema_version: "tepp.topic_context_posterior.v1", state_code: "persisted" }, + { authority: "fast-mlsirm", schema_version: "fast_mlsirm.topic_context_influence.v1", state_code: "persisted" }, + ], + model_run: { + tepp_run_id: "tepp-run", tepp_snapshot_id: "tepp-snapshot", source_snapshot_sha256: "b".repeat(64), + knowledge_cutoff: "2026-08-20T00:00:00Z", tepp_model_contract_version: "trsl-tm-1", + tepp_artifact_sha256: "c".repeat(64), posterior_draw_set_id: "draws-1", posterior_draw_count: 32, + topic_count: 2, fast_mlsirm_version: "0.1.0", fast_mlsirm_code_revision: "d".repeat(40), + fast_mlsirm_artifact_sha256: "e".repeat(64), compute_backend_code: "rust_cpu", precision_code: "f64", + membership_fingerprint_sha256: "f".repeat(64), + }, + topics: [{ topic_index: 0, activity_intervals: [{ state_code: "active", valid_from: "2026-08-01T00:00:00Z", valid_to: "2026-09-01T00:00:00Z" }], lineage_events: [{ event_code: "birth", source_topic_index: 0, target_topic_index: null, event_time: "2026-08-01T00:00:00Z", evidence_sha256: "1".repeat(64) }], contexts: [{ dimension_code: "team", context_id: "team-1", context_label: "Synthetic Team", influences: [influence, { ...influence, post_id: "post-2" }] }] }], + }, + }; + render(); + expect(screen.getAllByText("4.25")).toHaveLength(2); + expect(screen.getByText((_, element) => element?.tagName === "LI" && element.textContent === "2026-08-01 · birth")).toBeInTheDocument(); + expect(screen.getByText("tepp-snapshot")).toBeInTheDocument(); + await userEvent.click(screen.getAllByRole("button", { name: "근거 글 열기" })[1]); + expect(onOpenPost).toHaveBeenCalledWith("post-2"); + }); + it("keeps period controls mounted while a changed period loads", async () => { vi.mocked(fetchOperationsDashboard) .mockResolvedValueOnce(data) diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx index 726ba4117..e36adfdbf 100644 --- a/frontend/src/components/OperationsDashboard.tsx +++ b/frontend/src/components/OperationsDashboard.tsx @@ -1,6 +1,19 @@ import { useEffect, useState } from "react"; import { fetchOperationsDashboard, type OperationsDashboardResponse } from "../api"; +const dimensionLabels = { + business_unit: "사업부", + process_unit: "PU", + team: "팀", + person: "개인", +} as const; + +const topicStateLabels = { + active: "활성", + dormant: "휴면", + reactivated: "재활성", +} as const; + type Props = { accessToken: string; externalOnly?: boolean; @@ -85,6 +98,9 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
) : null} + {!externalOnly ? ( + + ) : null} {!externalOnly && journeys.length ? (

프로젝트 여정

@@ -129,3 +145,80 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
); } + +/** Renders persisted ADR-0210 producer evidence without calculating a local score. */ +export function TopicContextInfluence({ data, onOpenPost }: { data: OperationsDashboardResponse; onOpenPost: (postId: string) => void }) { + const topicContext = data.topic_context; + return ( +
+
+

TEPP · fast-mlsirm

시간 흐름별 Topic model influence

+

사업 가치가 아닌, 해당 글을 제외했을 때 Topic·조직 수준 모형이 변하는 정도입니다.

+
+ {topicContext.status_code === "unavailable" ? ( +
+ Topic model influence를 아직 표시할 수 없습니다. +

{topicContext.next_action}

+
    {topicContext.required_contracts.map((contract) => ( +
  • {contract.authority} · {contract.schema_version} · {contract.state_code === "persisted" ? "저장 완료" : "승인 결과 없음"}
  • + ))}
+
+ ) : ( + <> +

{topicContext.next_action}

+
+ {topicContext.topics.map((topic) => ( +
+ Topic {topic.topic_index + 1} · {topic.activity_intervals.map((interval) => topicStateLabels[interval.state_code]).join(" / ")} +
    + {topic.activity_intervals.map((interval) => ( +
  • + {topicStateLabels[interval.state_code]} +
  • + ))} +
+ {topic.lineage_events.length ?
    + {topic.lineage_events.map((event) =>
  • · {event.event_code}{event.target_topic_index === null ? "" : ` → Topic ${event.target_topic_index + 1}`}
  • )} +
: null} + {topic.contexts.map((context) => ( +
+

{dimensionLabels[context.dimension_code]} · {context.context_label}

+
+ + + + {context.influences.map((influence) => ( + + + + + + + + + ))} +
값이 같으면 동점이며, 순번이나 임의 가중치를 추가하지 않습니다.
Event 발생일상태Model influence불확실성소속 근거원문
{topicStateLabels[influence.topic_state_code]}{influence.model_influence}{influence.uncertainty_lower_value}–{influence.uncertainty_upper_value} · {influence.uncertainty_method_code}weight {influence.membership_weight} · {influence.membership_evidence_sha256}
+
+
+ ))} +
+ ))} +
+ {topicContext.model_run ? ( +
+ 모형·실행 근거 +
+
TEPP run
{topicContext.model_run.tepp_run_id}
+
TEPP snapshot
{topicContext.model_run.tepp_snapshot_id}
+
Snapshot
{topicContext.model_run.source_snapshot_sha256}
+
Knowledge cutoff
+
Posterior draws
{topicContext.model_run.posterior_draw_count} · {topicContext.model_run.posterior_draw_set_id}
+
fast-mlsirm
{topicContext.model_run.fast_mlsirm_version} · {topicContext.model_run.compute_backend_code} · {topicContext.model_run.precision_code}
+
+
+ ) : null} + + )} +
+ ); +} diff --git a/migrations/0214_topic_context_influence_projection.sql b/migrations/0214_topic_context_influence_projection.sql new file mode 100644 index 000000000..13daab657 --- /dev/null +++ b/migrations/0214_topic_context_influence_projection.sql @@ -0,0 +1,208 @@ +-- ADR 0210: normalized TEPP topic and fast-mlsirm influence projection. +-- LineageWeave stores accepted producer evidence; it performs no estimator math. + +create table if not exists topic_model_run ( + topic_model_run_id uuid primary key default uuid_generate_v4(), + analysis_run_id uuid not null unique references analysis_run (analysis_run_id), + tepp_run_id text not null unique check (length(btrim(tepp_run_id)) between 1 and 256), + tepp_snapshot_id text not null check (length(btrim(tepp_snapshot_id)) between 1 and 256), + tepp_schema_version text not null check (tepp_schema_version = 'tepp.topic_context_posterior.v1'), + tepp_model_contract_version text not null check (length(btrim(tepp_model_contract_version)) between 1 and 256), + tepp_artifact_sha256 text not null unique check (tepp_artifact_sha256 ~ '^[0-9a-f]{64}$'), + reported_source_snapshot_sha256 text not null check (reported_source_snapshot_sha256 ~ '^[0-9a-f]{64}$'), + reported_knowledge_cutoff timestamptz not null, + posterior_draw_set_id text not null check (length(btrim(posterior_draw_set_id)) between 1 and 256), + posterior_draw_count integer not null check (posterior_draw_count > 0), + topic_count integer not null check (topic_count >= 2), + inference_status_code text not null check (inference_status_code = 'posterior_topic_coordinates_not_importance'), + accepted_at timestamptz not null default now() +); + +create table if not exists topic_definition ( + topic_model_run_id uuid not null references topic_model_run (topic_model_run_id) on delete cascade, + topic_index integer not null check (topic_index >= 0), + primary key (topic_model_run_id, topic_index) +); + +create table if not exists topic_activity_interval ( + topic_model_run_id uuid not null, + topic_index integer not null, + valid_from timestamptz not null, + valid_to timestamptz not null, + state_code text not null check (state_code in ('active', 'dormant', 'reactivated')), + primary key (topic_model_run_id, topic_index, valid_from), + foreign key (topic_model_run_id, topic_index) + references topic_definition (topic_model_run_id, topic_index) on delete cascade, + check (valid_from < valid_to) +); + +create table if not exists topic_lineage_relation ( + topic_model_run_id uuid not null, + relation_ordinal integer not null check (relation_ordinal >= 0), + event_code text not null check (event_code in ('birth', 'split', 'merge', 'retirement')), + source_topic_index integer not null, + target_topic_index integer, + event_time timestamptz not null, + evidence_sha256 text not null check (evidence_sha256 ~ '^[0-9a-f]{64}$'), + primary key (topic_model_run_id, relation_ordinal), + foreign key (topic_model_run_id, source_topic_index) + references topic_definition (topic_model_run_id, topic_index) on delete cascade, + foreign key (topic_model_run_id, target_topic_index) + references topic_definition (topic_model_run_id, topic_index) on delete cascade, + check ( + (event_code in ('split', 'merge') and target_topic_index is not null) + or (event_code in ('birth', 'retirement') and target_topic_index is null) + ) +); + +create table if not exists topic_context_definition ( + topic_model_run_id uuid not null references topic_model_run (topic_model_run_id) on delete cascade, + dimension_code text not null check (dimension_code in ('business_unit', 'process_unit', 'team', 'person')), + context_id text not null check (length(btrim(context_id)) between 1 and 256), + context_label text not null check (length(btrim(context_label)) between 1 and 512), + primary key (topic_model_run_id, dimension_code, context_id) +); + +create table if not exists topic_context_membership ( + topic_model_run_id uuid not null references topic_model_run (topic_model_run_id) on delete cascade, + topic_context_membership_id uuid not null default uuid_generate_v4(), + source_post_id uuid not null references source_post (post_id) on delete restrict, + dimension_code text not null check (dimension_code in ('business_unit', 'process_unit', 'team', 'person')), + context_id text not null check (length(btrim(context_id)) between 1 and 256), + membership_weight double precision not null check ( + membership_weight > 0 and membership_weight < 'Infinity'::double precision + ), + valid_from timestamptz not null, + valid_to timestamptz not null, + evidence_sha256 text not null check (evidence_sha256 ~ '^[0-9a-f]{64}$'), + primary key (topic_model_run_id, topic_context_membership_id), + unique (topic_model_run_id, source_post_id, dimension_code, context_id, valid_from), + foreign key (topic_model_run_id, dimension_code, context_id) + references topic_context_definition (topic_model_run_id, dimension_code, context_id) + on delete cascade, + check (valid_from < valid_to) +); + +create table if not exists topic_influence_run ( + topic_model_run_id uuid not null references topic_model_run (topic_model_run_id) on delete cascade, + topic_influence_run_id uuid not null default uuid_generate_v4(), + fast_mlsirm_schema_version text not null check (fast_mlsirm_schema_version = 'fast_mlsirm.topic_context_influence.v1'), + fast_mlsirm_version text not null check (length(btrim(fast_mlsirm_version)) between 1 and 128), + fast_mlsirm_code_revision text not null check (fast_mlsirm_code_revision ~ '^(?:[0-9a-f]{40}|[0-9a-f]{64})$'), + fast_mlsirm_artifact_sha256 text not null unique check (fast_mlsirm_artifact_sha256 ~ '^[0-9a-f]{64}$'), + reported_tepp_run_id text not null, + reported_snapshot_sha256 text not null check (reported_snapshot_sha256 ~ '^[0-9a-f]{64}$'), + reported_knowledge_cutoff timestamptz not null, + membership_fingerprint_sha256 text not null check (membership_fingerprint_sha256 ~ '^[0-9a-f]{64}$'), + compute_backend_code text not null check (compute_backend_code in ('rust_cpu', 'rust_gpu')), + precision_code text not null check (precision_code in ('f64', 'f32')), + posterior_draw_coverage integer not null check (posterior_draw_coverage > 0), + convergence_status_code text not null check (convergence_status_code = 'converged'), + identification_status_code text not null check (identification_status_code = 'identified'), + parity_status_code text not null check (parity_status_code = 'passed'), + accepted_at timestamptz not null default now(), + primary key (topic_model_run_id, topic_influence_run_id) +); + +create table if not exists topic_post_context_influence ( + topic_model_run_id uuid not null, + topic_influence_run_id uuid not null, + topic_context_membership_id uuid not null, + topic_index integer not null, + influence_value double precision not null check ( + influence_value >= 0 and influence_value < 'Infinity'::double precision + ), + uncertainty_method_code text not null check (length(btrim(uncertainty_method_code)) between 1 and 128), + uncertainty_lower_value double precision not null check ( + uncertainty_lower_value >= 0 and uncertainty_lower_value < 'Infinity'::double precision + ), + uncertainty_upper_value double precision not null check ( + uncertainty_upper_value >= uncertainty_lower_value + and uncertainty_upper_value < 'Infinity'::double precision + ), + diagnostic_status_code text not null check (diagnostic_status_code = 'accepted'), + primary key ( + topic_model_run_id, + topic_influence_run_id, + topic_context_membership_id, + topic_index + ), + foreign key (topic_model_run_id, topic_influence_run_id) + references topic_influence_run (topic_model_run_id, topic_influence_run_id) on delete cascade, + foreign key (topic_model_run_id, topic_context_membership_id) + references topic_context_membership (topic_model_run_id, topic_context_membership_id) on delete cascade, + foreign key (topic_model_run_id, topic_index) + references topic_definition (topic_model_run_id, topic_index) on delete cascade +); + +create index if not exists topic_activity_interval_time_idx + on topic_activity_interval (valid_from, valid_to, topic_model_run_id, topic_index); +create index if not exists topic_context_membership_post_time_idx + on topic_context_membership (source_post_id, valid_from, valid_to, topic_model_run_id); +create index if not exists topic_post_context_influence_read_idx + on topic_post_context_influence (topic_model_run_id, topic_index, influence_value desc); + +create or replace function validate_topic_model_run_binding() +returns trigger +language plpgsql +as $$ +declare + canonical_snapshot_sha256 text; + canonical_knowledge_cutoff timestamptz; + canonical_run_kind_code text; +begin + select snapshot.snapshot_sha256, run.knowledge_cutoff, run.run_kind_code + into canonical_snapshot_sha256, canonical_knowledge_cutoff, canonical_run_kind_code + from analysis_run run + join analysis_source_snapshot snapshot + on snapshot.analysis_source_snapshot_id = run.analysis_source_snapshot_id + where run.analysis_run_id = new.analysis_run_id; + + if canonical_run_kind_code is distinct from 'analysis_run_topic_lineage' + or new.reported_source_snapshot_sha256 is distinct from canonical_snapshot_sha256 + or new.reported_knowledge_cutoff is distinct from canonical_knowledge_cutoff then + raise exception 'topic_model_run_provenance_binding_mismatch'; + end if; + return new; +end +$$; + +drop trigger if exists topic_model_run_binding_check on topic_model_run; +create trigger topic_model_run_binding_check +before insert or update on topic_model_run +for each row execute function validate_topic_model_run_binding(); + +create or replace function validate_topic_influence_run_binding() +returns trigger +language plpgsql +as $$ +declare + canonical_tepp_run_id text; + canonical_snapshot_sha256 text; + canonical_knowledge_cutoff timestamptz; + canonical_draw_count integer; +begin + select model.tepp_run_id, snapshot.snapshot_sha256, run.knowledge_cutoff, + model.posterior_draw_count + into canonical_tepp_run_id, canonical_snapshot_sha256, + canonical_knowledge_cutoff, canonical_draw_count + from topic_model_run model + join analysis_run run on run.analysis_run_id = model.analysis_run_id + join analysis_source_snapshot snapshot + on snapshot.analysis_source_snapshot_id = run.analysis_source_snapshot_id + where model.topic_model_run_id = new.topic_model_run_id; + + if new.reported_tepp_run_id is distinct from canonical_tepp_run_id + or new.reported_snapshot_sha256 is distinct from canonical_snapshot_sha256 + or new.reported_knowledge_cutoff is distinct from canonical_knowledge_cutoff + or new.posterior_draw_coverage is distinct from canonical_draw_count then + raise exception 'topic_influence_provenance_binding_mismatch'; + end if; + return new; +end +$$; + +drop trigger if exists topic_influence_run_binding_check on topic_influence_run; +create trigger topic_influence_run_binding_check +before insert or update on topic_influence_run +for each row execute function validate_topic_influence_run_binding(); diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py index 25631b0ba..343df2b39 100644 --- a/tests/test_operations_dashboard.py +++ b/tests/test_operations_dashboard.py @@ -15,6 +15,11 @@ def __init__(self) -> None: async def fetchrow(self, query: str, *args: object) -> dict[str, int]: self.queries.append((query, args)) + if "tepp_posterior_persisted" in query: + return { + "tepp_posterior_persisted": False, + "fast_mlsirm_influence_persisted": False, + } return { "total_post_count": 4, "total_event_count": 3, @@ -44,6 +49,8 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: "case_kind_code": "claim_investigation", "fact_type_code": "sales_pool", }] + if "from topic_post_context_influence influence" in query: + return [] return [ { "post_id": "00000000-0000-0000-0000-000000000001", @@ -135,7 +142,9 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None: ], } ] - assert len(conn.queries) == 4 + assert result["topic_context"]["status_code"] == "unavailable" + assert result["topic_context"]["reason_code"] == "tepp_topic_posterior_not_persisted" + assert len(conn.queries) == 6 for query, args in conn.queries: assert "visibility_code = 'public'" in query assert "corporate_entity_id::text = any($1::text[])" in query @@ -152,6 +161,98 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None: assert "coalesce(nullif(btrim(post.source_project_name), ''), project.primary_project_name)" in case_query +@pytest.mark.anyio +async def test_dashboard_projects_exact_topic_influence_without_local_scoring() -> None: + """Accepted rows retain ties, membership evidence, and producer identity.""" + + class TopicConnection(_Connection): + async def fetchrow(self, query: str, *args: object) -> dict[str, object]: + if "tepp_posterior_persisted" in query: + self.queries.append((query, args)) + return { + "tepp_posterior_persisted": True, + "fast_mlsirm_influence_persisted": True, + } + return await super().fetchrow(query, *args) + + async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: + if "from topic_post_context_influence influence" not in query: + return await super().fetch(query, *args) + self.queries.append((query, args)) + common = { + "topic_model_run_id": "model-1", + "tepp_run_id": "tepp-1", + "tepp_snapshot_id": "tepp-snapshot-1", + "tepp_schema_version": "tepp.topic_context_posterior.v1", + "tepp_model_contract_version": "trsl-tm-1", + "tepp_artifact_sha256": "a" * 64, + "posterior_draw_set_id": "draws-1", + "posterior_draw_count": 32, + "topic_count": 2, + "source_snapshot_sha256": "b" * 64, + "knowledge_cutoff": datetime(2026, 8, 20, tzinfo=timezone.utc), + "topic_influence_run_id": "influence-1", + "fast_mlsirm_schema_version": "fast_mlsirm.topic_context_influence.v1", + "fast_mlsirm_version": "0.1.0", + "fast_mlsirm_code_revision": "c" * 40, + "fast_mlsirm_artifact_sha256": "d" * 64, + "compute_backend_code": "rust_gpu", + "precision_code": "f64", + "membership_fingerprint_sha256": "e" * 64, + "topic_index": 0, + "state_code": "reactivated", + "activity_valid_from": datetime(2026, 8, 1, tzinfo=timezone.utc), + "activity_valid_to": datetime(2026, 9, 1, tzinfo=timezone.utc), + "dimension_code": "team", + "context_id": "team-synthetic", + "context_label": "Synthetic Service Team", + "membership_weight": 0.5, + "membership_evidence_sha256": "f" * 64, + "occurred_at": datetime(2026, 8, 12, tzinfo=timezone.utc), + "influence_value": 4.25, + "uncertainty_method_code": "posterior_interval", + "uncertainty_lower_value": 3.5, + "uncertainty_upper_value": 5.0, + "diagnostic_status_code": "accepted", + "lineage_events": '[{"event_code":"birth","source_topic_index":0,"target_topic_index":null,"event_time":"2026-08-01T00:00:00+00:00","evidence_sha256":"' + "1" * 64 + '"}]', + } + return [ + {**common, "source_post_id": "00000000-0000-0000-0000-000000000001"}, + { + **common, + "source_post_id": "00000000-0000-0000-0000-000000000002", + "lineage_events": [{"event_code": "birth"}], + }, + ] + + result = await fetch_operations_dashboard(TopicConnection(), []) + topic_context = result["topic_context"] + assert topic_context["status_code"] == "accepted" + assert topic_context["model_run"]["compute_backend_code"] == "rust_gpu" + influences = topic_context["topics"][0]["contexts"][0]["influences"] + assert [item["model_influence"] for item in influences] == [4.25, 4.25] + assert influences[0]["membership_weight"] == 0.5 + assert topic_context["topics"][0]["lineage_events"][0]["event_code"] == "birth" + + +@pytest.mark.anyio +async def test_dashboard_names_missing_fast_result_after_tepp_persistence() -> None: + """A persisted TEPP membership never becomes a fabricated influence value.""" + + class TeppOnlyConnection(_Connection): + async def fetchrow(self, query: str, *args: object) -> dict[str, object]: + if "tepp_posterior_persisted" in query: + self.queries.append((query, args)) + return { + "tepp_posterior_persisted": True, + "fast_mlsirm_influence_persisted": False, + } + return await super().fetchrow(query, *args) + + result = await fetch_operations_dashboard(TeppOnlyConnection(), []) + assert result["topic_context"]["reason_code"] == "fast_mlsirm_influence_not_persisted" + assert result["topic_context"]["topics"] == [] + @pytest.mark.anyio async def test_external_scope_is_bound_in_every_dashboard_query() -> None: """The external destination restricts data at the API query boundary.""" @@ -171,6 +272,11 @@ async def test_dashboard_zero_denominator_and_invalid_period() -> None: class EmptyConnection(_Connection): async def fetchrow(self, query: str, *args: object) -> dict[str, int]: self.queries.append((query, args)) + if "tepp_posterior_persisted" in query: + return { + "tepp_posterior_persisted": False, + "fast_mlsirm_influence_persisted": False, + } return dict.fromkeys( ("total_post_count", "total_event_count", "external_post_count", "pending_analysis_count", "failed_analysis_count"), 0, @@ -183,6 +289,8 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: empty = await fetch_operations_dashboard(EmptyConnection(), []) assert empty["external_percent"] == 0.0 assert all(metric["event_count"] == metric["post_count"] == 0 for metric in empty["case_metrics"]) + assert (await fetch_operations_dashboard(EmptyConnection(), [], [], date(2026, 8, 1)))["period_label"] == "2026-08-01 이후 · Event 발생일" + assert (await fetch_operations_dashboard(EmptyConnection(), [], [], None, date(2026, 8, 31)))["period_label"] == "2026-08-31 이전 · Event 발생일" with pytest.raises(ValueError, match="period_start"): await fetch_operations_dashboard( EmptyConnection(), [], [], date(2026, 9, 1), date(2026, 8, 31) @@ -194,8 +302,19 @@ async def test_external_information_projects_a_typed_prov_o_relation() -> None: """A cited semantic target becomes RDF reification, never a KG alias.""" class ExternalConnection(_Connection): + async def fetchrow(self, query: str, *args: object) -> dict[str, object]: + if "tepp_posterior_persisted" in query: + self.queries.append((query, args)) + return { + "tepp_posterior_persisted": False, + "fast_mlsirm_influence_persisted": False, + } + return await super().fetchrow(query, *args) + async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: self.queries.append((query, args)) + if "from topic_post_context_influence influence" in query: + return [] if "operations_case_fact fact" in query: return [{ "post_id": "00000000-0000-0000-0000-000000000001", diff --git a/tests/test_schema.py b/tests/test_schema.py index 0b4aaae69..5f7c730aa 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -95,6 +95,17 @@ _OPERATIONS_CASE_MISSING_MIGRATION = ( Path(__file__).resolve().parents[1] / "migrations" / "0211_operations_case_missing_fact.sql" ) +_ANALYSIS_RUN_REGISTRY_MIGRATION = ( + Path(__file__).resolve().parents[1] / "migrations" / "0018_analysis_run_registry.sql" +) +_TOPIC_CONTEXT_INFLUENCE_MIGRATION = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0214_topic_context_influence_projection.sql" +) +_TOPIC_LINEAGE_KIND_MIGRATION = ( + Path(__file__).resolve().parents[1] / "migrations" / "0131_analysis_run_topic_lineage_kind.sql" +) _OPERATIONS_EXTERNAL_RELATION_MIGRATION = ( Path(__file__).resolve().parents[1] / "migrations" @@ -132,6 +143,8 @@ def schema_db(): try: with conn.cursor() as cur: cur.execute(_MIGRATION_PATH.read_text()) + cur.execute(_ANALYSIS_RUN_REGISTRY_MIGRATION.read_text()) + cur.execute(_TOPIC_LINEAGE_KIND_MIGRATION.read_text()) cur.execute(_PROJECT_MENTION_MIGRATION.read_text()) cur.execute(_MAJOR_EVENT_ACTION_MIGRATION.read_text()) cur.execute(_PROJECT_BOUND_ACTION_MIGRATION.read_text()) @@ -148,6 +161,7 @@ def schema_db(): cur.execute(_OPERATIONS_CASE_MIGRATION.read_text()) cur.execute(_OPERATIONS_CASE_EVIDENCE_MIGRATION.read_text()) cur.execute(_OPERATIONS_CASE_MISSING_MIGRATION.read_text()) + cur.execute(_TOPIC_CONTEXT_INFLUENCE_MIGRATION.read_text()) cur.execute(_OPERATIONS_EXTERNAL_RELATION_MIGRATION.read_text()) conn.commit() yield conn @@ -207,10 +221,146 @@ def test_migration_applies_cleanly(schema_db) -> None: "operations_case_classification", "operations_case_fact", "operations_case_missing_fact", + "topic_model_run", + "topic_definition", + "topic_activity_interval", + "topic_lineage_relation", + "topic_context_definition", + "topic_context_membership", + "topic_influence_run", + "topic_post_context_influence", } assert expected <= tables +def test_topic_influence_schema_binds_exact_producer_provenance(schema_db) -> None: + """Accepted influence runs cannot cross a TEPP run, snapshot, or cutoff.""" + with schema_db.cursor() as cur: + cur.execute( + """ + select tgname + from pg_trigger + where tgname = 'topic_influence_run_binding_check' + and not tgisinternal + """ + ) + assert cur.fetchone() == ("topic_influence_run_binding_check",) + cur.execute( + """ + select tgname + from pg_trigger + where tgname = 'topic_model_run_binding_check' + and not tgisinternal + """ + ) + assert cur.fetchone() == ("topic_model_run_binding_check",) + cur.execute( + """ + select conname + from pg_constraint + where conrelid = 'topic_post_context_influence'::regclass + and contype = 'f' + """ + ) + foreign_keys = {row[0] for row in cur.fetchall()} + assert len(foreign_keys) == 3 + + account_id = uuid.uuid4() + snapshot_id = uuid.uuid4() + run_id = uuid.uuid4() + with schema_db.cursor() as cur: + cur.execute( + "insert into user_account (user_account_id, external_subject_id, display_name, email_address) values (%s, %s, %s, %s)", + (str(account_id), f"synthetic-{account_id}", "Synthetic Reviewer", f"synthetic-{account_id}@example.invalid"), + ) + cur.execute( + """ + insert into analysis_source_snapshot + (analysis_source_snapshot_id, snapshot_sha256, source_contract_version, + maximum_available_time, captured_at, created_at) + values (%s, %s, 'synthetic-v1', '2026-08-01T00:00:00Z', + '2026-08-02T00:00:00Z', '2026-08-03T00:00:00Z') + """, + (str(snapshot_id), "a" * 64), + ) + cur.execute( + """ + insert into analysis_run + (analysis_run_id, analysis_source_snapshot_id, run_kind_code, + requested_by_account_id, idempotency_key, knowledge_cutoff, + configuration_schema_version, configuration_sha256, + code_revision_sha, requested_at) + values (%s, %s, 'analysis_run_topic_lineage', %s, 'synthetic-topic-run', + '2026-08-01T00:00:00Z', 'synthetic-v1', %s, %s, + '2026-08-04T00:00:00Z') + """, + (str(run_id), str(snapshot_id), str(account_id), "b" * 64, "c" * 40), + ) + cur.execute("savepoint topic_model_mismatch") + with pytest.raises(psycopg2.errors.RaiseException, match="topic_model_run_provenance_binding_mismatch"): + cur.execute( + """ + insert into topic_model_run + (analysis_run_id, tepp_run_id, tepp_snapshot_id, + tepp_schema_version, tepp_model_contract_version, + tepp_artifact_sha256, reported_source_snapshot_sha256, + reported_knowledge_cutoff, posterior_draw_set_id, + posterior_draw_count, topic_count, inference_status_code) + values (%s, 'tepp-mismatch', 'snapshot-mismatch', + 'tepp.topic_context_posterior.v1', 'trsl-tm-v1', %s, %s, + '2026-08-01T00:00:00Z', 'draws-1', 8, 2, + 'posterior_topic_coordinates_not_importance') + """, + (str(run_id), "d" * 64, "e" * 64), + ) + cur.execute("rollback to savepoint topic_model_mismatch") + cur.execute( + """ + insert into topic_model_run + (analysis_run_id, tepp_run_id, tepp_snapshot_id, + tepp_schema_version, tepp_model_contract_version, + tepp_artifact_sha256, reported_source_snapshot_sha256, + reported_knowledge_cutoff, posterior_draw_set_id, + posterior_draw_count, topic_count, inference_status_code) + values (%s, 'tepp-accepted', 'snapshot-accepted', + 'tepp.topic_context_posterior.v1', 'trsl-tm-v1', %s, %s, + '2026-08-01T00:00:00Z', 'draws-1', 8, 2, + 'posterior_topic_coordinates_not_importance') + returning topic_model_run_id + """, + (str(run_id), "d" * 64, "a" * 64), + ) + model_id = cur.fetchone()[0] + cur.execute("savepoint topic_influence_mismatch") + with pytest.raises(psycopg2.errors.RaiseException, match="topic_influence_provenance_binding_mismatch"): + cur.execute( + """ + insert into topic_influence_run + (topic_model_run_id, fast_mlsirm_schema_version, + fast_mlsirm_version, fast_mlsirm_code_revision, + fast_mlsirm_artifact_sha256, reported_tepp_run_id, + reported_snapshot_sha256, reported_knowledge_cutoff, + membership_fingerprint_sha256, compute_backend_code, + precision_code, posterior_draw_coverage, + convergence_status_code, identification_status_code, + parity_status_code) + values (%s, 'fast_mlsirm.topic_context_influence.v1', '0.1.0', + %s, %s, 'different-tepp-run', %s, + '2026-08-01T00:00:00Z', %s, 'rust_cpu', 'f64', 8, + 'converged', 'identified', 'passed') + """, + (model_id, "f" * 40, "1" * 64, "a" * 64, "2" * 64), + ) + cur.execute("rollback to savepoint topic_influence_mismatch") + + +def test_topic_influence_projection_migration_replays(schema_db) -> None: + """The additive topic projection remains safe under sorted startup replay.""" + with schema_db.cursor() as cur: + cur.execute(_TOPIC_CONTEXT_INFLUENCE_MIGRATION.read_text()) + schema_db.commit() + + def test_post_lineage_edge_requires_an_allen_interval_code(schema_db) -> None: with schema_db.cursor() as cur: cur.execute( From a249efd65919b3b7e3542147110c2e6c7a5d66a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 09:32:09 -0700 Subject: [PATCH 026/393] fix(dashboard): separate semantic relation labels from values (#651) Co-authored-by: Codex --- frontend/src/App.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index cf34add79..f3f8fd08b 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1346,7 +1346,7 @@ } .dashboard-case-card dl { margin: 0; } -.dashboard-case-card dl div { display: grid; grid-template-columns: 8rem 1fr; padding: 0.5rem 0; border-top: 1px solid var(--color-border); } +.dashboard-case-card dl div { display: grid; grid-template-columns: 8rem 1fr; gap: var(--space-control-gap); padding: 0.5rem 0; border-top: 1px solid var(--color-border); } .dashboard-case-card dd { margin: 0; font-weight: 600; } .dashboard-case-card button { margin-top: auto; } From acf80790bebe14e330e91be15745de76a85365c7 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 01:43:36 +0900 Subject: [PATCH 027/393] perf(build): cache Rust-backed backend dependencies --- backend/Dockerfile | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index eb6b86288..14851ad43 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -20,9 +20,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ sh -s -- -y --profile minimal --default-toolchain 1.97.1 -ENV PATH="/app/.venv/bin:/root/.cargo/bin:${PATH}" +ENV PATH="/app/.venv/bin:/root/.cargo/bin:${PATH}" \ + UV_LINK_MODE=copy COPY pyproject.toml uv.lock README.md ./ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev --extra backend --no-install-project --no-editable COPY lineageweave ./lineageweave COPY backend ./backend # lineageweave/ontology.py resolves this path relative to itself @@ -31,7 +34,8 @@ COPY docs/ontology ./docs/ontology # Install exactly the committed universal lock. --no-editable prevents a # runtime dependency on source-tree editability while retaining package data. -RUN uv sync --frozen --no-dev --extra backend --no-editable \ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev --extra backend --no-editable \ && chown -R appuser:appuser /app USER appuser From 6f4211915aaa428769bbd9a09531320f2c129fda Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 09:45:14 -0700 Subject: [PATCH 028/393] feat(dashboard): measure observed case lifecycles (#653) * feat(dashboard): measure observed case lifecycles * merge: preserve observed milestone dashboard contract --------- Co-authored-by: Codex --- ARCHITECTURE.md | 12 ++ backend/app/operations_case_ingestion.py | 34 ++- backend/app/operations_dashboard.py | 197 ++++++++++++++--- backend/app/post_content_worker.py | 37 +++- .../adr/0206-evidence-operations-dashboard.md | 22 +- docs/product-requirements.md | 8 +- docs/product-technical-gap-baseline.md | 6 +- frontend/src/App.css | 22 ++ frontend/src/api.ts | 32 +++ .../OperationsDashboard.stories.tsx | 14 +- .../components/OperationsDashboard.test.tsx | 12 ++ .../src/components/OperationsDashboard.tsx | 41 ++++ lineageweave/operations_case_analysis.py | 201 ++++++++++++++++-- migrations/0215_operations_case_milestone.sql | 36 ++++ tests/test_operations_case_analysis.py | 154 +++++++++++--- tests/test_operations_case_ingestion.py | 74 ++++++- tests/test_operations_dashboard.py | 100 ++++++++- tests/test_post_content_worker.py | 34 +++ tests/test_schema.py | 29 ++- 19 files changed, 957 insertions(+), 108 deletions(-) create mode 100644 migrations/0215_operations_case_milestone.sql diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3a55015f5..af325c01c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -751,6 +751,18 @@ HTML-wrapped, base64-image-embedded version of the existing people through the live `/extract-keymen` endpoint (`test_extract_keymen_normalizes_html_and_embedded_image_content`). +## Evidence-operations lifecycle projection + +ADR 0206's Dashboard persists a semantic classification separately from its +facts and observed milestones. `operations_case_milestone` binds a closed XES- +style activity code to an exact evidence span, evidence-post digest, observed +instant, and named source clock; `operations_case_missing_milestone` records an +unsupported required endpoint without fabricating one. The Dashboard pairs +only the three declared start/end definitions for claim investigation, rebid +response, and handover. Both endpoints yield `end - start`; a cited start plus +a missing end is open with nullable elapsed time. API projection rechecks +current ABAC for focal and evidence posts before returning either span. + ## Phase 6d: external search verification for Ontology relation inferences The brief requires an external web/internal search agent to check the diff --git a/backend/app/operations_case_ingestion.py b/backend/app/operations_case_ingestion.py index ea92c4f22..f6b1bbed8 100644 --- a/backend/app/operations_case_ingestion.py +++ b/backend/app/operations_case_ingestion.py @@ -36,7 +36,9 @@ async def persist_operations_cases( ) -> None: """Atomically replace one post's normalized case analysis.""" async with conn.transaction(): - await conn.execute("delete from operations_case_analysis where post_id = $1", post_id) + await conn.execute( + "delete from operations_case_analysis where post_id = $1", post_id + ) await conn.execute( "insert into operations_case_analysis (post_id, source_body_sha256, orchestrator_session_id) values ($1, $2, $3)", post_id, @@ -64,5 +66,33 @@ async def persist_operations_cases( if case.missing_fact_type_codes: await conn.executemany( "insert into operations_case_missing_fact (post_id, case_kind_code, fact_type_code) values ($1, $2, $3)", - [(post_id, case.case_kind_code, code) for code in case.missing_fact_type_codes], + [ + (post_id, case.case_kind_code, code) + for code in case.missing_fact_type_codes + ], + ) + if case.milestones: + await conn.executemany( + "insert into operations_case_milestone (post_id, case_kind_code, milestone_type_code, evidence_text, evidence_post_id, evidence_input_sha256, observed_at, time_axis_code) values ($1, $2, $3, $4, $5, $6, $7, $8)", + [ + ( + post_id, + case.case_kind_code, + milestone.milestone_type_code, + milestone.evidence_text, + milestone.evidence_post_id, + milestone.evidence_input_sha256, + milestone.observed_at, + milestone.time_axis_code, + ) + for milestone in case.milestones + ], + ) + if case.missing_milestone_type_codes: + await conn.executemany( + "insert into operations_case_missing_milestone (post_id, case_kind_code, milestone_type_code) values ($1, $2, $3)", + [ + (post_id, case.case_kind_code, code) + for code in case.missing_milestone_type_codes + ], ) diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py index 74ceece09..4238ecc8d 100644 --- a/backend/app/operations_dashboard.py +++ b/backend/app/operations_dashboard.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import date +from datetime import date, datetime import json from typing import Any, Protocol @@ -30,6 +30,19 @@ "issue_pattern": "반복 유형", "improvement_action": "개선 조치", } +MILESTONE_TYPE_LABELS = { + "claim_received": "클레임 접수", + "cause_confirmed": "원인 확정", + "rebid_response_requested": "재입찰 대응 요청", + "rebid_decision_recorded": "재입찰 의사결정", + "handover_started": "인수인계 시작", + "handover_accepted": "인수 확인", +} +LIFECYCLE_DEFINITIONS = ( + ("claim_investigation", "claim_investigation", "클레임 원인 규명", "claim_received", "cause_confirmed"), + ("rebid_response", "rebid_handover", "재입찰 대응", "rebid_response_requested", "rebid_decision_recorded"), + ("handover_gap", "rebid_handover", "인수인계 공백", "handover_started", "handover_accepted"), +) CASE_KIND_ONTOLOGY_CLASSES = { "claim_investigation": str(LW.ClaimInvestigation), "rebid_handover": str(LW.RebidHandover), @@ -114,14 +127,21 @@ async def fetch(self, query: str, *args: object) -> list[Any]: pass # pragma: no cover - structural Protocol member -def _visible_period_sql(alias: str = "post") -> str: - """Return the shared ABAC, eligibility, and event-clock predicate.""" +def _visible_scope_sql(alias: str = "post") -> str: + """Return the shared ABAC and source-eligibility predicate.""" return f""" ({alias}.visibility_code = 'public' or ({alias}.corporate_entity_id::text = any($1::text[]) and (cardinality($2::text[]) = 0 or {alias}.process_unit_id::text = any($2::text[])))) and {SOURCE_POST_ELIGIBILITY_SQL.format(alias=alias)} + """ + + +def _visible_period_sql(alias: str = "post") -> str: + """Return the shared visibility predicate plus the requested event interval.""" + return f""" + {_visible_scope_sql(alias)} and ($3::date is null or (coalesce({alias}.event_occurred_at, {alias}.created_at) at time zone 'Asia/Seoul')::date >= $3) and ($4::date is null or (coalesce({alias}.event_occurred_at, {alias}.created_at) @@ -142,6 +162,7 @@ async def fetch_operations_dashboard( raise ValueError("period_start must not be after period_end") args = (list(corporate_entity_ids), list(process_unit_ids), period_start, period_end, external_only) visible = _visible_period_sql() + visible_evidence = _visible_scope_sql("evidence_post") metrics = await conn.fetchrow( f""" with visible_post as ( @@ -158,6 +179,9 @@ async def fetch_operations_dashboard( select classification.post_id, classification.case_kind_code from operations_case_classification classification join visible_post on visible_post.post_id = classification.post_id + join source_post evidence_post + on evidence_post.post_id = classification.evidence_post_id + where {visible_evidence} ) select (select count(*) from visible_post) as total_post_count, (select count(*) @@ -200,6 +224,8 @@ async def fetch_operations_dashboard( where summary_event.post_id = classification.post_id) as event_count from operations_case_classification classification join source_post post on post.post_id = classification.post_id + join source_post evidence_post + on evidence_post.post_id = classification.evidence_post_id left join lateral ( select array_agg(names.project_name order by names.project_name) as project_names, ( @@ -220,8 +246,9 @@ async def fetch_operations_dashboard( ) names where names.project_name is not null ) project on true - where {visible} - and ($5::boolean is false or classification.case_kind_code = 'external_information') + where {visible} + and {visible_evidence} + and ($5::boolean is false or classification.case_kind_code = 'external_information') order by coalesce(post.event_occurred_at, post.created_at) desc, classification.post_id, classification.case_kind_code """, @@ -234,7 +261,9 @@ async def fetch_operations_dashboard( fact.fact_ordinal, fact.relation_target_kind_code from operations_case_fact fact join source_post post on post.post_id = fact.post_id + join source_post evidence_post on evidence_post.post_id = fact.evidence_post_id where {visible} + and {visible_evidence} and ($5::boolean is false or fact.case_kind_code = 'external_information') order by fact.post_id, fact.case_kind_code, fact.fact_ordinal """, @@ -246,11 +275,35 @@ async def fetch_operations_dashboard( from operations_case_missing_fact missing join source_post post on post.post_id = missing.post_id where {visible} + and {visible_evidence} and ($5::boolean is false or missing.case_kind_code = 'external_information') order by missing.post_id, missing.case_kind_code, missing.fact_type_code """, *args, ) + milestone_rows = await conn.fetch( + f""" + select milestone.post_id, milestone.case_kind_code, + milestone.milestone_type_code, milestone.evidence_text, + milestone.evidence_post_id, milestone.observed_at, + milestone.time_axis_code, false as is_missing + from operations_case_milestone milestone + join source_post post on post.post_id = milestone.post_id + join source_post evidence_post on evidence_post.post_id = milestone.evidence_post_id + where {visible} + and {visible_evidence} + and ($5::boolean is false or milestone.case_kind_code = 'external_information') + union all + select missing.post_id, missing.case_kind_code, + missing.milestone_type_code, null, null, null, null, true + from operations_case_missing_milestone missing + join source_post post on post.post_id = missing.post_id + where {visible} + and ($5::boolean is false or missing.case_kind_code = 'external_information') + order by post_id, case_kind_code, milestone_type_code + """, + *args, + ) topic_context = ( { "status_code": "not_applicable", @@ -292,6 +345,30 @@ async def fetch_operations_dashboard( "fact_type_label": FACT_TYPE_LABELS[row["fact_type_code"]], } ) + milestones: dict[tuple[str, str], list[dict[str, Any]]] = {} + missing_milestones: dict[tuple[str, str], set[str]] = {} + for row in milestone_rows: + key = (str(row["post_id"]), row["case_kind_code"]) + if row["is_missing"]: + missing_milestones.setdefault(key, set()).add(row["milestone_type_code"]) + continue + milestones.setdefault(key, []).append( + { + "milestone_type_code": row["milestone_type_code"], + "milestone_type_label": MILESTONE_TYPE_LABELS[ + row["milestone_type_code"] + ], + "evidence_text": row["evidence_text"], + "evidence_post_id": str(row["evidence_post_id"]), + "observed_at": row["observed_at"].isoformat(), + "time_axis_code": row["time_axis_code"], + "time_axis_label": ( + "Event 발생일" + if row["time_axis_code"] == "event_occurred_at" + else "기록 생성일" + ), + } + ) total = int(metrics["total_post_count"]) external = int(metrics["external_post_count"]) case_post_ids: dict[str, set[str]] = {} @@ -300,6 +377,50 @@ async def fetch_operations_dashboard( kind = row["case_kind_code"] case_post_ids.setdefault(kind, set()).add(str(row["post_id"])) case_event_counts[kind] = case_event_counts.get(kind, 0) + int(row["event_count"]) + projected_cases = [] + lifecycle_metrics = { + lifecycle_code: { + "lifecycle_kind_code": lifecycle_code, + "lifecycle_kind_label": label, + "open_case_count": 0, + "resolved_case_count": 0, + "evidence_missing_case_count": 0, + } + for lifecycle_code, _kind, label, _start, _end in LIFECYCLE_DEFINITIONS + } + for row in case_rows: + key = (str(row["post_id"]), row["case_kind_code"]) + case_milestones = milestones.get(key, []) + case_lifecycles = _project_lifecycles( + row["case_kind_code"], case_milestones, missing_milestones.get(key, set()) + ) + for lifecycle in case_lifecycles: + lifecycle_metrics[lifecycle["lifecycle_kind_code"]][ + f"{lifecycle['status_code']}_case_count" + ] += 1 + projected_cases.append( + { + "post_id": str(row["post_id"]), + "case_kind_code": row["case_kind_code"], + "case_kind_label": CASE_KIND_LABELS[row["case_kind_code"]], + "project_name": row["project_name"], + "project_names": list(row["project_names"]), + "summary_text": row["summary_text"], + "evidence_text": row["evidence_text"], + "evidence_post_id": str(row["evidence_post_id"]), + "ontology_class_iri": CASE_KIND_ONTOLOGY_CLASSES[row["case_kind_code"]], + "provenance_relation_iri": PROV_WAS_DERIVED_FROM, + "occurred_at": row["occurred_at"].isoformat(), + "facts": facts.get(key, []), + "missing_facts": missing_facts.get(key, []), + "milestones": case_milestones, + "lifecycles": case_lifecycles, + "semantic_projection": _operations_case_jsonld( + str(row["post_id"]), row["case_kind_code"], + str(row["evidence_post_id"]), facts.get(key, []), + ), + } + ) return { "period_label": _period_label(period_start, period_end), "total_post_count": total, @@ -318,33 +439,51 @@ async def fetch_operations_dashboard( for kind, label in CASE_KIND_LABELS.items() ], "topic_context": topic_context, - "cases": [ - { - "post_id": str(row["post_id"]), - "case_kind_code": row["case_kind_code"], - "case_kind_label": CASE_KIND_LABELS[row["case_kind_code"]], - "project_name": row["project_name"], - "project_names": list(row["project_names"]), - "summary_text": row["summary_text"], - "evidence_text": row["evidence_text"], - "evidence_post_id": str(row["evidence_post_id"]), - "ontology_class_iri": CASE_KIND_ONTOLOGY_CLASSES[row["case_kind_code"]], - "provenance_relation_iri": PROV_WAS_DERIVED_FROM, - "occurred_at": row["occurred_at"].isoformat(), - "facts": facts.get((str(row["post_id"]), row["case_kind_code"]), []), - "missing_facts": missing_facts.get((str(row["post_id"]), row["case_kind_code"]), []), - "semantic_projection": _operations_case_jsonld( - str(row["post_id"]), - row["case_kind_code"], - str(row["evidence_post_id"]), - facts.get((str(row["post_id"]), row["case_kind_code"]), []), - ), - } - for row in case_rows - ], + "lifecycle_metrics": list(lifecycle_metrics.values()), + "cases": projected_cases, } +def _project_lifecycles( + case_kind_code: str, + milestones: list[dict[str, Any]], + missing_milestones: set[str], +) -> list[dict[str, Any]]: + """Pair observed endpoints and report exact elapsed time without thresholds.""" + by_type = {value["milestone_type_code"]: value for value in milestones} + result = [] + for lifecycle_code, required_kind, label, start_code, end_code in LIFECYCLE_DEFINITIONS: + if case_kind_code != required_kind: + continue + start = by_type.get(start_code) + end = by_type.get(end_code) + if start and end: + elapsed_seconds = int((datetime.fromisoformat(end["observed_at"]) - datetime.fromisoformat(start["observed_at"])).total_seconds()) + status_code = "resolved" + next_action = "시작·종료 Event 근거를 열어 경과 시간을 검토하세요." + elif start and end_code in missing_milestones: + elapsed_seconds = None + status_code = "open" + next_action = f"{MILESTONE_TYPE_LABELS[end_code]} Event 근거를 연결하세요." + else: + elapsed_seconds = None + status_code = "evidence_missing" + next_action = f"{MILESTONE_TYPE_LABELS[start_code]} Event 근거를 연결하세요." + result.append({ + "lifecycle_kind_code": lifecycle_code, + "lifecycle_kind_label": label, + "status_code": status_code, + "status_label": {"resolved": "종료 확인", "open": "진행 중", "evidence_missing": "측정 근거 부족"}[status_code], + "started_at": start["observed_at"] if start else None, + "resolved_at": end["observed_at"] if end else None, + "elapsed_seconds": elapsed_seconds, + "start_milestone": start, + "end_milestone": end, + "next_action_text": next_action, + }) + return result + + async def _fetch_topic_context_dashboard( conn: _Connection, visible_post_sql: str, diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py index 825c35b77..fd7dd8d2f 100644 --- a/backend/app/post_content_worker.py +++ b/backend/app/post_content_worker.py @@ -46,7 +46,9 @@ _INCOMPLETE_FAILURE_CODE = "post_content_ingestion_incomplete" _ATTEMPT_LIMIT_FAILURE_CODE = "post_content_ingestion_attempt_limit" _SOURCE_BODY_MISSING_FAILURE_CODE = "post_content_source_body_missing" -_UNEXPECTED_FAILURE_DETAIL = "post-content provider operation failed; retry the ingestion job" +_UNEXPECTED_FAILURE_DETAIL = ( + "post-content provider operation failed; retry the ingestion job" +) async def _operations_evidence_sources( @@ -68,6 +70,22 @@ def can_see(row: asyncpg.Record) -> bool: async with pool.acquire() as conn: sources = await gather_chat_sources(conn, post_id, can_see, vision_client) + if not sources: + return () + source_times = { + str(row["post_id"]): ( + row["observed_at"], + "event_occurred_at" + if row["event_occurred_at"] is not None + else "created_at", + ) + for row in await conn.fetch( + "select post_id, event_occurred_at, " + "coalesce(event_occurred_at, created_at) as observed_at " + "from source_post where post_id = any($1::uuid[])", + [source.post_id for source in sources], + ) + } return tuple( OperationsEvidenceSource( source.post_id, @@ -78,6 +96,8 @@ def can_see(row: asyncpg.Record) -> bool: if source.evidence_facts else "" ), + source_times[source.post_id][0], + source_times[source.post_id][1], ) for source in sources ) @@ -312,7 +332,9 @@ async def process_post_content_job( structure_client = structure_factory() with use_llm_metadata(metadata): vision_client = vision_factory() - normalized = await asyncio.to_thread(normalize_post_body, raw_body, vision_client) + normalized = await asyncio.to_thread( + normalize_post_body, raw_body, vision_client + ) async with pool.acquire() as conn: await persist_post_content( conn, @@ -360,7 +382,9 @@ async def process_post_content_job( complete = await post_content_is_complete( conn, post_id, - embedding_model_code=getattr(embedding_client, "resolved_model", None), + embedding_model_code=getattr( + embedding_client, "resolved_model", None + ), require_embedding=require_orchestrator_evidence, require_structure=require_orchestrator_evidence, ) @@ -410,7 +434,9 @@ async def consume_post_content_stream_once( from there on the next poll. """ try: - batches = await client.xread({POST_CONTENT_STREAM_KEY: last_id}, count=10, block=1000) + batches = await client.xread( + {POST_CONTENT_STREAM_KEY: last_id}, count=10, block=1000 + ) except Exception: # Keep idle polls silent, but retain a diagnostic span for broker failures. with traced( @@ -480,6 +506,7 @@ async def run_post_content_worker( ) except (redis.RedisError, OSError) as exc: _logger.warning( - "post-content Valkey poll failed; retrying (error_type=%s)", type(exc).__name__ + "post-content Valkey poll failed; retrying (error_type=%s)", + type(exc).__name__, ) await asyncio.sleep(_BROKER_RECOVERY_DELAY_SECONDS) diff --git a/docs/adr/0206-evidence-operations-dashboard.md b/docs/adr/0206-evidence-operations-dashboard.md index 21d923fd3..cacb982e2 100644 --- a/docs/adr/0206-evidence-operations-dashboard.md +++ b/docs/adr/0206-evidence-operations-dashboard.md @@ -137,8 +137,28 @@ treated as a negative case. responses, source-digest invalidation, and unavailable orchestrator states. - Backend integration tests cover ABAC filtering, event-time fallback, event versus post counts, external-information percentage, multi-project - membership, and explicit missing facts. + membership, explicit missing facts, observed lifecycle endpoints, exact + elapsed duration, open cases with nullable elapsed time, reversed endpoint + rejection, and evidence-post authorization. - Frontend tests cover period submission, navigation, empty/error states, evidence links, keyboard semantics, and non-color status copy. - Storybook interaction tests and authenticated browser screenshots audit the rendered desktop and narrow layouts. + +## References + +Institute of Electrical and Electronics Engineers. (2023). *IEEE standard for +eXtensible Event Stream (XES) for achieving interoperability in event logs and +event streams* (IEEE Std 1849-2023). IEEE Standards Association. +https://standards.ieee.org/ieee/1849/10907/ + +van der Aalst, W. M. P., Adriansyah, A., de Medeiros, A. K. A., Arcieri, F., +Baier, T., Blickle, T., Bose, J. C., van den Brand, P., Brandtjen, R., Buijs, +J., Burattin, A., Carmona, J., Castellanos, M., Claes, J., Cook, J., Costantini, +N., Curbera, F., Damiani, E., de Leoni, M., ... Wynn, M. (2012). Process mining +manifesto. In F. Daniel, K. Barkaoui, & S. Dustdar (Eds.), *Business process +management workshops* (pp. 169–194). Springer. +https://doi.org/10.1007/978-3-642-28108-2_19 + +World Wide Web Consortium. (2022). *Time ontology in OWL*. +https://www.w3.org/TR/owl-time/ diff --git a/docs/product-requirements.md b/docs/product-requirements.md index 75cba0410..36e5fcb44 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -83,12 +83,18 @@ content never becomes an external query or citation. - Show persisted operational cases, actions, commitments, delivery status, and similar-VOC evidence with extractive citations. +- For claim investigation, rebid response, and handover, persist closed-vocabulary + milestones only when an authorized source span supports them. Report + open/resolved/evidence-missing counts and exact elapsed time only between two + observed endpoints; never invent an endpoint or delay threshold. - Preserve controls during loading and retry; discard responses from an earlier navigation scope. - Distinguish pending, unavailable, failed, incomplete, and succeeded states. Acceptance: each state tells the user the next valid action and never displays -stale evidence from a previously opened post. +stale evidence from a previously opened post. An open lifecycle has a cited +start, a missing end, and nullable elapsed time; a resolved lifecycle links both +endpoint sources and names the source clock used for each instant. ### PRD-FR-6 — Measurement boundary diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 5ed4f5387..098b2b031 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -8,8 +8,8 @@ | Requirement | Evidence contract | Delivery state | |---|---|---| -| Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification with cited spans; Event Lineage context | Candidate API/UI reports separate per-kind Event and distinct-post counts; every required answer is cited or stored as an explicit missing fact with a collection action; authenticated runtime acceptance pending | -| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus persisted summary actions/roles | Candidate API/UI reports separate per-kind Event and distinct-post counts; every required answer is cited or stored as an explicit missing fact; corpus backfill pending | +| Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification and `claim_received` → `cause_confirmed` milestones with cited spans and observed source clocks | Stacked candidate reports open/resolved/evidence-missing counts and exact elapsed time only for paired observed endpoints; every required answer and endpoint is cited or explicitly missing; authenticated runtime acceptance and corpus re-analysis pending | +| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus separate rebid-response and handover milestone pairs | Stacked candidate reports open/resolved/evidence-missing rebid and handover lifecycles without a delay threshold or invented elapsed endpoint; authenticated runtime acceptance and corpus re-analysis pending | | External information count/rate and sales/project relation | ADR 0206; semantic `external_information` classification inside Dashboard GNB | Candidate GNB destination filters the Dashboard to external evidence; no separate Board by product decision; authenticated runtime acceptance pending | | Project-specific journey | Explicit source/semantic project membership plus event-time ordering | Candidate API preserves every explicit project membership and the UI orders each journey chronologically; authenticated runtime acceptance pending | | Repeat issue to design improvement | `repeat_issue`, `issue_pattern`, and `improvement_action` cited facts | Candidate semantic contract; design-system connector acceptance pending | @@ -377,7 +377,7 @@ this file per §3.5 of the prior snapshot). | #272 | Verify Global Ask KG/ontology/semantic claims with public SearXNG evidence | Ask stack | | #274 | Persist and explain Event Lineage channel evidence | #387 | | #277 | TEPP: persist accepted receipts, poll completed results, keep measurement authority distinct | #468, #417 | -| #280 | Full project-lifecycle history and handover intervals | Tracked with issue #284; no active delivery PR confirmed | +| #280 | Full project-lifecycle history and handover intervals | The current stacked candidate covers observed claim, rebid-response, and handover endpoint pairs; cross-record business-case identity remains unavailable unless an explicit source identifier is persisted, so project/similarity proximity is not used as a substitute | | #284 | Authoritative lifecycle ingestion and idempotent reconciliation | No active delivery PR confirmed | | #289 | Activate the optional lineage LLM channel through a bounded asynchronous rebuild | #434 | | #336 | Replace pseudo-CalDAV feed with a Naruon-owned calendar projection | Contract on `main` (#355); operator consume wiring in historical branch `feat/naruon-calendar-buyer-wiring-v2170` | diff --git a/frontend/src/App.css b/frontend/src/App.css index f3f8fd08b..123d01254 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1301,6 +1301,14 @@ .dashboard-metrics dd { margin: 0.25rem 0 0; font-size: 1.5rem; font-weight: 700; } .dashboard-case-metrics .dashboard-metrics { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.dashboard-lifecycle-summary { margin: 1.5rem 0; } +.dashboard-lifecycle-summary > p { color: var(--color-text); } +.dashboard-lifecycle-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0; border: 1px solid var(--color-border); } +.dashboard-lifecycle-metrics > div { padding: 1rem; border-right: 1px solid var(--color-border); } +.dashboard-lifecycle-metrics > div:last-child { border-right: 0; } +.dashboard-lifecycle-metrics dt { font-weight: 700; } +.dashboard-lifecycle-metrics dd { margin: 0.5rem 0 0; color: var(--color-text); } + .dashboard-case-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(22rem, 100%), 1fr)); @@ -1345,6 +1353,15 @@ border-left: 3px solid var(--color-dashboard-positive); } +.dashboard-case-lifecycles { display: grid; gap: 1rem; } +.dashboard-lifecycle-row { padding: 1rem; border: 1px solid var(--color-border); background: var(--color-background); } +.dashboard-lifecycle-row header { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; } +.dashboard-lifecycle-row h4, .dashboard-lifecycle-row p { margin: 0; } +.dashboard-lifecycle-row ol { display: grid; gap: 0.5rem; margin: 1rem 0; padding: 0; list-style: none; } +.dashboard-lifecycle-row li { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 0.5rem; border-top: 1px solid var(--color-border); padding-top: 0.5rem; } +.dashboard-lifecycle-row time { grid-column: 1 / -1; color: var(--color-text); font-variant-numeric: tabular-nums; } +.dashboard-next-action { font-weight: 700; } + .dashboard-case-card dl { margin: 0; } .dashboard-case-card dl div { display: grid; grid-template-columns: 8rem 1fr; gap: var(--space-control-gap); padding: 0.5rem 0; border-top: 1px solid var(--color-border); } .dashboard-case-card dd { margin: 0; font-weight: 600; } @@ -1461,6 +1478,11 @@ .operations-dashboard-heading { align-items: start; flex-direction: column; } .dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .dashboard-case-metrics .dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .dashboard-lifecycle-metrics { grid-template-columns: 1fr; } + .dashboard-lifecycle-metrics > div { border-right: 0; border-bottom: 1px solid var(--color-border); } + .dashboard-lifecycle-metrics > div:last-child { border-bottom: 0; } + .dashboard-lifecycle-row li { grid-template-columns: 1fr; } + .dashboard-lifecycle-row li button { width: 100%; } .dashboard-case-grid { grid-template-columns: 1fr; } .dashboard-topic-context > header { align-items: start; flex-direction: column; } .dashboard-topic-provenance dl div { grid-template-columns: 1fr; gap: 0.25rem; } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 1eb85a5d5..fc76a6b6d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -55,6 +55,29 @@ export interface OperationsDashboardFact { relation_predicate_iri?: string; } +export interface OperationsDashboardMilestone { + milestone_type_code: string; + milestone_type_label: string; + evidence_text: string; + evidence_post_id: string; + observed_at: string; + time_axis_code: "event_occurred_at" | "created_at"; + time_axis_label: string; +} + +export interface OperationsDashboardLifecycle { + lifecycle_kind_code: string; + lifecycle_kind_label: string; + status_code: "resolved" | "open" | "evidence_missing"; + status_label: string; + started_at: string | null; + resolved_at: string | null; + elapsed_seconds: number | null; + start_milestone: OperationsDashboardMilestone | null; + end_milestone: OperationsDashboardMilestone | null; + next_action_text: string; +} + export interface OperationsDashboardCase { post_id: string; case_kind_code: string; @@ -67,6 +90,8 @@ export interface OperationsDashboardCase { occurred_at: string; facts: OperationsDashboardFact[]; missing_facts: Array<{ fact_type_code: string; fact_type_label: string }>; + milestones: OperationsDashboardMilestone[]; + lifecycles: OperationsDashboardLifecycle[]; ontology_class_iri?: string; provenance_relation_iri?: string; semantic_projection?: Record; @@ -86,6 +111,13 @@ export interface OperationsDashboardResponse { event_count: number; post_count: number; }>; + lifecycle_metrics: Array<{ + lifecycle_kind_code: string; + lifecycle_kind_label: string; + open_case_count: number; + resolved_case_count: number; + evidence_missing_case_count: number; + }>; topic_context: TopicContextDashboard; cases: OperationsDashboardCase[]; } diff --git a/frontend/src/components/OperationsDashboard.stories.tsx b/frontend/src/components/OperationsDashboard.stories.tsx index 6d813019f..affe0f78e 100644 --- a/frontend/src/components/OperationsDashboard.stories.tsx +++ b/frontend/src/components/OperationsDashboard.stories.tsx @@ -18,6 +18,11 @@ export const EvidenceReady: Story = { { case_kind_code: "external_information", case_kind_label: "발주 공고 · 시장 동향", event_count: 9, post_count: 9 }, { case_kind_code: "repeat_issue", case_kind_label: "반복 이슈", event_count: 2, post_count: 2 }, ], + lifecycle_metrics: [ + { lifecycle_kind_code: "claim_investigation", lifecycle_kind_label: "클레임 원인 규명", open_case_count: 1, resolved_case_count: 0, evidence_missing_case_count: 0 }, + { lifecycle_kind_code: "rebid_response", lifecycle_kind_label: "재입찰 대응", open_case_count: 0, resolved_case_count: 1, evidence_missing_case_count: 0 }, + { lifecycle_kind_code: "handover_gap", lifecycle_kind_label: "인수인계 공백", open_case_count: 0, resolved_case_count: 0, evidence_missing_case_count: 1 }, + ], topic_context: { status_code: "unavailable", reason_code: "tepp_topic_posterior_not_persisted", next_action: "TEPP posterior topic 계약 결과를 먼저 완료하세요.", model_run: null, topics: [], @@ -28,10 +33,10 @@ export const EvidenceReady: Story = { }, failed_analysis_count: 0, cases: [ - { post_id: "synthetic-post-1", case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 역추적", project_name: "Synthetic Transformer Renewal", summary_text: "사양 변경 이후 원인 수주와 Pool을 확인", evidence_text: "Revision B originated in order SO-100 from pool SP-20.", evidence_post_id: "synthetic-post-1", occurred_at: "2026-08-04T00:00:00Z", facts: [{ fact_type_code: "originating_order", fact_type_label: "원인 수주", value_text: "SO-100 · SP-20", evidence_text: "order SO-100 from pool SP-20", evidence_post_id: "synthetic-post-1" }], missing_facts: [{ fact_type_code: "order", fact_type_label: "발생 수주" }, { fact_type_code: "specification_change", fact_type_label: "사양 변경" }, { fact_type_code: "sales_pool", fact_type_label: "수주 Pool" }] }, - { post_id: "synthetic-post-2", case_kind_code: "rebid_handover", case_kind_label: "재입찰 · 인수인계", project_name: "Synthetic Transformer Renewal", summary_text: "담당자 교체 전 협의와 후속 결정을 연결", evidence_text: "The account owner and design lead agreed to submit the revised proposal.", evidence_post_id: "synthetic-post-2", occurred_at: "2026-08-11T00:00:00Z", facts: [{ fact_type_code: "decision", fact_type_label: "이어진 결정", value_text: "수정 제안 제출", evidence_text: "submit the revised proposal", evidence_post_id: "synthetic-post-2" }], missing_facts: [{ fact_type_code: "discussion", fact_type_label: "협의 내용" }, { fact_type_code: "counterparty", fact_type_label: "협의 상대" }, { fact_type_code: "our_owner", fact_type_label: "우리측 담당자" }] }, - { post_id: "synthetic-post-3", case_kind_code: "external_information", case_kind_label: "외부 정보", project_name: "Synthetic Transformer Renewal", summary_text: "시장 공고를 영업 기회와 연결", evidence_text: "The public procurement notice opened on August 15.", evidence_post_id: "synthetic-post-3", occurred_at: "2026-08-15T00:00:00Z", facts: [{ fact_type_code: "external_relation", fact_type_label: "업무 관계", value_text: "갱신 제안 준비", evidence_text: "procurement notice", evidence_post_id: "synthetic-post-3", relation_target_kind_code: "project", relation_target_kind_label: "프로젝트" }], missing_facts: [] }, - { post_id: "synthetic-post-4", case_kind_code: "repeat_issue", case_kind_label: "반복 이슈 반영", project_name: "Synthetic Transformer Renewal", summary_text: "동일 유형 이슈를 설계 개선으로 환류", evidence_text: "The same enclosure issue recurred after Revision B.", evidence_post_id: "synthetic-post-4", occurred_at: "2026-08-18T00:00:00Z", facts: [{ fact_type_code: "improvement_action", fact_type_label: "개선 과제", value_text: "표준 사양 개정", evidence_text: "Update the standard enclosure specification.", evidence_post_id: "synthetic-post-4" }], missing_facts: [{ fact_type_code: "issue_pattern", fact_type_label: "반복 유형" }] }, + { post_id: "synthetic-post-1", case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 역추적", project_name: "Synthetic Transformer Renewal", summary_text: "사양 변경 이후 원인 수주와 Pool을 확인", evidence_text: "Revision B originated in order SO-100 from pool SP-20.", evidence_post_id: "synthetic-post-1", occurred_at: "2026-08-04T00:00:00Z", facts: [{ fact_type_code: "originating_order", fact_type_label: "원인 수주", value_text: "SO-100 · SP-20", evidence_text: "order SO-100 from pool SP-20", evidence_post_id: "synthetic-post-1" }], missing_facts: [{ fact_type_code: "order", fact_type_label: "발생 수주" }, { fact_type_code: "specification_change", fact_type_label: "사양 변경" }, { fact_type_code: "sales_pool", fact_type_label: "수주 Pool" }], milestones: [], lifecycles: [] }, + { post_id: "synthetic-post-2", case_kind_code: "rebid_handover", case_kind_label: "재입찰 · 인수인계", project_name: "Synthetic Transformer Renewal", summary_text: "담당자 교체 전 협의와 후속 결정을 연결", evidence_text: "The account owner and design lead agreed to submit the revised proposal.", evidence_post_id: "synthetic-post-2", occurred_at: "2026-08-11T00:00:00Z", facts: [{ fact_type_code: "decision", fact_type_label: "이어진 결정", value_text: "수정 제안 제출", evidence_text: "submit the revised proposal", evidence_post_id: "synthetic-post-2" }], missing_facts: [{ fact_type_code: "discussion", fact_type_label: "협의 내용" }, { fact_type_code: "counterparty", fact_type_label: "협의 상대" }, { fact_type_code: "our_owner", fact_type_label: "우리측 담당자" }], milestones: [], lifecycles: [] }, + { post_id: "synthetic-post-3", case_kind_code: "external_information", case_kind_label: "외부 정보", project_name: "Synthetic Transformer Renewal", summary_text: "시장 공고를 영업 기회와 연결", evidence_text: "The public procurement notice opened on August 15.", evidence_post_id: "synthetic-post-3", occurred_at: "2026-08-15T00:00:00Z", facts: [{ fact_type_code: "external_relation", fact_type_label: "업무 관계", value_text: "갱신 제안 준비", evidence_text: "procurement notice", evidence_post_id: "synthetic-post-3", relation_target_kind_code: "project", relation_target_kind_label: "프로젝트" }], missing_facts: [], milestones: [], lifecycles: [] }, + { post_id: "synthetic-post-4", case_kind_code: "repeat_issue", case_kind_label: "반복 이슈 반영", project_name: "Synthetic Transformer Renewal", summary_text: "동일 유형 이슈를 설계 개선으로 환류", evidence_text: "The same enclosure issue recurred after Revision B.", evidence_post_id: "synthetic-post-4", occurred_at: "2026-08-18T00:00:00Z", facts: [{ fact_type_code: "improvement_action", fact_type_label: "개선 과제", value_text: "표준 사양 개정", evidence_text: "Update the standard enclosure specification.", evidence_post_id: "synthetic-post-4" }], missing_facts: [{ fact_type_code: "issue_pattern", fact_type_label: "반복 유형" }], milestones: [], lifecycles: [] }, ], }, onOpenPost: () => undefined, @@ -40,6 +45,7 @@ export const EvidenceReady: Story = { const canvas = within(canvasElement); await expect(canvas.getByText("9건 · 22.5%")).toBeInTheDocument(); await expect(canvas.getByText("7 Event · 5글")).toBeVisible(); + await expect(canvas.getByText("3일 3시간 30분 0초")).toBeVisible(); await expect(canvas.getAllByRole("button", { name: "분류 근거 글 열기" })[0]).toBeVisible(); }, }; diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx index 9328c1235..dbb9bb5d3 100644 --- a/frontend/src/components/OperationsDashboard.test.tsx +++ b/frontend/src/components/OperationsDashboard.test.tsx @@ -21,6 +21,9 @@ const data: OperationsDashboardResponse = { { case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 규명", event_count: 3, post_count: 2 }, { case_kind_code: "rebid_handover", case_kind_label: "재입찰 · 인수인계", event_count: 2, post_count: 2 }, ], + lifecycle_metrics: [ + { lifecycle_kind_code: "claim_investigation", lifecycle_kind_label: "클레임 원인 규명", open_case_count: 1, resolved_case_count: 0, evidence_missing_case_count: 0 }, + ], topic_context: { status_code: "unavailable", reason_code: "tepp_topic_posterior_not_persisted", @@ -37,6 +40,11 @@ const data: OperationsDashboardResponse = { project_name: "Synthetic Grid Upgrade", summary_text: "사양 변경 이후 원인 수주를 확인했습니다.", evidence_text: "Revision B changed the enclosure.", evidence_post_id: "evidence-post-1", occurred_at: "2026-08-12T00:00:00Z", facts: [{ fact_type_code: "originating_order", fact_type_label: "원인 수주", value_text: "ORDER-100", evidence_text: "Original order ORDER-100", evidence_post_id: "evidence-post-2" }], missing_facts: [{ fact_type_code: "sales_pool", fact_type_label: "수주 Pool" }], + milestones: [ + { milestone_type_code: "claim_received", milestone_type_label: "클레임 접수", evidence_text: "Claim received", evidence_post_id: "evidence-post-1", observed_at: "2026-08-01T09:00:00Z", time_axis_code: "event_occurred_at", time_axis_label: "Event 발생일" }, + { milestone_type_code: "cause_confirmed", milestone_type_label: "원인 확정", evidence_text: "Cause confirmed", evidence_post_id: "evidence-post-2", observed_at: "2026-08-03T12:30:00Z", time_axis_code: "created_at", time_axis_label: "기록 생성일" }, + ], + lifecycles: [{ lifecycle_kind_code: "claim_investigation", lifecycle_kind_label: "클레임 원인 규명", status_code: "resolved", status_label: "종료 확인", started_at: "2026-08-01T09:00:00Z", resolved_at: "2026-08-03T12:30:00Z", elapsed_seconds: 185400, start_milestone: { milestone_type_code: "claim_received", milestone_type_label: "클레임 접수", evidence_text: "Claim received", evidence_post_id: "evidence-post-1", observed_at: "2026-08-01T09:00:00Z", time_axis_code: "event_occurred_at", time_axis_label: "Event 발생일" }, end_milestone: { milestone_type_code: "cause_confirmed", milestone_type_label: "원인 확정", evidence_text: "Cause confirmed", evidence_post_id: "evidence-post-2", observed_at: "2026-08-03T12:30:00Z", time_axis_code: "created_at", time_axis_label: "기록 생성일" }, next_action_text: "시작·종료 Event 근거를 열어 경과 시간을 검토하세요." }], }], }; @@ -48,10 +56,14 @@ describe("OperationsDashboardView", () => { expect(screen.getByText("5건 · 25.0%")).toBeInTheDocument(); expect(screen.getByText("원인 수주")).toBeInTheDocument(); expect(screen.getByText(/수주 Pool: 권한 범위 내 근거가 없습니다/)).toBeInTheDocument(); + expect(screen.getByText("2일 3시간 30분 0초")).toBeInTheDocument(); + expect(screen.getByText(/진행 중 1건 · 종료 확인 0건/)).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "분류 근거 글 열기" })); expect(onOpenPost).toHaveBeenCalledWith("evidence-post-1"); await userEvent.click(screen.getByRole("button", { name: "원인 수주 근거 열기" })); expect(onOpenPost).toHaveBeenCalledWith("evidence-post-2"); + await userEvent.click(screen.getByRole("button", { name: "클레임 접수 근거 열기" })); + expect(onOpenPost).toHaveBeenCalledWith("evidence-post-1"); }); it("shows an actionable empty external-information state", () => { diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx index e36adfdbf..f7c93dacb 100644 --- a/frontend/src/components/OperationsDashboard.tsx +++ b/frontend/src/components/OperationsDashboard.tsx @@ -1,6 +1,13 @@ import { useEffect, useState } from "react"; import { fetchOperationsDashboard, type OperationsDashboardResponse } from "../api"; +function formatElapsed(seconds: number): string { + const days = Math.floor(seconds / 86_400); + const hours = Math.floor((seconds % 86_400) / 3_600); + const minutes = Math.floor((seconds % 3_600) / 60); + return `${days}일 ${hours}시간 ${minutes}분 ${seconds % 60}초`; +} + const dimensionLabels = { business_unit: "사업부", process_unit: "PU", @@ -98,6 +105,20 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost ) : null} + {!externalOnly ? ( +
+

관측된 처리 구간

+

임의 지연 기준 없이, 시작·종료 Event가 모두 확인된 구간만 경과 시간을 계산합니다.

+
+ {data.lifecycle_metrics.map((metric) => ( +
+
{metric.lifecycle_kind_label}
+
진행 중 {metric.open_case_count}건 · 종료 확인 {metric.resolved_case_count}건 · 측정 근거 부족 {metric.evidence_missing_case_count}건
+
+ ))} +
+
+ ) : null} {!externalOnly ? ( ) : null} @@ -127,6 +148,26 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
{item.case_kind_label}{item.project_name ?? "프로젝트 연결 분석 중"}

{item.summary_text}

{item.evidence_text}
+ {item.lifecycles.length ? ( +
+ {item.lifecycles.map((lifecycle) => ( +
+

{lifecycle.lifecycle_kind_label}

{lifecycle.status_label}
+ {lifecycle.elapsed_seconds !== null ?

확정 경과 시간 {formatElapsed(lifecycle.elapsed_seconds)}

:

경과 시간은 종료 Event가 관측될 때 계산됩니다.

} +
    + {[lifecycle.start_milestone, lifecycle.end_milestone].filter((milestone) => milestone !== null).map((milestone) => ( +
  1. + + {milestone.milestone_type_label} · {milestone.time_axis_label} + +
  2. + ))} +
+

다음 조치: {lifecycle.next_action_text}

+
+ ))} +
+ ) : null}
{item.facts.map((fact) =>
{fact.fact_type_label}{fact.relation_target_kind_label ? ` · ${fact.relation_target_kind_label}` : ""}
{fact.value_text}
)}
{item.missing_facts.length ? (
diff --git a/lineageweave/operations_case_analysis.py b/lineageweave/operations_case_analysis.py index 3c054cc59..b27468706 100644 --- a/lineageweave/operations_case_analysis.py +++ b/lineageweave/operations_case_analysis.py @@ -5,6 +5,7 @@ import hashlib import json from dataclasses import dataclass +from datetime import datetime from typing import Protocol from .http_client import chat_completion_content, post_json @@ -14,20 +15,55 @@ ) FACT_TYPES = frozenset( { - "order", "specification_change", "originating_order", "sales_pool", - "discussion", "counterparty", "our_owner", "decision", "external_relation", - "issue_pattern", "improvement_action", + "order", + "specification_change", + "originating_order", + "sales_pool", + "discussion", + "counterparty", + "our_owner", + "decision", + "external_relation", + "issue_pattern", + "improvement_action", } ) EXTERNAL_RELATION_TARGET_KINDS = frozenset( {"order", "project", "sales", "business_management"} ) REQUIRED_FACT_TYPES = { - "claim_investigation": frozenset({"order", "specification_change", "originating_order", "sales_pool"}), - "rebid_handover": frozenset({"discussion", "counterparty", "our_owner", "decision"}), + "claim_investigation": frozenset( + {"order", "specification_change", "originating_order", "sales_pool"} + ), + "rebid_handover": frozenset( + {"discussion", "counterparty", "our_owner", "decision"} + ), "external_information": frozenset({"external_relation"}), "repeat_issue": frozenset({"issue_pattern", "improvement_action"}), } +MILESTONE_TYPES = frozenset( + { + "claim_received", + "cause_confirmed", + "rebid_response_requested", + "rebid_decision_recorded", + "handover_started", + "handover_accepted", + } +) +REQUIRED_MILESTONE_TYPES = { + "claim_investigation": frozenset({"claim_received", "cause_confirmed"}), + "rebid_handover": frozenset( + { + "rebid_response_requested", + "rebid_decision_recorded", + "handover_started", + "handover_accepted", + } + ), + "external_information": frozenset(), + "repeat_issue": frozenset(), +} @dataclass(frozen=True) @@ -42,6 +78,18 @@ class OperationsCaseFact: relation_target_kind_code: str | None = None +@dataclass(frozen=True) +class OperationsCaseMilestone: + """One semantically identified milestone bound to an observed source instant.""" + + milestone_type_code: str + evidence_text: str + evidence_post_id: str + evidence_input_sha256: str + observed_at: datetime + time_axis_code: str + + @dataclass(frozen=True) class OperationsCase: """One semantically classified operational case in a post.""" @@ -53,6 +101,8 @@ class OperationsCase: evidence_post_id: str = "" evidence_input_sha256: str = "" missing_fact_type_codes: tuple[str, ...] = () + milestones: tuple[OperationsCaseMilestone, ...] = () + missing_milestone_type_codes: tuple[str, ...] = () @dataclass(frozen=True) @@ -62,6 +112,8 @@ class OperationsEvidenceSource: post_id: str title: str text: str + observed_at: datetime | None = None + time_axis_code: str | None = None @property def input_sha256(self) -> str: @@ -109,7 +161,14 @@ def analyze( specification_change, originating_order, sales_pool; rebid_handover = discussion, counterparty, our_owner, decision; external_information = external_relation; repeat_issue = issue_pattern, improvement_action. Return [] only when the record supports none -of the case kinds. +of the case kinds. Each item must also contain milestones and +missing_milestone_type_codes. A milestone has milestone_type_code, +evidence_post_id, and a verbatim evidence_text; its instant is assigned from +that source record and must never be generated by the model. Required milestone +types are: claim_investigation = claim_received, cause_confirmed; +rebid_handover = rebid_response_requested, rebid_decision_recorded, +handover_started, handover_accepted; the other case kinds have no milestones. +Represent every required type exactly once as cited evidence or as missing. Stored context (hints, not proof): {context} Authorized numbered sources: @@ -141,19 +200,39 @@ def parse_operations_case_response( seen_case_kinds.add(item["case_kind_code"]) summary = item.get("summary_text") evidence = item.get("evidence_text") - evidence_post_id = item.get("evidence_post_id") or ("focal" if legacy_focal else None) + evidence_post_id = item.get("evidence_post_id") or ( + "focal" if legacy_focal else None + ) facts = item.get("facts") missing_fact_types = item.get("missing_fact_type_codes") + milestones = item.get("milestones") + missing_milestone_types = item.get("missing_milestone_type_codes") evidence_source = sources_by_id.get(evidence_post_id) - if not isinstance(summary, str) or not summary.strip() or not isinstance(evidence, str) or not evidence.strip() or evidence_source is None or evidence not in evidence_source.text or not isinstance(facts, list) or not isinstance(missing_fact_types, list): + if ( + not isinstance(summary, str) + or not summary.strip() + or not isinstance(evidence, str) + or not evidence.strip() + or evidence_source is None + or evidence not in evidence_source.text + or not isinstance(facts, list) + or not isinstance(missing_fact_types, list) + or not isinstance(milestones, list) + or not isinstance(missing_milestone_types, list) + ): return None parsed_facts: list[OperationsCaseFact] = [] for fact in facts: - if not isinstance(fact, dict) or fact.get("fact_type_code") not in FACT_TYPES: + if ( + not isinstance(fact, dict) + or fact.get("fact_type_code") not in FACT_TYPES + ): return None value = fact.get("value_text") fact_evidence = fact.get("evidence_text") - fact_post_id = fact.get("evidence_post_id") or ("focal" if legacy_focal else None) + fact_post_id = fact.get("evidence_post_id") or ( + "focal" if legacy_focal else None + ) fact_source = sources_by_id.get(fact_post_id) relation_target_kind = fact.get("relation_target_kind_code") if ( @@ -194,7 +273,82 @@ def parse_operations_case_response( or not required_types.issubset(supported_types.union(missing_types)) ): return None - cases.append(OperationsCase(item["case_kind_code"], summary.strip(), evidence, tuple(parsed_facts), evidence_source.post_id, evidence_source.input_sha256, tuple(missing_fact_types))) + parsed_milestones: list[OperationsCaseMilestone] = [] + for milestone in milestones: + if ( + not isinstance(milestone, dict) + or milestone.get("milestone_type_code") not in MILESTONE_TYPES + ): + return None + milestone_evidence = milestone.get("evidence_text") + milestone_post_id = milestone.get("evidence_post_id") or ( + "focal" if legacy_focal else None + ) + milestone_source = sources_by_id.get(milestone_post_id) + if ( + not isinstance(milestone_evidence, str) + or not milestone_evidence.strip() + or milestone_source is None + or milestone_evidence not in milestone_source.text + or milestone_source.observed_at is None + or milestone_source.time_axis_code + not in {"event_occurred_at", "created_at"} + ): + return None + parsed_milestones.append( + OperationsCaseMilestone( + milestone["milestone_type_code"], + milestone_evidence, + milestone_source.post_id, + milestone_source.input_sha256, + milestone_source.observed_at, + milestone_source.time_axis_code, + ) + ) + supported_milestone_types = { + value.milestone_type_code for value in parsed_milestones + } + required_milestones = REQUIRED_MILESTONE_TYPES[item["case_kind_code"]] + if ( + len(supported_milestone_types) != len(parsed_milestones) + or any( + not isinstance(code, str) or code not in MILESTONE_TYPES + for code in missing_milestone_types + ) + or len(set(missing_milestone_types)) != len(missing_milestone_types) + or supported_milestone_types.intersection(missing_milestone_types) + or supported_milestone_types.union(missing_milestone_types) + != required_milestones + ): + return None + milestone_by_type = { + value.milestone_type_code: value for value in parsed_milestones + } + for start_code, end_code in ( + ("claim_received", "cause_confirmed"), + ("rebid_response_requested", "rebid_decision_recorded"), + ("handover_started", "handover_accepted"), + ): + if ( + start_code in milestone_by_type + and end_code in milestone_by_type + and milestone_by_type[end_code].observed_at + < milestone_by_type[start_code].observed_at + ): + return None + cases.append( + OperationsCase( + item["case_kind_code"], + summary.strip(), + evidence, + tuple(parsed_facts), + evidence_source.post_id, + evidence_source.input_sha256, + tuple(missing_fact_types), + tuple(parsed_milestones), + tuple(missing_milestone_types), + ) + ) return tuple(cases) @@ -214,11 +368,30 @@ def analyze( """Classify cases and reject any uncited or malformed result.""" response = post_json( f"{self._base_url}/v1/chat/completions", - {"messages": [{"role": "user", "content": _PROMPT.format(context=context, sources="\n\n".join(f"[Source {index}] post_id={source.post_id}\nTitle: {source.title}\n{source.text}" for index, source in enumerate(sources, 1)))}], "mode": "auto", "reasoning_effort": "auto"}, + { + "messages": [ + { + "role": "user", + "content": _PROMPT.format( + context=context, + sources="\n\n".join( + f"[Source {index}] post_id={source.post_id}\nTitle: {source.title}\n{source.text}" + for index, source in enumerate(sources, 1) + ), + ), + } + ], + "mode": "auto", + "reasoning_effort": "auto", + }, headers={"authorization": f"Bearer {self._api_key}"}, timeout=self._timeout, ) - parsed = parse_operations_case_response(chat_completion_content(response), sources) + parsed = parse_operations_case_response( + chat_completion_content(response), sources + ) if parsed is None: - raise ValueError("operations case response did not match the evidence contract") + raise ValueError( + "operations case response did not match the evidence contract" + ) return parsed diff --git a/migrations/0215_operations_case_milestone.sql b/migrations/0215_operations_case_milestone.sql new file mode 100644 index 000000000..614accb7c --- /dev/null +++ b/migrations/0215_operations_case_milestone.sql @@ -0,0 +1,36 @@ +-- ADR 0206: observed lifecycle milestones; no inferred timestamps or delay threshold. +create table if not exists operations_case_milestone ( + post_id uuid not null, + case_kind_code text not null, + milestone_type_code text not null check (milestone_type_code in ( + 'claim_received', 'cause_confirmed', + 'rebid_response_requested', 'rebid_decision_recorded', + 'handover_started', 'handover_accepted' + )), + evidence_text text not null check (btrim(evidence_text) <> ''), + evidence_post_id uuid not null references source_post(post_id) on delete restrict, + evidence_input_sha256 text not null check (evidence_input_sha256 ~ '^[0-9a-f]{64}$'), + observed_at timestamptz not null, + time_axis_code text not null check (time_axis_code in ('event_occurred_at', 'created_at')), + primary key (post_id, case_kind_code, milestone_type_code), + foreign key (post_id, case_kind_code) + references operations_case_classification(post_id, case_kind_code) + on delete cascade +); + +create table if not exists operations_case_missing_milestone ( + post_id uuid not null, + case_kind_code text not null, + milestone_type_code text not null check (milestone_type_code in ( + 'claim_received', 'cause_confirmed', + 'rebid_response_requested', 'rebid_decision_recorded', + 'handover_started', 'handover_accepted' + )), + primary key (post_id, case_kind_code, milestone_type_code), + foreign key (post_id, case_kind_code) + references operations_case_classification(post_id, case_kind_code) + on delete cascade +); + +create index if not exists operations_case_milestone_kind_time_idx + on operations_case_milestone (case_kind_code, milestone_type_code, observed_at, post_id); diff --git a/tests/test_operations_case_analysis.py b/tests/test_operations_case_analysis.py index d5df88c2d..7b248c168 100644 --- a/tests/test_operations_case_analysis.py +++ b/tests/test_operations_case_analysis.py @@ -1,25 +1,75 @@ """Operational case semantic-response contract tests.""" import json +from datetime import UTC, datetime -from lineageweave.operations_case_analysis import OperationsEvidenceSource, parse_operations_case_response +from lineageweave.operations_case_analysis import ( + OperationsEvidenceSource, + parse_operations_case_response, +) def test_parses_multiple_cases_and_grounded_facts() -> None: """One record may support multiple case kinds without losing evidence.""" body = "The revised specification caused the claim. Mina agreed with Alex to rebid." payload = [ - {"case_kind_code": "claim_investigation", "summary_text": "Specification-linked claim", "evidence_text": "The revised specification caused the claim.", "facts": [{"fact_type_code": "specification_change", "value_text": "revised specification", "evidence_text": "The revised specification caused the claim."}], "missing_fact_type_codes": ["order", "originating_order", "sales_pool"]}, - {"case_kind_code": "rebid_handover", "summary_text": "Rebid agreement", "evidence_text": "Mina agreed with Alex to rebid.", "facts": [{"fact_type_code": "counterparty", "value_text": "Mina and Alex", "evidence_text": "Mina agreed with Alex to rebid."}], "missing_fact_type_codes": ["discussion", "our_owner", "decision"]}, + { + "case_kind_code": "claim_investigation", + "summary_text": "Specification-linked claim", + "evidence_text": "The revised specification caused the claim.", + "facts": [ + { + "fact_type_code": "specification_change", + "value_text": "revised specification", + "evidence_text": "The revised specification caused the claim.", + } + ], + "missing_fact_type_codes": ["order", "originating_order", "sales_pool"], + "milestones": [], + "missing_milestone_type_codes": ["claim_received", "cause_confirmed"], + }, + { + "case_kind_code": "rebid_handover", + "summary_text": "Rebid agreement", + "evidence_text": "Mina agreed with Alex to rebid.", + "facts": [ + { + "fact_type_code": "counterparty", + "value_text": "Mina and Alex", + "evidence_text": "Mina agreed with Alex to rebid.", + } + ], + "missing_fact_type_codes": ["discussion", "our_owner", "decision"], + "milestones": [], + "missing_milestone_type_codes": [ + "rebid_response_requested", + "rebid_decision_recorded", + "handover_started", + "handover_accepted", + ], + }, ] result = parse_operations_case_response(json.dumps(payload), body) assert result is not None - assert [case.case_kind_code for case in result] == ["claim_investigation", "rebid_handover"] + assert [case.case_kind_code for case in result] == [ + "claim_investigation", + "rebid_handover", + ] def test_rejects_uncited_model_claim() -> None: """A plausible answer absent from the source is not persisted.""" - payload = [{"case_kind_code": "external_information", "summary_text": "Market note", "evidence_text": "invented", "facts": [], "missing_fact_type_codes": ["external_relation"]}] + payload = [ + { + "case_kind_code": "external_information", + "summary_text": "Market note", + "evidence_text": "invented", + "facts": [], + "missing_fact_type_codes": ["external_relation"], + "milestones": [], + "missing_milestone_type_codes": [], + } + ] assert parse_operations_case_response(json.dumps(payload), "source body") is None @@ -31,17 +81,43 @@ def test_accepts_supported_no_case_result() -> None: def test_rejects_unknown_codes_and_malformed_json() -> None: """Closed vocabularies prevent provider prose from entering persistence.""" assert parse_operations_case_response("not json", "body") is None - assert parse_operations_case_response('[{"case_kind_code":"other"}]', "body") is None + assert ( + parse_operations_case_response('[{"case_kind_code":"other"}]', "body") is None + ) def test_rejects_duplicate_case_kinds_and_blank_evidence() -> None: """One normalized key has one grounded classification, never an empty span.""" duplicate = [ - {"case_kind_code": "repeat_issue", "summary_text": "First", "evidence_text": "body", "facts": [], "missing_fact_type_codes": ["issue_pattern", "improvement_action"]}, - {"case_kind_code": "repeat_issue", "summary_text": "Second", "evidence_text": "body", "facts": [], "missing_fact_type_codes": ["issue_pattern", "improvement_action"]}, + { + "case_kind_code": "repeat_issue", + "summary_text": "First", + "evidence_text": "body", + "facts": [], + "missing_fact_type_codes": ["issue_pattern", "improvement_action"], + "milestones": [], + "missing_milestone_type_codes": [], + }, + { + "case_kind_code": "repeat_issue", + "summary_text": "Second", + "evidence_text": "body", + "facts": [], + "missing_fact_type_codes": ["issue_pattern", "improvement_action"], + "milestones": [], + "missing_milestone_type_codes": [], + }, ] blank = [ - {"case_kind_code": "repeat_issue", "summary_text": "Blank", "evidence_text": "", "facts": [], "missing_fact_type_codes": ["issue_pattern", "improvement_action"]} + { + "case_kind_code": "repeat_issue", + "summary_text": "Blank", + "evidence_text": "", + "facts": [], + "missing_fact_type_codes": ["issue_pattern", "improvement_action"], + "milestones": [], + "missing_milestone_type_codes": [], + } ] assert parse_operations_case_response(json.dumps(duplicate), "body") is None assert parse_operations_case_response(json.dumps(blank), "body") is None @@ -51,21 +127,29 @@ def test_linked_fact_retains_its_authorized_source_post_and_input_digest() -> No """A linked specification fact is never attributed to the focal record.""" sources = ( OperationsEvidenceSource("focal", "Claim", "A claim was received."), - OperationsEvidenceSource("linked", "Specification", "Specification S2 replaced S1."), + OperationsEvidenceSource( + "linked", "Specification", "Specification S2 replaced S1." + ), ) - payload = [{ - "case_kind_code": "claim_investigation", - "summary_text": "Specification changed before the claim", - "evidence_post_id": "focal", - "evidence_text": "A claim was received.", - "facts": [{ - "fact_type_code": "specification_change", - "value_text": "S2 replaced S1", - "evidence_post_id": "linked", - "evidence_text": "Specification S2 replaced S1.", - }], - "missing_fact_type_codes": ["order", "originating_order", "sales_pool"], - }] + payload = [ + { + "case_kind_code": "claim_investigation", + "summary_text": "Specification changed before the claim", + "evidence_post_id": "focal", + "evidence_text": "A claim was received.", + "facts": [ + { + "fact_type_code": "specification_change", + "value_text": "S2 replaced S1", + "evidence_post_id": "linked", + "evidence_text": "Specification S2 replaced S1.", + } + ], + "missing_fact_type_codes": ["order", "originating_order", "sales_pool"], + "milestones": [], + "missing_milestone_type_codes": ["claim_received", "cause_confirmed"], + } + ] result = parse_operations_case_response(json.dumps(payload), sources) @@ -78,13 +162,17 @@ def test_linked_fact_retains_its_authorized_source_post_and_input_digest() -> No def test_requires_each_case_question_to_be_supported_or_explicitly_missing() -> None: """The provider cannot silently omit or both support and miss a required answer.""" - payload = [{ - "case_kind_code": "external_information", - "summary_text": "External notice", - "evidence_text": "A public notice was published.", - "facts": [], - "missing_fact_type_codes": [], - }] + payload = [ + { + "case_kind_code": "external_information", + "summary_text": "External notice", + "evidence_text": "A public notice was published.", + "facts": [], + "missing_fact_type_codes": [], + "milestones": [], + "missing_milestone_type_codes": [], + } + ] body = "A public notice was published." assert parse_operations_case_response(json.dumps(payload), body) is None @@ -102,6 +190,8 @@ def test_accepts_additional_grounded_fact_beyond_required_questions() -> None: {"fact_type_code": "discussion", "value_text": "Claim discussion", "evidence_text": "claim changed"}, ], "missing_fact_type_codes": ["order", "originating_order"], + "milestones": [], + "missing_milestone_type_codes": ["claim_received", "cause_confirmed"], }] result = parse_operations_case_response(json.dumps(payload), body) assert result is not None @@ -139,6 +229,8 @@ def test_accepts_grounded_nonrequired_fact_after_required_questions_are_complete }, ], "missing_fact_type_codes": [], + "milestones": [], + "missing_milestone_type_codes": [], }] assert parse_operations_case_response(json.dumps(payload), body) is not None @@ -158,6 +250,8 @@ def test_external_relation_requires_a_semantic_target_type() -> None: "evidence_text": body, "facts": [fact], "missing_fact_type_codes": [], + "milestones": [], + "missing_milestone_type_codes": [], }] result = parse_operations_case_response(json.dumps(payload), body) diff --git a/tests/test_operations_case_ingestion.py b/tests/test_operations_case_ingestion.py index e6df5b1b2..f24b1f3eb 100644 --- a/tests/test_operations_case_ingestion.py +++ b/tests/test_operations_case_ingestion.py @@ -1,9 +1,17 @@ """Operational case persistence tests.""" import asyncio +from datetime import UTC, datetime -from backend.app.operations_case_ingestion import persist_operations_cases, source_body_digest -from lineageweave.operations_case_analysis import OperationsCase, OperationsCaseFact +from backend.app.operations_case_ingestion import ( + persist_operations_cases, + source_body_digest, +) +from lineageweave.operations_case_analysis import ( + OperationsCase, + OperationsCaseFact, + OperationsCaseMilestone, +) class _Transaction: @@ -72,11 +80,59 @@ def test_persists_missing_required_facts_without_invented_evidence() -> None: ("order", "specification_change", "originating_order", "sales_pool"), ) - asyncio.run(persist_operations_cases(conn, "post-1", "source", "session-1", (case,))) + asyncio.run( + persist_operations_cases(conn, "post-1", "source", "session-1", (case,)) + ) + + assert conn.batches == [ + [ + ("post-1", "claim_investigation", "order"), + ("post-1", "claim_investigation", "specification_change"), + ("post-1", "claim_investigation", "originating_order"), + ("post-1", "claim_investigation", "sales_pool"), + ] + ] + + +def test_persists_observed_and_missing_milestones_separately() -> None: + """An observed source instant is never replaced by an invented endpoint.""" + conn = _Connection() + observed_at = datetime(2026, 8, 1, tzinfo=UTC) + case = OperationsCase( + "claim_investigation", + "Claim", + "source", + (), + "post-1", + "a" * 64, + ("order", "specification_change", "originating_order", "sales_pool"), + ( + OperationsCaseMilestone( + "claim_received", + "source", + "post-1", + "a" * 64, + observed_at, + "event_occurred_at", + ), + ), + ("cause_confirmed",), + ) + + asyncio.run( + persist_operations_cases(conn, "post-1", "source", "session-1", (case,)) + ) - assert conn.batches == [[ - ("post-1", "claim_investigation", "order"), - ("post-1", "claim_investigation", "specification_change"), - ("post-1", "claim_investigation", "originating_order"), - ("post-1", "claim_investigation", "sales_pool"), - ]] + assert conn.batches[-2] == [ + ( + "post-1", + "claim_investigation", + "claim_received", + "source", + "post-1", + "a" * 64, + observed_at, + "event_occurred_at", + ) + ] + assert conn.batches[-1] == [("post-1", "claim_investigation", "cause_confirmed")] diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py index 343df2b39..e5742d081 100644 --- a/tests/test_operations_dashboard.py +++ b/tests/test_operations_dashboard.py @@ -1,6 +1,6 @@ """Focused tests for the operational dashboard evidence projection.""" -from datetime import date, datetime, timezone +from datetime import UTC, date, datetime, timezone import pytest @@ -49,6 +49,29 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: "case_kind_code": "claim_investigation", "fact_type_code": "sales_pool", }] + if "operations_case_milestone milestone" in query: + return [ + { + "post_id": "00000000-0000-0000-0000-000000000001", + "case_kind_code": "claim_investigation", + "milestone_type_code": "claim_received", + "evidence_text": "The claim was received", + "evidence_post_id": "00000000-0000-0000-0000-000000000001", + "observed_at": datetime(2026, 8, 1, 9, tzinfo=timezone.utc), + "time_axis_code": "event_occurred_at", + "is_missing": False, + }, + { + "post_id": "00000000-0000-0000-0000-000000000001", + "case_kind_code": "claim_investigation", + "milestone_type_code": "cause_confirmed", + "evidence_text": "The cause was confirmed", + "evidence_post_id": "00000000-0000-0000-0000-000000000002", + "observed_at": datetime(2026, 8, 3, 12, 30, tzinfo=timezone.utc), + "time_axis_code": "created_at", + "is_missing": False, + }, + ] if "from topic_post_context_influence influence" in query: return [] return [ @@ -140,11 +163,61 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None: "missing_facts": [ {"fact_type_code": "sales_pool", "fact_type_label": "수주 Pool"} ], + "milestones": [ + { + "milestone_type_code": "claim_received", + "milestone_type_label": "클레임 접수", + "evidence_text": "The claim was received", + "evidence_post_id": "00000000-0000-0000-0000-000000000001", + "observed_at": "2026-08-01T09:00:00+00:00", + "time_axis_code": "event_occurred_at", + "time_axis_label": "Event 발생일", + }, + { + "milestone_type_code": "cause_confirmed", + "milestone_type_label": "원인 확정", + "evidence_text": "The cause was confirmed", + "evidence_post_id": "00000000-0000-0000-0000-000000000002", + "observed_at": "2026-08-03T12:30:00+00:00", + "time_axis_code": "created_at", + "time_axis_label": "기록 생성일", + }, + ], + "lifecycles": [ + { + "lifecycle_kind_code": "claim_investigation", + "lifecycle_kind_label": "클레임 원인 규명", + "status_code": "resolved", + "status_label": "종료 확인", + "started_at": "2026-08-01T09:00:00+00:00", + "resolved_at": "2026-08-03T12:30:00+00:00", + "elapsed_seconds": 185400, + "start_milestone": { + "milestone_type_code": "claim_received", + "milestone_type_label": "클레임 접수", + "evidence_text": "The claim was received", + "evidence_post_id": "00000000-0000-0000-0000-000000000001", + "observed_at": "2026-08-01T09:00:00+00:00", + "time_axis_code": "event_occurred_at", + "time_axis_label": "Event 발생일", + }, + "end_milestone": { + "milestone_type_code": "cause_confirmed", + "milestone_type_label": "원인 확정", + "evidence_text": "The cause was confirmed", + "evidence_post_id": "00000000-0000-0000-0000-000000000002", + "observed_at": "2026-08-03T12:30:00+00:00", + "time_axis_code": "created_at", + "time_axis_label": "기록 생성일", + }, + "next_action_text": "시작·종료 Event 근거를 열어 경과 시간을 검토하세요.", + } + ], } ] assert result["topic_context"]["status_code"] == "unavailable" assert result["topic_context"]["reason_code"] == "tepp_topic_posterior_not_persisted" - assert len(conn.queries) == 6 + assert len(conn.queries) == 7 for query, args in conn.queries: assert "visibility_code = 'public'" in query assert "corporate_entity_id::text = any($1::text[])" in query @@ -158,7 +231,18 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None: ) case_query = conn.queries[1][0] assert "order by primary_mention.confidence desc" in case_query - assert "coalesce(nullif(btrim(post.source_project_name), ''), project.primary_project_name)" in case_query + assert ( + "coalesce(nullif(btrim(post.source_project_name), ''), project.primary_project_name)" + in case_query + ) + for evidence_query in ( + conn.queries[0][0], + conn.queries[1][0], + conn.queries[2][0], + conn.queries[4][0], + ): + assert "join source_post evidence_post" in evidence_query + assert "evidence_post.corporate_entity_id::text = any($1::text[])" in evidence_query @pytest.mark.anyio @@ -278,7 +362,13 @@ async def fetchrow(self, query: str, *args: object) -> dict[str, int]: "fast_mlsirm_influence_persisted": False, } return dict.fromkeys( - ("total_post_count", "total_event_count", "external_post_count", "pending_analysis_count", "failed_analysis_count"), + ( + "total_post_count", + "total_event_count", + "external_post_count", + "pending_analysis_count", + "failed_analysis_count", + ), 0, ) @@ -313,6 +403,8 @@ async def fetchrow(self, query: str, *args: object) -> dict[str, object]: async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: self.queries.append((query, args)) + if "operations_case_milestone milestone" in query: + return [] if "from topic_post_context_influence influence" in query: return [] if "operations_case_fact fact" in query: diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py index 7bd661b44..46f9fa02d 100644 --- a/tests/test_post_content_worker.py +++ b/tests/test_post_content_worker.py @@ -4,6 +4,7 @@ import asyncio from contextlib import asynccontextmanager +from datetime import UTC, datetime from types import SimpleNamespace from backend.app import post_content_worker @@ -109,6 +110,39 @@ async def gather(_conn, _post_id, can_see, _vision): assert decisions == [True, False, False, True] +def test_operations_sources_bind_milestones_to_source_owned_clocks(monkeypatch) -> None: + """The source row, not model output, supplies each milestone instant.""" + observed_at = datetime(2026, 8, 1, 9, tzinfo=UTC) + + async def gather(*_args): + return [SimpleNamespace( + post_id="00000000-0000-0000-0000-000000000001", + post_title="Synthetic claim", + post_body="A claim was received.", + evidence_facts=(), + )] + + class SourceConnection(_Connection): + async def fetch(self, query: str, *_args: object): + assert "coalesce(event_occurred_at, created_at) as observed_at" in query + return [{ + "post_id": "00000000-0000-0000-0000-000000000001", + "event_occurred_at": observed_at, + "observed_at": observed_at, + }] + + monkeypatch.setattr(post_content_worker, "gather_chat_sources", gather) + sources = asyncio.run(post_content_worker._operations_evidence_sources( + _Pool(SourceConnection()), + "00000000-0000-0000-0000-000000000001", + {"corporate_entity_id": "corp", "process_unit_id": "pu"}, + SimpleNamespace(available=False), + )) + + assert sources[0].observed_at == observed_at + assert sources[0].time_axis_code == "event_occurred_at" + + def test_terminal_failed_job_ignores_a_stale_duplicate_wakeup() -> None: connection = _Connection(_row(FAILED, POST_CONTENT_MAX_ATTEMPTS)) diff --git a/tests/test_schema.py b/tests/test_schema.py index 5f7c730aa..0474ac74d 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -26,12 +26,16 @@ _ADMIN_DSN = os.environ.get( "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" ) -_MIGRATION_PATH = Path(__file__).resolve().parents[1] / "migrations" / "0001_initial_schema.sql" +_MIGRATION_PATH = ( + Path(__file__).resolve().parents[1] / "migrations" / "0001_initial_schema.sql" +) _MAJOR_EVENT_ACTION_MIGRATION = ( Path(__file__).resolve().parents[1] / "migrations" / "0100_major_event_action.sql" ) _PROJECT_MENTION_MIGRATION = ( - Path(__file__).resolve().parents[1] / "migrations" / "0031_semantic_project_mentions.sql" + Path(__file__).resolve().parents[1] + / "migrations" + / "0031_semantic_project_mentions.sql" ) _PROJECT_BOUND_ACTION_MIGRATION = ( Path(__file__).resolve().parents[1] @@ -74,7 +78,9 @@ / "0169_report_leftover_map_axis.sql" ) _CHANNEL_EVIDENCE_MIGRATION = ( - Path(__file__).resolve().parents[1] / "migrations" / "0174_post_lineage_edge_signal.sql" + Path(__file__).resolve().parents[1] + / "migrations" + / "0174_post_lineage_edge_signal.sql" ) _LEFTOVER_MAP_COVERAGE_MIGRATION = ( Path(__file__).resolve().parents[1] @@ -87,13 +93,24 @@ / "0182_report_leftover_map_unexplained.sql" ) _OPERATIONS_CASE_MIGRATION = ( - Path(__file__).resolve().parents[1] / "migrations" / "0208_operations_case_analysis.sql" + Path(__file__).resolve().parents[1] + / "migrations" + / "0208_operations_case_analysis.sql" ) _OPERATIONS_CASE_EVIDENCE_MIGRATION = ( - Path(__file__).resolve().parents[1] / "migrations" / "0209_operations_case_evidence_source.sql" + Path(__file__).resolve().parents[1] + / "migrations" + / "0209_operations_case_evidence_source.sql" ) _OPERATIONS_CASE_MISSING_MIGRATION = ( - Path(__file__).resolve().parents[1] / "migrations" / "0211_operations_case_missing_fact.sql" + Path(__file__).resolve().parents[1] + / "migrations" + / "0211_operations_case_missing_fact.sql" +) +_OPERATIONS_CASE_MILESTONE_MIGRATION = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0215_operations_case_milestone.sql" ) _ANALYSIS_RUN_REGISTRY_MIGRATION = ( Path(__file__).resolve().parents[1] / "migrations" / "0018_analysis_run_registry.sql" From 72e0c9bb5fb46028a5bab486f82b48a67a271628 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 01:49:57 +0900 Subject: [PATCH 029/393] fix(dashboard): clarify observed lifecycle evidence --- .../adr/0206-evidence-operations-dashboard.md | 30 +++++++++++++++++++ docs/product-technical-gap-baseline.md | 19 +++++++----- .../components/OperationsDashboard.test.tsx | 15 ++++++++++ .../src/components/OperationsDashboard.tsx | 2 +- 4 files changed, 57 insertions(+), 9 deletions(-) diff --git a/docs/adr/0206-evidence-operations-dashboard.md b/docs/adr/0206-evidence-operations-dashboard.md index cacb982e2..371f41357 100644 --- a/docs/adr/0206-evidence-operations-dashboard.md +++ b/docs/adr/0206-evidence-operations-dashboard.md @@ -122,6 +122,36 @@ provenance. to typed ontology properties. This is not a `knowledge_graph_edge` alias: PostgreSQL operations tables remain authoritative, and an older untyped relation remains absent from the typed projection until re-analysis. +16. Claim investigation and rebid/handover use an observed event-log contract + aligned with IEEE 1849-2023 (XES). A classification is the local analysis + case identifier; a milestone has a closed activity code, an exact cited + evidence span, its evidence post, source digest, observed instant, and named + clock. Cross-post business-case identity is not inferred from project, + similarity, proximity, or text. +17. Claim investigation pairs `claim_received` with `cause_confirmed`. + Rebid/handover independently pairs `rebid_response_requested` with + `rebid_decision_recorded`, and `handover_started` with + `handover_accepted`. Contextual-orchestrator identifies the supported + milestone semantics; LineageWeave assigns the instant only from that cited + `source_post`: `event_occurred_at` when present, otherwise the explicitly + labeled `created_at` fallback from ADR 0202. The model never emits a date. +18. Each required endpoint is exactly one cited milestone or one normalized + missing-milestone row. Both observed endpoints produce the exact duration + `end - start`; start plus an explicitly missing end is `open`; a missing + start is `evidence_missing`. An open case has no elapsed duration because + no end instant was observed. Reversed observed endpoints reject the entire + provider result. No delay threshold, severity band, current-time endpoint, + imputed date, average, score, or arbitrary weight is introduced. Equal + source instants yield an auditable zero duration; they are not replaced by + an invented sub-record timestamp. +19. The API rechecks the reader's current ABAC and source eligibility for each + classification, fact, and milestone evidence post before returning its + span. Consequently, aggregate counts exclude classifications whose cited + evidence is no longer authorized. The UI reports open, resolved, and + evidence-missing counts separately, shows exact elapsed seconds in a + lossless human-readable form, names each milestone's clock, and links the + reader to both endpoint sources. State and next action are conveyed in text + rather than color alone. ## Consequences diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 098b2b031..f948f44ca 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -80,15 +80,16 @@ remain local audit evidence and are not committed. ### Exact open-PR boundary -At this snapshot there were 8 open PRs and 10 open issues. Exact observed heads -were `#643 041ec13b`, `#640 985b4492`, `#639 aee02dca`, `#636 f7b9a65f`, `#632 e1ebe50a`, -`#631 c0022c97`, `#629 74823e99`, and `#579 689a21b6`. All remain blocked on +At this snapshot there were 10 open PRs and 10 open issues. Exact observed heads +were `#654 ff096c18`, `#644 c1018a0a`, `#643 041ec13b`, `#640 6f421191`, +`#639 aee02dca`, `#636 f7b9a65f`, `#632 d227edce`, `#631 c0022c97`, +`#629 4b4d6707`, and `#579 689a21b6`. All remain blocked on hosted gates and/or independent review. These observations are not merge readiness. Re-fetch exact heads, unresolved threads, checks, approvals, rulesets, and merge SHA before any lifecycle claim. -> Audit snapshot: 2026-08-26 (refreshed by the autonomous merge +> Audit snapshot: 2026-08-26 01:50 KST (refreshed by the autonomous merge > loop). This repository records synthetic fixtures and aggregate, > non-identifying runtime evidence only. Open PRs and local checks are not > protected-default-branch release evidence. Identifying post identifiers, @@ -98,20 +99,22 @@ lifecycle claim. ## 1. Exact-head and governance evidence The protected default branch was `04e6b610655d0db91d5f7ba9486bdda1440e0b19` -when this baseline was refreshed. The live queue contained 8 open PRs and 10 +when this baseline was refreshed. The live queue contained 10 open PRs and 10 open issues. The exact-head inventory below supersedes older per-PR snapshots elsewhere in this document; those older rows remain useful historical delivery context only. | PR | Exact observed head | Merge/check state at this snapshot | | ---: | --- | --- | +| #654 | `ff096c18` | repairs long ontology labels; hosted gates and independent review remain required | +| #644 | `c1018a0a` | splits conditional frontend surfaces and preserves keyed recovery; hosted gates and independent review remain required | | #643 | `041ec13b` | shares token-backed success/unavailable/retry status notices for the Calendar surface; hosted gates and independent review remain required | -| #640 | `985b4492` | quantifies dashboard case metrics, preserves confidence-ranked project labels, persists explicit missing required facts, and enforces distinct event counts plus SQL-level external scoping; hosted gates and independent review remain required | +| #640 | `6f421191` (observed parent) | quantifies dashboard cases, persists explicit missing facts and observed lifecycle milestones, and consumes producer-owned topic influence without local arithmetic; this documentation repair advances the head, so hosted gates and independent review must run again | | #639 | `aee02dca` | repairs Running-action, Compose, and TEPP configuration contracts; hosted gates and independent review remain required | | #636 | `f7b9a65f` | publishes the calibrated external-lineage contract without a redundant explicit-child filter and repairs test import hygiene; hosted gates and independent review remain required | -| #632 | `e1ebe50a` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required | +| #632 | `d227edce` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required | | #631 | `c0022c97` | decomposes ADR gaps and queue baseline; hosted gates and independent review remain required | -| #629 | `74823e99` | releases provider work from database leases and bounds landing reads; hosted gates and independent review remain required | +| #629 | `4b4d6707` | releases provider work from database leases and bounds landing reads; hosted gates and independent review remain required | | #579 | `689a21b6` | delegates leftover interaction-map arithmetic to fast-mlsirm and persists only the consumer projection; hosted gates and independent review remain required | No row above is merge evidence. Immediately before any lifecycle action, diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx index dbb9bb5d3..c61954710 100644 --- a/frontend/src/components/OperationsDashboard.test.tsx +++ b/frontend/src/components/OperationsDashboard.test.tsx @@ -66,6 +66,21 @@ describe("OperationsDashboardView", () => { expect(onOpenPost).toHaveBeenCalledWith("evidence-post-1"); }); + it("does not imply that only the end evidence is missing", () => { + const openLifecycle = { + ...data.cases[0].lifecycles[0], + status_code: "evidence_missing" as const, + status_label: "측정 근거 부족", + started_at: null, + resolved_at: null, + elapsed_seconds: null, + start_milestone: null, + end_milestone: null, + }; + render( undefined} />); + expect(screen.getByText("경과 시간은 필요한 시작·종료 Event 근거가 모두 관측될 때 계산됩니다.")).toBeInTheDocument(); + }); + it("shows an actionable empty external-information state", () => { render( undefined} />); expect(screen.getByRole("status")).toHaveTextContent("기간이나 접근 범위를 확인하세요"); diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx index f7c93dacb..fc26540d4 100644 --- a/frontend/src/components/OperationsDashboard.tsx +++ b/frontend/src/components/OperationsDashboard.tsx @@ -153,7 +153,7 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost {item.lifecycles.map((lifecycle) => (

{lifecycle.lifecycle_kind_label}

{lifecycle.status_label}
- {lifecycle.elapsed_seconds !== null ?

확정 경과 시간 {formatElapsed(lifecycle.elapsed_seconds)}

:

경과 시간은 종료 Event가 관측될 때 계산됩니다.

} + {lifecycle.elapsed_seconds !== null ?

확정 경과 시간 {formatElapsed(lifecycle.elapsed_seconds)}

:

경과 시간은 필요한 시작·종료 Event 근거가 모두 관측될 때 계산됩니다.

}
    {[lifecycle.start_milestone, lifecycle.end_milestone].filter((milestone) => milestone !== null).map((milestone) => (
  1. From 6d397d3f8427bd29e47e82dd642e14b391d442dc Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 01:53:37 +0900 Subject: [PATCH 030/393] fix(dashboard): enforce provider and SQL boundaries --- backend/app/operations_dashboard.py | 2 +- backend/app/post_content_worker.py | 2 ++ frontend/src/api.ts | 2 +- lineageweave/operations_case_analysis.py | 8 +++++-- tests/test_operations_case_analysis.py | 14 ++++++++++++ tests/test_operations_dashboard.py | 7 ++++-- tests/test_post_content_worker.py | 27 ++++++++++++++++++++++++ 7 files changed, 56 insertions(+), 6 deletions(-) diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py index 4238ecc8d..be6fbdbd5 100644 --- a/backend/app/operations_dashboard.py +++ b/backend/app/operations_dashboard.py @@ -314,7 +314,7 @@ async def fetch_operations_dashboard( "topics": [], } if external_only - else await _fetch_topic_context_dashboard(conn, visible, args) + else await _fetch_topic_context_dashboard(conn, visible, args[:4]) ) facts: dict[tuple[str, str], list[dict[str, str]]] = {} for row in fact_rows: diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py index fd7dd8d2f..10f8e958a 100644 --- a/backend/app/post_content_worker.py +++ b/backend/app/post_content_worker.py @@ -86,6 +86,8 @@ def can_see(row: asyncpg.Record) -> bool: [source.post_id for source in sources], ) } + if any(source.post_id not in source_times for source in sources): + raise RuntimeError("authorized evidence source clock unavailable") return tuple( OperationsEvidenceSource( source.post_id, diff --git a/frontend/src/api.ts b/frontend/src/api.ts index fc76a6b6d..858b871b3 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -123,7 +123,7 @@ export interface OperationsDashboardResponse { } export interface TopicContextDashboard { - status_code: "accepted" | "unavailable"; + status_code: "accepted" | "unavailable" | "not_applicable"; reason_code: string | null; next_action: string; required_contracts: Array<{ diff --git a/lineageweave/operations_case_analysis.py b/lineageweave/operations_case_analysis.py index b27468706..62471f7df 100644 --- a/lineageweave/operations_case_analysis.py +++ b/lineageweave/operations_case_analysis.py @@ -263,11 +263,15 @@ def parse_operations_case_response( ) ) supported_types = {fact.fact_type_code for fact in parsed_facts} + if any( + not isinstance(code, str) or code not in FACT_TYPES + for code in missing_fact_types + ): + return None missing_types = set(missing_fact_types) required_types = REQUIRED_FACT_TYPES[item["case_kind_code"]] if ( - any(not isinstance(code, str) or code not in FACT_TYPES for code in missing_fact_types) - or len(missing_types) != len(missing_fact_types) + len(missing_types) != len(missing_fact_types) or not missing_types.issubset(required_types) or supported_types.intersection(missing_types) or not required_types.issubset(supported_types.union(missing_types)) diff --git a/tests/test_operations_case_analysis.py b/tests/test_operations_case_analysis.py index 7b248c168..7d94f519a 100644 --- a/tests/test_operations_case_analysis.py +++ b/tests/test_operations_case_analysis.py @@ -73,6 +73,20 @@ def test_rejects_uncited_model_claim() -> None: assert parse_operations_case_response(json.dumps(payload), "source body") is None +def test_rejects_unhashable_missing_fact_code() -> None: + """Malformed provider arrays are rejected without escaping the parser.""" + payload = [{ + "case_kind_code": "external_information", + "summary_text": "Market note", + "evidence_text": "source body", + "facts": [], + "missing_fact_type_codes": [{}], + "milestones": [], + "missing_milestone_type_codes": [], + }] + assert parse_operations_case_response(json.dumps(payload), "source body") is None + + def test_accepts_supported_no_case_result() -> None: """An empty semantic result remains distinct from malformed output.""" assert parse_operations_case_response("[]", "ordinary status") == () diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py index e5742d081..eac3776c9 100644 --- a/tests/test_operations_dashboard.py +++ b/tests/test_operations_dashboard.py @@ -16,6 +16,7 @@ def __init__(self) -> None: async def fetchrow(self, query: str, *args: object) -> dict[str, int]: self.queries.append((query, args)) if "tepp_posterior_persisted" in query: + assert len(args) == 4 return { "tepp_posterior_persisted": False, "fast_mlsirm_influence_persisted": False, @@ -73,6 +74,7 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: }, ] if "from topic_post_context_influence influence" in query: + assert len(args) == 4 return [] return [ { @@ -223,12 +225,13 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None: assert "corporate_entity_id::text = any($1::text[])" in query assert "process_unit_id::text = any($2::text[])" in query assert "coalesce(post.event_occurred_at, post.created_at)" in query - assert args[1:] == ( + assert args[:4] == ( + ["00000000-0000-0000-0000-000000000009"], ["00000000-0000-0000-0000-000000000008"], date(2026, 8, 1), date(2026, 8, 31), - False, ) + assert args[4:] == ((False,) if "$5" in query else ()) case_query = conn.queries[1][0] assert "order by primary_mention.confidence desc" in case_query assert ( diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py index 46f9fa02d..b14fe75df 100644 --- a/tests/test_post_content_worker.py +++ b/tests/test_post_content_worker.py @@ -7,6 +7,8 @@ from datetime import UTC, datetime from types import SimpleNamespace +import pytest + from backend.app import post_content_worker from backend.app.post_content_queue import ( FAILED, @@ -143,6 +145,31 @@ async def fetch(self, query: str, *_args: object): assert sources[0].time_axis_code == "event_occurred_at" +def test_operations_sources_retry_when_a_source_clock_disappears(monkeypatch) -> None: + """A source deleted during assembly fails explicitly instead of inventing time.""" + + async def gather(*_args): + return [SimpleNamespace( + post_id="00000000-0000-0000-0000-000000000001", + post_title="Synthetic claim", + post_body="A claim was received.", + evidence_facts=(), + )] + + class MissingClockConnection(_Connection): + async def fetch(self, *_args: object): + return [] + + monkeypatch.setattr(post_content_worker, "gather_chat_sources", gather) + with pytest.raises(RuntimeError, match="source clock unavailable"): + asyncio.run(post_content_worker._operations_evidence_sources( + _Pool(MissingClockConnection()), + "00000000-0000-0000-0000-000000000001", + {"corporate_entity_id": "corp", "process_unit_id": "pu"}, + SimpleNamespace(available=False), + )) + + def test_terminal_failed_job_ignores_a_stale_duplicate_wakeup() -> None: connection = _Connection(_row(FAILED, POST_CONTENT_MAX_ATTEMPTS)) From b045a6e5aa06e115011d5357a32a717f1628143a Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 01:58:38 +0900 Subject: [PATCH 031/393] fix(dashboard): bind evidence and topic query scopes --- backend/app/operations_dashboard.py | 1 - tests/test_operations_dashboard.py | 1 - 2 files changed, 2 deletions(-) diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py index be6fbdbd5..6da64b5ba 100644 --- a/backend/app/operations_dashboard.py +++ b/backend/app/operations_dashboard.py @@ -275,7 +275,6 @@ async def fetch_operations_dashboard( from operations_case_missing_fact missing join source_post post on post.post_id = missing.post_id where {visible} - and {visible_evidence} and ($5::boolean is false or missing.case_kind_code = 'external_information') order by missing.post_id, missing.case_kind_code, missing.fact_type_code """, diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py index eac3776c9..37aed4dae 100644 --- a/tests/test_operations_dashboard.py +++ b/tests/test_operations_dashboard.py @@ -242,7 +242,6 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None: conn.queries[0][0], conn.queries[1][0], conn.queries[2][0], - conn.queries[4][0], ): assert "join source_post evidence_post" in evidence_query assert "evidence_post.corporate_entity_id::text = any($1::text[])" in evidence_query From 49ab9bb1ba3e239ce02918d2e08726a1708d10be Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 02:00:03 +0900 Subject: [PATCH 032/393] docs(operability): record dashboard concurrency evidence --- docs/operability/http-concurrency-evidence.md | 20 +++++++++++++++++++ docs/product-technical-gap-baseline.md | 7 ++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/docs/operability/http-concurrency-evidence.md b/docs/operability/http-concurrency-evidence.md index 1db0922a6..0ec1d2c2d 100644 --- a/docs/operability/http-concurrency-evidence.md +++ b/docs/operability/http-concurrency-evidence.md @@ -56,6 +56,26 @@ or shared-runner result to a product guarantee. Figma and screenshot review do not apply: this is a non-UI HTTP load harness. +## Dashboard candidate verification record + +On 2026-08-26, the synthetic 27-post Compose dataset at candidate head +`b045a6e5` ran with 4 VUs for 30 seconds on alternate local ports. It completed +1,240 iterations and 4,962 authenticated HTTP requests with zero failed +requests and 4,960/4,960 successful checks across posts, Event Lineage, +Dashboard, and Ask polling. Overall request duration was 75.02 ms average, +56.73 ms median, 181.76 ms p95, and 791.89 ms maximum; the combined reader +metric was 81.68 ms average and 197.25 ms p95. The one Ask enqueue took +173.66 ms, while Ask polling averaged 54.80 ms with 132.98 ms p95. + +The first candidate run exposed two Dashboard-only SQL contract defects: +an evidence-post predicate in the missing-fact query despite that query having +no evidence-post join, and a fifth bind value passed to the four-parameter +topic projection. Both failed every Dashboard request while sibling endpoints +remained responsive. The shared query boundary was repaired and regression +tests now assert the join and bind arity; the distribution above is the clean +rerun. This is synthetic candidate evidence, not protected-main evidence or a +capacity/SLO claim. + ## Current-main verification record On 2026-08-25, a worktree based on protected-main commit `48f013a2` passed diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index f948f44ca..ca95fd0f2 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,7 +1,8 @@ # Product & Technical Gap Baseline > Dashboard delivery snapshot: 2026-08-26 (latest exact-head fetch). Protected `main` was -> `04e6b610655d0db91d5f7ba9486bdda1440e0b19`. This local branch is not +> `04e6b610655d0db91d5f7ba9486bdda1440e0b19`. Dashboard candidate head +> `b045a6e5` has local synthetic runtime evidence; this is not > protected-main release evidence. ## Operations Dashboard PRD/TRD traceability @@ -109,7 +110,7 @@ context only. | #654 | `ff096c18` | repairs long ontology labels; hosted gates and independent review remain required | | #644 | `c1018a0a` | splits conditional frontend surfaces and preserves keyed recovery; hosted gates and independent review remain required | | #643 | `041ec13b` | shares token-backed success/unavailable/retry status notices for the Calendar surface; hosted gates and independent review remain required | -| #640 | `6f421191` (observed parent) | quantifies dashboard cases, persists explicit missing facts and observed lifecycle milestones, and consumes producer-owned topic influence without local arithmetic; this documentation repair advances the head, so hosted gates and independent review must run again | +| #640 | `b045a6e5` | quantifies dashboard cases, persists explicit missing facts and observed lifecycle milestones, consumes producer-owned topic influence without local arithmetic, and repairs runtime evidence-query joins/bind arity; hosted gates and independent review must run on this exact head | | #639 | `aee02dca` | repairs Running-action, Compose, and TEPP configuration contracts; hosted gates and independent review remain required | | #636 | `f7b9a65f` | publishes the calibrated external-lineage contract without a redundant explicit-child filter and repairs test import hygiene; hosted gates and independent review remain required | | #632 | `d227edce` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required | @@ -400,7 +401,7 @@ this file per §3.5 of the prior snapshot). | Shared frontend gate | The ADR 0109 login repair is on protected `main`; eight older branches carried the defect and received the same verified repair this loop (#521–#560) | Keep every future branch cut from post-repair bases; re-verify with frontend lint/test/build before push | | Identifying baseline regression | `main` gap file listed real post identifiers; separately, closed #506 and pre-existing public history contain a private runtime source-table identifier, while current `main` and #507 trees are clean | Land this non-identifying rewrite, then coordinate ADR 0001 history remediation with security/privacy owners; do not reproduce the value, force-push, or delete evidence ad hoc | | Authorized-corpus runtime | Repository tests use synthetic fixtures; private records remain outside git | Authenticated runtime validation returning only aggregate, non-identifying evidence | -| Concurrent web responsiveness | ADR 0204 releases pooled transactions during provider work, and the synthetic Compose boundary has an authenticated k6 E2E harness for Ask enqueue, concurrent reads, and job polling. An older-image local observation found repeated post-filter queries while `/api/posts` exceeded 30 seconds; ADR 0212 combines two authorized filter-option queries into one round trip without narrowing ABAC-visible options. The observation is not exact-head evidence or a product guarantee, and no physical scan reduction is claimed without an exact-head plan | Rebuild an exact-head application image, run `make load-http` with declared environment concurrency/window, and retain raw distributions and resource configuration. Compare the post-list database plan and latency with ADR 0212 while preserving the complete authorized filter set; set no SLO until representative capacity evidence is approved | +| Concurrent web responsiveness | ADR 0204 releases pooled transactions during provider work. Candidate `b045a6e5` completed a synthetic authenticated 4-VU/30-second k6 run: 4,962 requests, zero failures, HTTP p95 181.76 ms, and reader p95 197.25 ms. The first run exposed and the candidate repaired Dashboard evidence-join and bind-arity defects. This is local candidate evidence, not a product guarantee | Repeat on the protected merge SHA and a representative deployment/corpus with declared CPU, memory, database pool, worker concurrency, and raw output; approve an SLO only from that capacity evidence | | Image understanding | Region, OCR, and description work exists across active heads (#405, #419), but current runtime acceptance has not yet proved table-image structure, complete region coverage, or summary/image readiness together | Orchestrator-backed rendered workflow, original/derived asset provenance, region-before-OCR processing, and honest unsupported states; reconcile ADR 0052's image-bearing summary readiness with ADR 0098 before changing sequencing | | Semantic source rendering | Paragraph, table, list, formula, and indentation work exists across stacks (#394, #427, #448–#450); #515 adds synthetic backend/frontend parity for deterministic rows/cells, footnote boundaries, and encoded scripts | Land the #427 → #515 stack, then gather authenticated browser evidence that list nesting, continuation alignment, and formula units render without authoring-layout artifacts | | Event and project semantics | Multi-project mentions, project-bound actions, 5W1H, requester/processor, and semantic relations exist in ADR 0036/0052/0100/0111/0129 and active stacks | Aggregate authenticated evidence must show distinct projects and events, explicit requester/processor and real R&R, normalized relative time, and product/entity relations without promoting attendance or co-occurrence | From c780579248a67b98da545e55a2e4845d1d7404b0 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 02:01:10 +0900 Subject: [PATCH 033/393] test(dashboard): execute SQL against PostgreSQL --- .../test_operations_dashboard_postgres.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 backend/tests/test_operations_dashboard_postgres.py diff --git a/backend/tests/test_operations_dashboard_postgres.py b/backend/tests/test_operations_dashboard_postgres.py new file mode 100644 index 000000000..d03bc94b7 --- /dev/null +++ b/backend/tests/test_operations_dashboard_postgres.py @@ -0,0 +1,38 @@ +"""Real-PostgreSQL contract test for the operations Dashboard projection.""" + +from __future__ import annotations + +import os + +import asyncpg +import pytest + +from backend.app.operations_dashboard import fetch_operations_dashboard + + +_POSTGRES_DSN = os.environ.get( + "LINEAGEWEAVE_TEST_POSTGRES_DSN", + "postgresql://lineageweave:lineageweave_dev_only@localhost:15432/lineageweave", +) + + +@pytest.mark.anyio +async def test_operations_dashboard_sql_binds_against_postgres() -> None: + """Execute every Dashboard query through asyncpg's real parser and binder.""" + try: + connection = await asyncpg.connect(_POSTGRES_DSN, timeout=2) + except (OSError, asyncpg.PostgresError): + pytest.skip("requires the migrated local Compose database") + try: + result = await fetch_operations_dashboard(connection, []) + finally: + await connection.close() + + assert result["total_post_count"] >= 0 + assert result["topic_context"]["status_code"] in {"accepted", "unavailable"} + + +@pytest.fixture +def anyio_backend() -> str: + """Use the installed asyncio backend for the asyncpg contract test.""" + return "asyncio" From 2b6571b40ca98f4e7ee4fccc7694e9c4ea39b8e2 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 02:02:04 +0900 Subject: [PATCH 034/393] test(api): isolate seeded analysis snapshots --- backend/tests/test_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index df1744dd2..78b99ab08 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -568,7 +568,7 @@ def _seed_analysis_run( ) other_account_id = cur.fetchone()[0] visible_run_id = _seed_analysis_run( - "a" * 64, + "f" * 64, "visible-own-corp", account_id, "analysis_scope_corporate_entity", From 06ddcc106b86bd211e0c185f8ad7f0179eb35112 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 02:05:46 +0900 Subject: [PATCH 035/393] docs(gaps): refresh exact dashboard queue --- docs/product-technical-gap-baseline.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index ca95fd0f2..2bb4bfa47 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -81,9 +81,9 @@ remain local audit evidence and are not committed. ### Exact open-PR boundary -At this snapshot there were 10 open PRs and 10 open issues. Exact observed heads -were `#654 ff096c18`, `#644 c1018a0a`, `#643 041ec13b`, `#640 6f421191`, -`#639 aee02dca`, `#636 f7b9a65f`, `#632 d227edce`, `#631 c0022c97`, +At this snapshot there were 9 open PRs and 10 open issues. Exact observed heads +were `#644 c1018a0a`, `#643 041ec13b`, `#640 8750638c`, +`#639 aee02dca`, `#636 f7b9a65f`, `#632 a946f879`, `#631 c0022c97`, `#629 4b4d6707`, and `#579 689a21b6`. All remain blocked on hosted gates and/or independent review. These observations are not merge readiness. Re-fetch exact heads, @@ -100,20 +100,19 @@ lifecycle claim. ## 1. Exact-head and governance evidence The protected default branch was `04e6b610655d0db91d5f7ba9486bdda1440e0b19` -when this baseline was refreshed. The live queue contained 10 open PRs and 10 +when this baseline was refreshed. The live queue contained 9 open PRs and 10 open issues. The exact-head inventory below supersedes older per-PR snapshots elsewhere in this document; those older rows remain useful historical delivery context only. | PR | Exact observed head | Merge/check state at this snapshot | | ---: | --- | --- | -| #654 | `ff096c18` | repairs long ontology labels; hosted gates and independent review remain required | | #644 | `c1018a0a` | splits conditional frontend surfaces and preserves keyed recovery; hosted gates and independent review remain required | | #643 | `041ec13b` | shares token-backed success/unavailable/retry status notices for the Calendar surface; hosted gates and independent review remain required | -| #640 | `b045a6e5` | quantifies dashboard cases, persists explicit missing facts and observed lifecycle milestones, consumes producer-owned topic influence without local arithmetic, and repairs runtime evidence-query joins/bind arity; hosted gates and independent review must run on this exact head | +| #640 | `8750638c` | quantifies dashboard cases, persists explicit missing facts and observed lifecycle milestones, consumes producer-owned topic influence without local arithmetic, and repairs runtime evidence-query joins/bind arity; hosted gates and independent review must run on this exact head | | #639 | `aee02dca` | repairs Running-action, Compose, and TEPP configuration contracts; hosted gates and independent review remain required | | #636 | `f7b9a65f` | publishes the calibrated external-lineage contract without a redundant explicit-child filter and repairs test import hygiene; hosted gates and independent review remain required | -| #632 | `d227edce` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required | +| #632 | `a946f879` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required | | #631 | `c0022c97` | decomposes ADR gaps and queue baseline; hosted gates and independent review remain required | | #629 | `4b4d6707` | releases provider work from database leases and bounds landing reads; hosted gates and independent review remain required | | #579 | `689a21b6` | delegates leftover interaction-map arithmetic to fast-mlsirm and persists only the consumer projection; hosted gates and independent review remain required | From a60e5060d16bfea01e4791036779964be1937edb Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 02:15:17 +0900 Subject: [PATCH 036/393] test(api): isolate live integration evidence --- backend/tests/test_api.py | 10 ++++++++-- backend/tests/test_operations_dashboard_postgres.py | 4 ++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 78b99ab08..5538f465b 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -568,7 +568,7 @@ def _seed_analysis_run( ) other_account_id = cur.fetchone()[0] visible_run_id = _seed_analysis_run( - "f" * 64, + "0" * 64, "visible-own-corp", account_id, "analysis_scope_corporate_entity", @@ -3900,7 +3900,9 @@ def answer(self, question: str, sources) -> None: ) assert response.status_code == 503 - assert "no complete evidence object" in response.json()["detail"] + assert response.json()["detail"] == ( + "Post chat is temporarily unavailable. Saved evidence is still available." + ) def test_live_chat_provider_error_does_not_leak_raw_error( @@ -3943,7 +3945,11 @@ class _FailingAskClient: def answer(self, question: str, sources) -> object: raise Exception("raw-global-provider-secret") + async def _sources(*_args, **_kwargs): + return [SimpleNamespace(post_id=seeded_db["own_private_post_id"])] + monkeypatch.setattr("backend.app.main._post_chat_client", lambda **_kwargs: _FailingAskClient()) + monkeypatch.setattr("backend.app.global_ask_queue.gather_global_chat_sources", _sources) headers = {"Authorization": f"Bearer {demo_analyst_token}"} submitted = client.post( diff --git a/backend/tests/test_operations_dashboard_postgres.py b/backend/tests/test_operations_dashboard_postgres.py index d03bc94b7..16135c311 100644 --- a/backend/tests/test_operations_dashboard_postgres.py +++ b/backend/tests/test_operations_dashboard_postgres.py @@ -24,6 +24,10 @@ async def test_operations_dashboard_sql_binds_against_postgres() -> None: except (OSError, asyncpg.PostgresError): pytest.skip("requires the migrated local Compose database") try: + if await connection.fetchval( + "select to_regclass('public.operations_case_classification')" + ) is None: + pytest.skip("requires migration 0209 on the local Compose database") result = await fetch_operations_dashboard(connection, []) finally: await connection.close() From 6b1951716a26d5d4a006387b1a2a7ea5146371ed Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 02:21:16 +0900 Subject: [PATCH 037/393] fix(ui): keep workspace navigation reachable on mobile --- frontend/src/App.css | 15 ++++++++++++--- frontend/src/mobileNavigationCss.test.ts | 13 +++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 frontend/src/mobileNavigationCss.test.ts diff --git a/frontend/src/App.css b/frontend/src/App.css index 123d01254..a4fb2d71e 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1097,13 +1097,22 @@ @media (max-width: 768px) { /* Phone Breakpoint (<768px) */ - + .workspace-gnb { - display: none; /* Replaced by drawer on mobile */ + overflow-x: auto; + overscroll-behavior-inline: contain; + gap: 0.75rem; + padding: 0 1rem; + scrollbar-width: thin; + } + + .workspace-gnb-item, + .workspace-gnb-tools { + flex: 0 0 auto; } .mobile-drawer-trigger { - display: block; + display: none; } .app-header { diff --git a/frontend/src/mobileNavigationCss.test.ts b/frontend/src/mobileNavigationCss.test.ts new file mode 100644 index 000000000..bd2a37f52 --- /dev/null +++ b/frontend/src/mobileNavigationCss.test.ts @@ -0,0 +1,13 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, it } from "vitest"; + +const css = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "App.css"), "utf-8"); + +it("keeps the workspace GNB reachable on mobile", () => { + const mobile = css.match(/@media \(max-width: 768px\) \{([\s\S]*?)\n\}/)?.[1] ?? ""; + expect(mobile).toContain(".workspace-gnb"); + expect(mobile).toContain("overflow-x: auto"); + expect(mobile).not.toContain(".workspace-gnb {\n display: none"); +}); From 89c44db8643b75bd5e089b111c12a876804a2413 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 02:22:21 +0900 Subject: [PATCH 038/393] docs(gaps): record authenticated mobile audit --- docs/product-technical-gap-baseline.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 2bb4bfa47..7adaa1e79 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -78,11 +78,19 @@ stories were re-rendered locally with synthetic data at desktop and iPhone actions; the narrow view preserves readable cards and 44px-class actions while keeping the multi-step project journey horizontally scrollable. These images remain local audit evidence and are not committed. +An authenticated synthetic OIDC audit then found that the mobile breakpoint +hid the entire GNB despite having no drawer implementation. Candidate +`6b195171` keeps the same semantic navigation in a keyboard-accessible +horizontal viewport. The 390×844 rerender showed the GNB, one `main`, zero +unnamed controls, no document-level horizontal overflow, and visible keyboard +focus. The isolated frontend/backend issuer mismatch returned Dashboard 401, +so populated authenticated data remains a separate acceptance item; the k6 +API run independently proved the Dashboard endpoint with the isolated issuer. ### Exact open-PR boundary At this snapshot there were 9 open PRs and 10 open issues. Exact observed heads -were `#644 c1018a0a`, `#643 041ec13b`, `#640 8750638c`, +were `#644 c1018a0a`, `#643 041ec13b`, `#640 6b195171`, `#639 aee02dca`, `#636 f7b9a65f`, `#632 a946f879`, `#631 c0022c97`, `#629 4b4d6707`, and `#579 689a21b6`. All remain blocked on hosted gates and/or independent review. These @@ -109,7 +117,7 @@ context only. | ---: | --- | --- | | #644 | `c1018a0a` | splits conditional frontend surfaces and preserves keyed recovery; hosted gates and independent review remain required | | #643 | `041ec13b` | shares token-backed success/unavailable/retry status notices for the Calendar surface; hosted gates and independent review remain required | -| #640 | `8750638c` | quantifies dashboard cases, persists explicit missing facts and observed lifecycle milestones, consumes producer-owned topic influence without local arithmetic, and repairs runtime evidence-query joins/bind arity; hosted gates and independent review must run on this exact head | +| #640 | `6b195171` | quantifies dashboard cases, persists explicit missing facts and observed lifecycle milestones, consumes producer-owned topic influence without local arithmetic, repairs runtime evidence-query joins/bind arity, and restores mobile GNB reachability; hosted gates and independent review must run on this exact head | | #639 | `aee02dca` | repairs Running-action, Compose, and TEPP configuration contracts; hosted gates and independent review remain required | | #636 | `f7b9a65f` | publishes the calibrated external-lineage contract without a redundant explicit-child filter and repairs test import hygiene; hosted gates and independent review remain required | | #632 | `a946f879` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required | From 90995cea12442539d83f2569c537b65ce8cd45cb Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 02:40:49 +0900 Subject: [PATCH 039/393] fix(dashboard): enforce temporal evidence contracts --- backend/app/operations_dashboard.py | 3 +- .../test_operations_dashboard_postgres.py | 17 +- .../adr/0206-evidence-operations-dashboard.md | 5 +- ...poral-topic-context-influence-dashboard.md | 10 +- docs/operability/http-concurrency-evidence.md | 2 +- docs/product-technical-gap-baseline.md | 2 +- lineageweave/operations_case_analysis.py | 13 +- ...13_operations_external_relation_target.sql | 2 +- ...214_topic_context_influence_projection.sql | 118 +++++++++++ migrations/0215_operations_case_milestone.sql | 24 +++ ...6_validate_operations_case_constraints.sql | 9 + tests/test_operations_case_analysis.py | 22 ++ tests/test_operations_dashboard.py | 1 + tests/test_schema.py | 200 +++++++++++++++++- 14 files changed, 414 insertions(+), 14 deletions(-) create mode 100644 migrations/0216_validate_operations_case_constraints.sql diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py index 6da64b5ba..a7d826ebd 100644 --- a/backend/app/operations_dashboard.py +++ b/backend/app/operations_dashboard.py @@ -299,7 +299,8 @@ async def fetch_operations_dashboard( join source_post post on post.post_id = missing.post_id where {visible} and ($5::boolean is false or missing.case_kind_code = 'external_information') - order by post_id, case_kind_code, milestone_type_code + order by post_id, case_kind_code, observed_at nulls last, + milestone_type_code """, *args, ) diff --git a/backend/tests/test_operations_dashboard_postgres.py b/backend/tests/test_operations_dashboard_postgres.py index 16135c311..ef529867f 100644 --- a/backend/tests/test_operations_dashboard_postgres.py +++ b/backend/tests/test_operations_dashboard_postgres.py @@ -24,10 +24,19 @@ async def test_operations_dashboard_sql_binds_against_postgres() -> None: except (OSError, asyncpg.PostgresError): pytest.skip("requires the migrated local Compose database") try: - if await connection.fetchval( - "select to_regclass('public.operations_case_classification')" - ) is None: - pytest.skip("requires migration 0209 on the local Compose database") + required_tables = ( + "operations_case_classification", + "operations_case_missing_fact", + "operations_case_milestone", + "operations_case_missing_milestone", + "topic_context_membership", + "topic_post_context_influence", + ) + for table_name in required_tables: + if await connection.fetchval( + "select to_regclass($1)", f"public.{table_name}" + ) is None: + pytest.skip(f"requires the migration that creates {table_name}") result = await fetch_operations_dashboard(connection, []) finally: await connection.close() diff --git a/docs/adr/0206-evidence-operations-dashboard.md b/docs/adr/0206-evidence-operations-dashboard.md index 371f41357..a1235c232 100644 --- a/docs/adr/0206-evidence-operations-dashboard.md +++ b/docs/adr/0206-evidence-operations-dashboard.md @@ -131,7 +131,10 @@ provenance. 17. Claim investigation pairs `claim_received` with `cause_confirmed`. Rebid/handover independently pairs `rebid_response_requested` with `rebid_decision_recorded`, and `handover_started` with - `handover_accepted`. Contextual-orchestrator identifies the supported + `handover_accepted`. The database rejects a claim milestone on a + rebid/handover case, a rebid/handover milestone on a claim case, and every + milestone on the other case kinds; the same invariant applies to observed + and explicitly missing endpoints. Contextual-orchestrator identifies the supported milestone semantics; LineageWeave assigns the instant only from that cited `source_post`: `event_occurred_at` when present, otherwise the explicitly labeled `created_at` fallback from ADR 0202. The model never emits a date. diff --git a/docs/adr/0210-temporal-topic-context-influence-dashboard.md b/docs/adr/0210-temporal-topic-context-influence-dashboard.md index 0cfdbe1d8..ad266a6da 100644 --- a/docs/adr/0210-temporal-topic-context-influence-dashboard.md +++ b/docs/adr/0210-temporal-topic-context-influence-dashboard.md @@ -87,7 +87,15 @@ The accepted TEPP result schema must include: LineageWeave verifies the exact snapshot and cutoff before persisting a 3NF projection. It does not inspect TEPP's private tables or reinterpret posterior -coordinates. +coordinates. `topic_model_run.coordinate_kind_code` fixes one representation +for the run; `topic_post_coordinate` stores one finite value per run, post, +topic, and posterior-draw ordinal, and the ordinal must belong to the run's +declared draw set. Topic-lineage and context-membership evidence +each references a normalized `provenance_assertion` whose canonical relation is +`prov:wasDerivedFrom`; its SHA-256 remains an integrity field rather than a +substitute for provenance. Import materializes that assertion through +`lineageweave.prov_o.ProvGraph` so PROV-O hierarchy and qualified-relation +implications remain the shared standard projection. TEPP protected main currently exposes `tepp.trsl_topic_lineage.v1`, a digest-bound CPU-`f64` artifact containing fitted forward sequence edges and diff --git a/docs/operability/http-concurrency-evidence.md b/docs/operability/http-concurrency-evidence.md index 0ec1d2c2d..eca0b8a6a 100644 --- a/docs/operability/http-concurrency-evidence.md +++ b/docs/operability/http-concurrency-evidence.md @@ -58,7 +58,7 @@ Figma and screenshot review do not apply: this is a non-UI HTTP load harness. ## Dashboard candidate verification record -On 2026-08-26, the synthetic 27-post Compose dataset at candidate head +On 2026-08-26 KST (2026-08-25 UTC), the synthetic 27-post Compose dataset at candidate head `b045a6e5` ran with 4 VUs for 30 seconds on alternate local ports. It completed 1,240 iterations and 4,962 authenticated HTTP requests with zero failed requests and 4,960/4,960 successful checks across posts, Event Lineage, diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 7adaa1e79..0761f7175 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,6 +1,6 @@ # Product & Technical Gap Baseline -> Dashboard delivery snapshot: 2026-08-26 (latest exact-head fetch). Protected `main` was +> Dashboard delivery snapshot: 2026-08-26 KST (2026-08-25 UTC; latest exact-head fetch). Protected `main` was > `04e6b610655d0db91d5f7ba9486bdda1440e0b19`. Dashboard candidate head > `b045a6e5` has local synthetic runtime evidence; this is not > protected-main release evidence. diff --git a/lineageweave/operations_case_analysis.py b/lineageweave/operations_case_analysis.py index 62471f7df..74ca912d3 100644 --- a/lineageweave/operations_case_analysis.py +++ b/lineageweave/operations_case_analysis.py @@ -262,7 +262,15 @@ def parse_operations_case_response( relation_target_kind, ) ) - supported_types = {fact.fact_type_code for fact in parsed_facts} + supported_type_counts = { + fact_type: sum( + fact.fact_type_code == fact_type for fact in parsed_facts + ) + for fact_type in FACT_TYPES + } + supported_types = { + fact_type for fact_type, count in supported_type_counts.items() if count + } if any( not isinstance(code, str) or code not in FACT_TYPES for code in missing_fact_types @@ -271,7 +279,8 @@ def parse_operations_case_response( missing_types = set(missing_fact_types) required_types = REQUIRED_FACT_TYPES[item["case_kind_code"]] if ( - len(missing_types) != len(missing_fact_types) + any(supported_type_counts[fact_type] > 1 for fact_type in required_types) + or len(missing_types) != len(missing_fact_types) or not missing_types.issubset(required_types) or supported_types.intersection(missing_types) or not required_types.issubset(supported_types.union(missing_types)) diff --git a/migrations/0213_operations_external_relation_target.sql b/migrations/0213_operations_external_relation_target.sql index eb059744d..5fe13a48f 100644 --- a/migrations/0213_operations_external_relation_target.sql +++ b/migrations/0213_operations_external_relation_target.sql @@ -9,7 +9,7 @@ alter table operations_case_fact and (relation_target_kind_code is null or relation_target_kind_code in ('order', 'project', 'sales', 'business_management'))) or (fact_type_code <> 'external_relation' and relation_target_kind_code is null) - ); + ) not valid; comment on column operations_case_fact.relation_target_kind_code is 'Semantic target type supplied with cited external_relation evidence; null legacy rows are not projected as typed relations.'; diff --git a/migrations/0214_topic_context_influence_projection.sql b/migrations/0214_topic_context_influence_projection.sql index 13daab657..ffa88b202 100644 --- a/migrations/0214_topic_context_influence_projection.sql +++ b/migrations/0214_topic_context_influence_projection.sql @@ -14,6 +14,9 @@ create table if not exists topic_model_run ( posterior_draw_set_id text not null check (length(btrim(posterior_draw_set_id)) between 1 and 256), posterior_draw_count integer not null check (posterior_draw_count > 0), topic_count integer not null check (topic_count >= 2), + coordinate_kind_code text not null check ( + coordinate_kind_code in ('logistic_normal_coordinate', 'plausible_value') + ), inference_status_code text not null check (inference_status_code = 'posterior_topic_coordinates_not_importance'), accepted_at timestamptz not null default now() ); @@ -24,6 +27,20 @@ create table if not exists topic_definition ( primary key (topic_model_run_id, topic_index) ); +create table if not exists topic_post_coordinate ( + topic_model_run_id uuid not null references topic_model_run (topic_model_run_id) on delete cascade, + source_post_id uuid not null references source_post (post_id) on delete restrict, + topic_index integer not null, + posterior_draw_ordinal integer not null check (posterior_draw_ordinal >= 0), + coordinate_value double precision not null check ( + coordinate_value > '-Infinity'::double precision + and coordinate_value < 'Infinity'::double precision + ), + primary key (topic_model_run_id, source_post_id, topic_index, posterior_draw_ordinal), + foreign key (topic_model_run_id, topic_index) + references topic_definition (topic_model_run_id, topic_index) on delete cascade +); + create table if not exists topic_activity_interval ( topic_model_run_id uuid not null, topic_index integer not null, @@ -44,6 +61,7 @@ create table if not exists topic_lineage_relation ( target_topic_index integer, event_time timestamptz not null, evidence_sha256 text not null check (evidence_sha256 ~ '^[0-9a-f]{64}$'), + provenance_assertion_id uuid not null references provenance_assertion (assertion_id), primary key (topic_model_run_id, relation_ordinal), foreign key (topic_model_run_id, source_topic_index) references topic_definition (topic_model_run_id, topic_index) on delete cascade, @@ -75,6 +93,7 @@ create table if not exists topic_context_membership ( valid_from timestamptz not null, valid_to timestamptz not null, evidence_sha256 text not null check (evidence_sha256 ~ '^[0-9a-f]{64}$'), + provenance_assertion_id uuid not null references provenance_assertion (assertion_id), primary key (topic_model_run_id, topic_context_membership_id), unique (topic_model_run_id, source_post_id, dimension_code, context_id, valid_from), foreign key (topic_model_run_id, dimension_code, context_id) @@ -135,6 +154,20 @@ create table if not exists topic_post_context_influence ( references topic_definition (topic_model_run_id, topic_index) on delete cascade ); +-- Preserve sorted replay when an earlier 0214 projection already exists. +alter table topic_model_run + add column if not exists coordinate_kind_code text + check (coordinate_kind_code in ('logistic_normal_coordinate', 'plausible_value')); +alter table topic_model_run alter column coordinate_kind_code set not null; +alter table topic_lineage_relation + add column if not exists provenance_assertion_id uuid + references provenance_assertion (assertion_id); +alter table topic_lineage_relation alter column provenance_assertion_id set not null; +alter table topic_context_membership + add column if not exists provenance_assertion_id uuid + references provenance_assertion (assertion_id); +alter table topic_context_membership alter column provenance_assertion_id set not null; + create index if not exists topic_activity_interval_time_idx on topic_activity_interval (valid_from, valid_to, topic_model_run_id, topic_index); create index if not exists topic_context_membership_post_time_idx @@ -142,6 +175,91 @@ create index if not exists topic_context_membership_post_time_idx create index if not exists topic_post_context_influence_read_idx on topic_post_context_influence (topic_model_run_id, topic_index, influence_value desc); +create index if not exists topic_lineage_relation_provenance_idx + on topic_lineage_relation (provenance_assertion_id); +create index if not exists topic_context_membership_provenance_idx + on topic_context_membership (provenance_assertion_id); + +create or replace function validate_topic_post_coordinate_draw() +returns trigger +language plpgsql +as $$ +declare + canonical_draw_count integer; +begin + select posterior_draw_count + into canonical_draw_count + from topic_model_run + where topic_model_run_id = new.topic_model_run_id; + + if new.posterior_draw_ordinal >= canonical_draw_count then + raise exception 'topic_post_coordinate_draw_out_of_range'; + end if; + return new; +end +$$; + +drop trigger if exists topic_post_coordinate_draw_check on topic_post_coordinate; +create trigger topic_post_coordinate_draw_check +before insert or update on topic_post_coordinate +for each row execute function validate_topic_post_coordinate_draw(); + +create or replace function validate_topic_evidence_provenance() +returns trigger +language plpgsql +as $$ +declare + canonical_relation_code text; +begin + select relation_code + into canonical_relation_code + from provenance_assertion + where assertion_id = new.provenance_assertion_id; + + if canonical_relation_code is distinct from 'prov_was_derived_from' then + raise exception 'topic_evidence_requires_prov_was_derived_from'; + end if; + return new; +end +$$; + +drop trigger if exists topic_lineage_relation_provenance_check on topic_lineage_relation; +create trigger topic_lineage_relation_provenance_check +before insert or update on topic_lineage_relation +for each row execute function validate_topic_evidence_provenance(); + +drop trigger if exists topic_context_membership_provenance_check on topic_context_membership; +create trigger topic_context_membership_provenance_check +before insert or update on topic_context_membership +for each row execute function validate_topic_evidence_provenance(); + +create or replace function protect_topic_evidence_provenance_relation() +returns trigger +language plpgsql +as $$ +begin + if new.relation_code is distinct from old.relation_code + and ( + exists ( + select 1 from topic_lineage_relation + where provenance_assertion_id = old.assertion_id + ) + or exists ( + select 1 from topic_context_membership + where provenance_assertion_id = old.assertion_id + ) + ) then + raise exception 'topic_evidence_provenance_relation_is_immutable'; + end if; + return new; +end +$$; + +drop trigger if exists topic_evidence_provenance_relation_protect on provenance_assertion; +create trigger topic_evidence_provenance_relation_protect +before update of relation_code on provenance_assertion +for each row execute function protect_topic_evidence_provenance_relation(); + create or replace function validate_topic_model_run_binding() returns trigger language plpgsql diff --git a/migrations/0215_operations_case_milestone.sql b/migrations/0215_operations_case_milestone.sql index 614accb7c..fd82b5da6 100644 --- a/migrations/0215_operations_case_milestone.sql +++ b/migrations/0215_operations_case_milestone.sql @@ -32,5 +32,29 @@ create table if not exists operations_case_missing_milestone ( on delete cascade ); +alter table operations_case_milestone + drop constraint if exists operations_case_milestone_kind_type_check, + add constraint operations_case_milestone_kind_type_check check ( + (case_kind_code = 'claim_investigation' + and milestone_type_code in ('claim_received', 'cause_confirmed')) + or (case_kind_code = 'rebid_handover' + and milestone_type_code in ( + 'rebid_response_requested', 'rebid_decision_recorded', + 'handover_started', 'handover_accepted' + )) + ) not valid; + +alter table operations_case_missing_milestone + drop constraint if exists operations_case_missing_milestone_kind_type_check, + add constraint operations_case_missing_milestone_kind_type_check check ( + (case_kind_code = 'claim_investigation' + and milestone_type_code in ('claim_received', 'cause_confirmed')) + or (case_kind_code = 'rebid_handover' + and milestone_type_code in ( + 'rebid_response_requested', 'rebid_decision_recorded', + 'handover_started', 'handover_accepted' + )) + ) not valid; + create index if not exists operations_case_milestone_kind_time_idx on operations_case_milestone (case_kind_code, milestone_type_code, observed_at, post_id); diff --git a/migrations/0216_validate_operations_case_constraints.sql b/migrations/0216_validate_operations_case_constraints.sql new file mode 100644 index 000000000..03efa980e --- /dev/null +++ b/migrations/0216_validate_operations_case_constraints.sql @@ -0,0 +1,9 @@ +-- Validate ADR 0206 dashboard checks separately from their short NOT VALID installation. +alter table operations_case_fact + validate constraint operations_case_fact_relation_target_kind_check; + +alter table operations_case_milestone + validate constraint operations_case_milestone_kind_type_check; + +alter table operations_case_missing_milestone + validate constraint operations_case_missing_milestone_kind_type_check; diff --git a/tests/test_operations_case_analysis.py b/tests/test_operations_case_analysis.py index 7d94f519a..c85783a2e 100644 --- a/tests/test_operations_case_analysis.py +++ b/tests/test_operations_case_analysis.py @@ -222,6 +222,28 @@ def test_accepts_additional_grounded_fact_beyond_required_questions() -> None: assert parse_operations_case_response(json.dumps(payload), body) is None +def test_rejects_duplicate_required_facts() -> None: + """Each required question has exactly one supported or missing answer.""" + body = "Two notices linked the same external opportunity." + fact = { + "fact_type_code": "external_relation", + "value_text": "Synthetic opportunity", + "evidence_text": body, + "relation_target_kind_code": "sales", + } + payload = [{ + "case_kind_code": "external_information", + "summary_text": "External opportunity", + "evidence_text": body, + "facts": [fact, fact], + "missing_fact_type_codes": [], + "milestones": [], + "missing_milestone_type_codes": [], + }] + + assert parse_operations_case_response(json.dumps(payload), body) is None + + def test_accepts_grounded_nonrequired_fact_after_required_questions_are_complete() -> None: """A cited optional fact must not invalidate complete required answers.""" body = "A public notice was published and assigned to the sales team." diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py index 37aed4dae..3a52b2f4e 100644 --- a/tests/test_operations_dashboard.py +++ b/tests/test_operations_dashboard.py @@ -238,6 +238,7 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None: "coalesce(nullif(btrim(post.source_project_name), ''), project.primary_project_name)" in case_query ) + assert "observed_at nulls last" in conn.queries[4][0] for evidence_query in ( conn.queries[0][0], conn.queries[1][0], diff --git a/tests/test_schema.py b/tests/test_schema.py index 0474ac74d..3529fd7e1 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -112,9 +112,17 @@ / "migrations" / "0215_operations_case_milestone.sql" ) +_OPERATIONS_CASE_CONSTRAINT_VALIDATION_MIGRATION = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0216_validate_operations_case_constraints.sql" +) _ANALYSIS_RUN_REGISTRY_MIGRATION = ( Path(__file__).resolve().parents[1] / "migrations" / "0018_analysis_run_registry.sql" ) +_PROV_O_MIGRATION = ( + Path(__file__).resolve().parents[1] / "migrations" / "0017_prov_o_standard_relations.sql" +) _TOPIC_CONTEXT_INFLUENCE_MIGRATION = ( Path(__file__).resolve().parents[1] / "migrations" @@ -160,6 +168,7 @@ def schema_db(): try: with conn.cursor() as cur: cur.execute(_MIGRATION_PATH.read_text()) + cur.execute(_PROV_O_MIGRATION.read_text()) cur.execute(_ANALYSIS_RUN_REGISTRY_MIGRATION.read_text()) cur.execute(_TOPIC_LINEAGE_KIND_MIGRATION.read_text()) cur.execute(_PROJECT_MENTION_MIGRATION.read_text()) @@ -180,6 +189,8 @@ def schema_db(): cur.execute(_OPERATIONS_CASE_MISSING_MIGRATION.read_text()) cur.execute(_TOPIC_CONTEXT_INFLUENCE_MIGRATION.read_text()) cur.execute(_OPERATIONS_EXTERNAL_RELATION_MIGRATION.read_text()) + cur.execute(_OPERATIONS_CASE_MILESTONE_MIGRATION.read_text()) + cur.execute(_OPERATIONS_CASE_CONSTRAINT_VALIDATION_MIGRATION.read_text()) conn.commit() yield conn finally: @@ -238,10 +249,13 @@ def test_migration_applies_cleanly(schema_db) -> None: "operations_case_classification", "operations_case_fact", "operations_case_missing_fact", + "operations_case_milestone", + "operations_case_missing_milestone", "topic_model_run", "topic_definition", "topic_activity_interval", "topic_lineage_relation", + "topic_post_coordinate", "topic_context_definition", "topic_context_membership", "topic_influence_run", @@ -250,6 +264,142 @@ def test_migration_applies_cleanly(schema_db) -> None: assert expected <= tables +def test_operations_case_constraints_are_validated(schema_db) -> None: + """Deferred dashboard checks finish validated after the follow-up migration.""" + names = { + "operations_case_fact_relation_target_kind_check", + "operations_case_milestone_kind_type_check", + "operations_case_missing_milestone_kind_type_check", + } + with schema_db.cursor() as cur: + cur.execute( + "select conname, convalidated from pg_constraint where conname = any(%s)", + (list(names),), + ) + constraints = dict(cur.fetchall()) + assert constraints == {name: True for name in names} + + +def test_operations_case_milestones_reject_cross_kind_types(schema_db) -> None: + """Observed and missing endpoints accept only the activity set for their case.""" + post_id = uuid.uuid4() + account_id = uuid.uuid4() + with schema_db.cursor() as cur: + cur.execute( + """ + insert into common_lookup_value + (lookup_category, lookup_code, lookup_label) + values ('corporate_entity_level', 'synthetic_level', 'Synthetic level'), + ('voc_type', 'synthetic_voc', 'Synthetic VOC'), + ('post_visibility', 'synthetic_visibility', 'Synthetic visibility') + """, + ) + cur.execute( + "insert into corporate_entity " + "(corporate_entity_code, entity_name, entity_level_code) " + "values ('SYNTHETIC-CASE', 'Synthetic Case Entity', 'synthetic_level') " + "returning corporate_entity_id" + ) + entity_id = cur.fetchone()[0] + cur.execute( + "insert into user_account " + "(user_account_id, external_subject_id, display_name, email_address) " + "values (%s, %s, 'Synthetic Author', %s)", + (str(account_id), f"synthetic-{account_id}", f"synthetic-{account_id}@example.invalid"), + ) + cur.execute( + """ + insert into source_post + (post_id, author_account_id, corporate_entity_id, post_title, + post_body, voc_type_code, visibility_code, created_at) + values (%s, %s, %s, 'Synthetic claim', 'Synthetic claim evidence', + 'synthetic_voc', 'synthetic_visibility', '2026-08-01T00:00:00Z') + """, + (str(post_id), str(account_id), str(entity_id)), + ) + cur.execute( + """ + insert into operations_case_analysis + (post_id, source_body_sha256, orchestrator_session_id) + values (%s, %s, 'synthetic-session') + """, + (str(post_id), "a" * 64), + ) + cur.execute( + """ + insert into operations_case_classification + (post_id, case_kind_code, summary_text, evidence_text, + evidence_post_id, evidence_input_sha256) + values (%s, 'claim_investigation', 'Synthetic summary', + 'Synthetic claim evidence', %s, %s) + """, + (str(post_id), str(post_id), "a" * 64), + ) + invalid_statements = ( + """ + insert into operations_case_milestone + (post_id, case_kind_code, milestone_type_code, evidence_text, + evidence_post_id, evidence_input_sha256, observed_at, time_axis_code) + values (%s, 'claim_investigation', 'handover_started', + 'Synthetic claim evidence', %s, %s, + '2026-08-01T00:00:00Z', 'created_at') + """, + """ + insert into operations_case_missing_milestone + (post_id, case_kind_code, milestone_type_code) + values (%s, 'claim_investigation', 'handover_started') + """, + ) + parameters = ( + (str(post_id), str(post_id), "a" * 64), + (str(post_id),), + ) + for index, (statement, values) in enumerate(zip(invalid_statements, parameters)): + savepoint = f"invalid_milestone_kind_{index}" + cur.execute(f"savepoint {savepoint}") + with pytest.raises(psycopg2.errors.CheckViolation): + cur.execute(statement, values) + cur.execute(f"rollback to savepoint {savepoint}") + schema_db.rollback() + + +def test_operations_case_constraint_migrations_replay(schema_db) -> None: + """Constraint installation and validation remain safe under startup replay.""" + with schema_db.cursor() as cur: + cur.execute(_OPERATIONS_EXTERNAL_RELATION_MIGRATION.read_text()) + cur.execute(_OPERATIONS_CASE_MILESTONE_MIGRATION.read_text()) + cur.execute( + """ + select conname, convalidated + from pg_constraint + where conname in ( + 'operations_case_fact_relation_target_kind_check', + 'operations_case_milestone_kind_type_check', + 'operations_case_missing_milestone_kind_type_check' + ) + """ + ) + assert dict(cur.fetchall()) == { + "operations_case_fact_relation_target_kind_check": False, + "operations_case_milestone_kind_type_check": False, + "operations_case_missing_milestone_kind_type_check": False, + } + cur.execute(_OPERATIONS_CASE_CONSTRAINT_VALIDATION_MIGRATION.read_text()) + cur.execute( + """ + select bool_and(convalidated) + from pg_constraint + where conname in ( + 'operations_case_fact_relation_target_kind_check', + 'operations_case_milestone_kind_type_check', + 'operations_case_missing_milestone_kind_type_check' + ) + """ + ) + assert cur.fetchone()[0] is True + schema_db.commit() + + def test_topic_influence_schema_binds_exact_producer_provenance(schema_db) -> None: """Accepted influence runs cannot cross a TEPP run, snapshot, or cutoff.""" with schema_db.cursor() as cur: @@ -282,6 +432,48 @@ def test_topic_influence_schema_binds_exact_producer_provenance(schema_db) -> No foreign_keys = {row[0] for row in cur.fetchall()} assert len(foreign_keys) == 3 + with schema_db.cursor() as cur: + cur.execute( + """ + select column_name, is_nullable + from information_schema.columns + where table_name in ('topic_lineage_relation', 'topic_context_membership') + and column_name = 'provenance_assertion_id' + """ + ) + assert cur.fetchall() == [ + ("provenance_assertion_id", "NO"), + ("provenance_assertion_id", "NO"), + ] + cur.execute( + """ + select count(*) + from pg_constraint + where conrelid = 'topic_post_coordinate'::regclass + and contype = 'f' + """ + ) + assert cur.fetchone() == (3,) + cur.execute( + """ + select tgname + from pg_trigger + where tgname in ( + 'topic_post_coordinate_draw_check', + 'topic_lineage_relation_provenance_check', + 'topic_context_membership_provenance_check', + 'topic_evidence_provenance_relation_protect' + ) + and not tgisinternal + """ + ) + assert {row[0] for row in cur.fetchall()} == { + "topic_post_coordinate_draw_check", + "topic_lineage_relation_provenance_check", + "topic_context_membership_provenance_check", + "topic_evidence_provenance_relation_protect", + } + account_id = uuid.uuid4() snapshot_id = uuid.uuid4() run_id = uuid.uuid4() @@ -322,10 +514,12 @@ def test_topic_influence_schema_binds_exact_producer_provenance(schema_db) -> No tepp_schema_version, tepp_model_contract_version, tepp_artifact_sha256, reported_source_snapshot_sha256, reported_knowledge_cutoff, posterior_draw_set_id, - posterior_draw_count, topic_count, inference_status_code) + posterior_draw_count, topic_count, coordinate_kind_code, + inference_status_code) values (%s, 'tepp-mismatch', 'snapshot-mismatch', 'tepp.topic_context_posterior.v1', 'trsl-tm-v1', %s, %s, '2026-08-01T00:00:00Z', 'draws-1', 8, 2, + 'logistic_normal_coordinate', 'posterior_topic_coordinates_not_importance') """, (str(run_id), "d" * 64, "e" * 64), @@ -338,10 +532,12 @@ def test_topic_influence_schema_binds_exact_producer_provenance(schema_db) -> No tepp_schema_version, tepp_model_contract_version, tepp_artifact_sha256, reported_source_snapshot_sha256, reported_knowledge_cutoff, posterior_draw_set_id, - posterior_draw_count, topic_count, inference_status_code) + posterior_draw_count, topic_count, coordinate_kind_code, + inference_status_code) values (%s, 'tepp-accepted', 'snapshot-accepted', 'tepp.topic_context_posterior.v1', 'trsl-tm-v1', %s, %s, '2026-08-01T00:00:00Z', 'draws-1', 8, 2, + 'logistic_normal_coordinate', 'posterior_topic_coordinates_not_importance') returning topic_model_run_id """, From e2c47f9680a20054529d49c118d354a7d9f513eb Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 02:41:06 +0900 Subject: [PATCH 040/393] docs(gaps): refresh dashboard review head --- docs/product-technical-gap-baseline.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 0761f7175..56818bc77 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -90,7 +90,7 @@ API run independently proved the Dashboard endpoint with the isolated issuer. ### Exact open-PR boundary At this snapshot there were 9 open PRs and 10 open issues. Exact observed heads -were `#644 c1018a0a`, `#643 041ec13b`, `#640 6b195171`, +were `#644 c1018a0a`, `#643 041ec13b`, `#640 90995cea` (observed parent), `#639 aee02dca`, `#636 f7b9a65f`, `#632 a946f879`, `#631 c0022c97`, `#629 4b4d6707`, and `#579 689a21b6`. All remain blocked on hosted gates and/or independent review. These @@ -117,7 +117,7 @@ context only. | ---: | --- | --- | | #644 | `c1018a0a` | splits conditional frontend surfaces and preserves keyed recovery; hosted gates and independent review remain required | | #643 | `041ec13b` | shares token-backed success/unavailable/retry status notices for the Calendar surface; hosted gates and independent review remain required | -| #640 | `6b195171` | quantifies dashboard cases, persists explicit missing facts and observed lifecycle milestones, consumes producer-owned topic influence without local arithmetic, repairs runtime evidence-query joins/bind arity, and restores mobile GNB reachability; hosted gates and independent review must run on this exact head | +| #640 | `90995cea` (observed parent) | quantifies dashboard cases, persists explicit missing facts and observed lifecycle milestones, consumes producer-owned topic influence without local arithmetic, repairs runtime evidence-query joins/bind arity, restores mobile GNB reachability, and enforces coordinate/provenance/lifecycle contracts; this baseline refresh advances the head, so hosted gates and independent review must run again | | #639 | `aee02dca` | repairs Running-action, Compose, and TEPP configuration contracts; hosted gates and independent review remain required | | #636 | `f7b9a65f` | publishes the calibrated external-lineage contract without a redundant explicit-child filter and repairs test import hygiene; hosted gates and independent review remain required | | #632 | `a946f879` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required | From 7e63d8c2c9e7cd9999216e2097efbdf7b64907d1 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 02:42:31 +0900 Subject: [PATCH 041/393] fix(dashboard): preserve visible lifecycle starts --- backend/app/operations_dashboard.py | 2 +- tests/test_operations_dashboard.py | 22 +++++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py index a7d826ebd..7a4dbf622 100644 --- a/backend/app/operations_dashboard.py +++ b/backend/app/operations_dashboard.py @@ -461,7 +461,7 @@ def _project_lifecycles( elapsed_seconds = int((datetime.fromisoformat(end["observed_at"]) - datetime.fromisoformat(start["observed_at"])).total_seconds()) status_code = "resolved" next_action = "시작·종료 Event 근거를 열어 경과 시간을 검토하세요." - elif start and end_code in missing_milestones: + elif start: elapsed_seconds = None status_code = "open" next_action = f"{MILESTONE_TYPE_LABELS[end_code]} Event 근거를 연결하세요." diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py index 3a52b2f4e..30dc72664 100644 --- a/tests/test_operations_dashboard.py +++ b/tests/test_operations_dashboard.py @@ -4,7 +4,7 @@ import pytest -from backend.app.operations_dashboard import fetch_operations_dashboard +from backend.app.operations_dashboard import _project_lifecycles, fetch_operations_dashboard class _Connection: @@ -91,6 +91,26 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: ] +def test_projected_start_with_unavailable_end_remains_open() -> None: + """A hidden end citation cannot make an observed start look absent.""" + start = { + "milestone_type_code": "claim_received", + "milestone_type_label": "클레임 접수", + "evidence_text": "Synthetic claim received", + "evidence_post_id": "synthetic-start", + "observed_at": "2026-08-01T09:00:00+00:00", + "time_axis_code": "event_occurred_at", + "time_axis_label": "Event 발생일", + } + + lifecycle = _project_lifecycles("claim_investigation", [start], set())[0] + + assert lifecycle["status_code"] == "open" + assert lifecycle["start_milestone"] == start + assert lifecycle["end_milestone"] is None + assert lifecycle["next_action_text"] == "원인 확정 Event 근거를 연결하세요." + + @pytest.mark.anyio async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None: """Counts and cases share the exact authorized event-time population.""" From 1cc3cd2f0e614d305f8ecfa5afe2901fee869c63 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 02:50:25 +0900 Subject: [PATCH 042/393] docs(operability): record constrained dashboard rerun --- docs/operability/http-concurrency-evidence.md | 11 +++++++++++ docs/product-technical-gap-baseline.md | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/operability/http-concurrency-evidence.md b/docs/operability/http-concurrency-evidence.md index eca0b8a6a..bca6d5b3f 100644 --- a/docs/operability/http-concurrency-evidence.md +++ b/docs/operability/http-concurrency-evidence.md @@ -76,6 +76,17 @@ tests now assert the join and bind arity; the distribution above is the clean rerun. This is synthetic candidate evidence, not protected-main evidence or a capacity/SLO claim. +After the normalized topic-coordinate/provenance and lifecycle constraints were +added, candidate `7e63d8c2` replayed migrations through `0216` on the retained +synthetic volume and passed the real-PostgreSQL Dashboard contract. Its clean +4-VU/30-second rerun completed 345 iterations, 1,382 requests, and 1,380/1,380 +checks with zero request failures. HTTP duration was 255.55 ms average, +187.54 ms median, and 593.53 ms p95; the reader metric was 275.26 ms average +and 637.94 ms p95. Ask enqueue took 791.12 ms and polling p95 was 425.66 ms. +The host was still completing the Keycloak/Quarkus cold start immediately +before this run, so the distribution is retained as correctness/concurrency +evidence and is not compared as a performance regression or SLO. + ## Current-main verification record On 2026-08-25, a worktree based on protected-main commit `48f013a2` passed diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 56818bc77..6bee0ebcc 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -408,7 +408,7 @@ this file per §3.5 of the prior snapshot). | Shared frontend gate | The ADR 0109 login repair is on protected `main`; eight older branches carried the defect and received the same verified repair this loop (#521–#560) | Keep every future branch cut from post-repair bases; re-verify with frontend lint/test/build before push | | Identifying baseline regression | `main` gap file listed real post identifiers; separately, closed #506 and pre-existing public history contain a private runtime source-table identifier, while current `main` and #507 trees are clean | Land this non-identifying rewrite, then coordinate ADR 0001 history remediation with security/privacy owners; do not reproduce the value, force-push, or delete evidence ad hoc | | Authorized-corpus runtime | Repository tests use synthetic fixtures; private records remain outside git | Authenticated runtime validation returning only aggregate, non-identifying evidence | -| Concurrent web responsiveness | ADR 0204 releases pooled transactions during provider work. Candidate `b045a6e5` completed a synthetic authenticated 4-VU/30-second k6 run: 4,962 requests, zero failures, HTTP p95 181.76 ms, and reader p95 197.25 ms. The first run exposed and the candidate repaired Dashboard evidence-join and bind-arity defects. This is local candidate evidence, not a product guarantee | Repeat on the protected merge SHA and a representative deployment/corpus with declared CPU, memory, database pool, worker concurrency, and raw output; approve an SLO only from that capacity evidence | +| Concurrent web responsiveness | ADR 0204 releases pooled transactions during provider work. Candidate `b045a6e5` completed a synthetic authenticated 4-VU/30-second k6 run with 4,962 requests and zero failures. After schema/lifecycle repairs, `7e63d8c2` replayed through migration 0216, passed the real-PostgreSQL Dashboard contract, and completed 1,382 requests with zero failures (HTTP p95 593.53 ms; reader p95 637.94 ms) while the host remained cold-start loaded. These are local correctness/concurrency observations, not a product guarantee | Repeat on the protected merge SHA and a representative deployment/corpus with declared CPU, memory, database pool, worker concurrency, and raw output; approve an SLO only from that capacity evidence | | Image understanding | Region, OCR, and description work exists across active heads (#405, #419), but current runtime acceptance has not yet proved table-image structure, complete region coverage, or summary/image readiness together | Orchestrator-backed rendered workflow, original/derived asset provenance, region-before-OCR processing, and honest unsupported states; reconcile ADR 0052's image-bearing summary readiness with ADR 0098 before changing sequencing | | Semantic source rendering | Paragraph, table, list, formula, and indentation work exists across stacks (#394, #427, #448–#450); #515 adds synthetic backend/frontend parity for deterministic rows/cells, footnote boundaries, and encoded scripts | Land the #427 → #515 stack, then gather authenticated browser evidence that list nesting, continuation alignment, and formula units render without authoring-layout artifacts | | Event and project semantics | Multi-project mentions, project-bound actions, 5W1H, requester/processor, and semantic relations exist in ADR 0036/0052/0100/0111/0129 and active stacks | Aggregate authenticated evidence must show distinct projects and events, explicit requester/processor and real R&R, normalized relative time, and product/entity relations without promoting attendance or co-occurrence | From 1fb65c2e4c8fe42a43db62f380594c7e1f0f7eb7 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 03:03:06 +0900 Subject: [PATCH 043/393] fix(frontend): pass Keyverse build arguments --- frontend/Dockerfile | 8 ++++---- tests/test_frontend_container_contract.py | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) create mode 100644 tests/test_frontend_container_contract.py diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 5f2e1eb9a..978eb2616 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -6,11 +6,11 @@ RUN pnpm install --frozen-lockfile COPY . . # Vite bakes VITE_* vars in at build time, not runtime -- build args let # docker-compose pass through its own (possibly operator-overridden) ports. -ARG VITE_KEYCLOAK_ISSUER -ARG VITE_KEYCLOAK_CLIENT_ID +ARG VITE_KEYVERSE_ISSUER +ARG VITE_KEYVERSE_CLIENT_ID ARG VITE_BACKEND_BASE_URL -ENV VITE_KEYCLOAK_ISSUER=${VITE_KEYCLOAK_ISSUER} \ - VITE_KEYCLOAK_CLIENT_ID=${VITE_KEYCLOAK_CLIENT_ID} \ +ENV VITE_KEYVERSE_ISSUER=${VITE_KEYVERSE_ISSUER} \ + VITE_KEYVERSE_CLIENT_ID=${VITE_KEYVERSE_CLIENT_ID} \ VITE_BACKEND_BASE_URL=${VITE_BACKEND_BASE_URL} RUN pnpm run build diff --git a/tests/test_frontend_container_contract.py b/tests/test_frontend_container_contract.py new file mode 100644 index 000000000..8970f34f7 --- /dev/null +++ b/tests/test_frontend_container_contract.py @@ -0,0 +1,21 @@ +"""Static contracts for frontend Compose build-time configuration.""" + +from pathlib import Path + + +_ROOT = Path(__file__).resolve().parents[1] + + +def test_compose_and_frontend_image_share_vite_build_arguments() -> None: + """Compose overrides must reach the names Vite reads during its build.""" + compose = (_ROOT / "docker-compose.yml").read_text(encoding="utf-8") + dockerfile = (_ROOT / "frontend" / "Dockerfile").read_text(encoding="utf-8") + + for variable in ( + "VITE_KEYVERSE_ISSUER", + "VITE_KEYVERSE_CLIENT_ID", + "VITE_BACKEND_BASE_URL", + ): + assert f"{variable}:" in compose + assert f"ARG {variable}" in dockerfile + assert f"{variable}=${{{variable}}}" in dockerfile From 175db369e772ea531d072ad260a2f9670981ed09 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 03:07:11 +0900 Subject: [PATCH 044/393] docs: record authenticated dashboard acceptance --- docs/product-technical-gap-baseline.md | 33 ++++++++++++++++---------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 6bee0ebcc..7d32d15b3 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -2,17 +2,17 @@ > Dashboard delivery snapshot: 2026-08-26 KST (2026-08-25 UTC; latest exact-head fetch). Protected `main` was > `04e6b610655d0db91d5f7ba9486bdda1440e0b19`. Dashboard candidate head -> `b045a6e5` has local synthetic runtime evidence; this is not +> `1fb65c2e` (observed parent) has local synthetic runtime evidence; this is not > protected-main release evidence. ## Operations Dashboard PRD/TRD traceability | Requirement | Evidence contract | Delivery state | |---|---|---| -| Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification and `claim_received` → `cause_confirmed` milestones with cited spans and observed source clocks | Stacked candidate reports open/resolved/evidence-missing counts and exact elapsed time only for paired observed endpoints; every required answer and endpoint is cited or explicitly missing; authenticated runtime acceptance and corpus re-analysis pending | -| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus separate rebid-response and handover milestone pairs | Stacked candidate reports open/resolved/evidence-missing rebid and handover lifecycles without a delay threshold or invented elapsed endpoint; authenticated runtime acceptance and corpus re-analysis pending | -| External information count/rate and sales/project relation | ADR 0206; semantic `external_information` classification inside Dashboard GNB | Candidate GNB destination filters the Dashboard to external evidence; no separate Board by product decision; authenticated runtime acceptance pending | -| Project-specific journey | Explicit source/semantic project membership plus event-time ordering | Candidate API preserves every explicit project membership and the UI orders each journey chronologically; authenticated runtime acceptance pending | +| Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification and `claim_received` → `cause_confirmed` milestones with cited spans and observed source clocks | Stacked candidate reports open/resolved/evidence-missing counts and exact elapsed time only for paired observed endpoints; every required answer and endpoint is cited or explicitly missing; authenticated synthetic runtime passed, while authorized-corpus re-analysis remains pending | +| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus separate rebid-response and handover milestone pairs | Stacked candidate reports open/resolved/evidence-missing rebid and handover lifecycles without a delay threshold or invented elapsed endpoint; authenticated synthetic runtime passed, while authorized-corpus re-analysis remains pending | +| External information count/rate and sales/project relation | ADR 0206; semantic `external_information` classification inside Dashboard GNB | Candidate GNB destination filters the Dashboard to external evidence; no separate Board by product decision; authenticated synthetic runtime passed with the honest zero-result state | +| Project-specific journey | Explicit source/semantic project membership plus event-time ordering | Candidate API preserves every explicit project membership and the UI orders each journey chronologically; authenticated synthetic runtime passed | | Repeat issue to design improvement | `repeat_issue`, `issue_pattern`, and `improvement_action` cited facts | Candidate semantic contract; design-system connector acceptance pending | | Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback; authenticated runtime acceptance pending | | Similar VOC, customer cohort, prior action | Persisted repeat-issue candidate semantics plus orchestrator pair adjudication and extractive evidence | Candidate live post endpoint and post-detail UI implemented; authenticated runtime acceptance pending | @@ -83,15 +83,22 @@ hid the entire GNB despite having no drawer implementation. Candidate `6b195171` keeps the same semantic navigation in a keyboard-accessible horizontal viewport. The 390×844 rerender showed the GNB, one `main`, zero unnamed controls, no document-level horizontal overflow, and visible keyboard -focus. The isolated frontend/backend issuer mismatch returned Dashboard 401, -so populated authenticated data remains a separate acceptance item; the k6 -API run independently proved the Dashboard endpoint with the isolated issuer. +focus. Candidate `1fb65c2e` fixed the root cause of the isolated 401: Compose +passed canonical `VITE_KEYVERSE_*` build arguments while the frontend image +consumed different names and silently compiled the default issuer. A fresh OIDC +browser context then rendered the authenticated synthetic Dashboard at +1440×1000 and 390×844 with 27 total posts, one classified Event/post, 24 +pending analyses, zero failures, and honest unavailable TEPP/fast-mlsirm +producer contracts. Both viewports had one `main`, one navigation landmark, +zero unnamed controls, visible first-tab focus, no document horizontal +overflow, no console errors, and no HTTP 4xx/5xx. Screenshots remain local and +uncommitted. ### Exact open-PR boundary At this snapshot there were 9 open PRs and 10 open issues. Exact observed heads -were `#644 c1018a0a`, `#643 041ec13b`, `#640 90995cea` (observed parent), -`#639 aee02dca`, `#636 f7b9a65f`, `#632 a946f879`, `#631 c0022c97`, +were `#644 c1018a0a`, `#643 041ec13b`, `#640 1fb65c2e`, +`#639 aee02dca`, `#636 f7b9a65f`, `#632 8b322992`, `#631 c0022c97`, `#629 4b4d6707`, and `#579 689a21b6`. All remain blocked on hosted gates and/or independent review. These observations are not merge readiness. Re-fetch exact heads, @@ -117,10 +124,10 @@ context only. | ---: | --- | --- | | #644 | `c1018a0a` | splits conditional frontend surfaces and preserves keyed recovery; hosted gates and independent review remain required | | #643 | `041ec13b` | shares token-backed success/unavailable/retry status notices for the Calendar surface; hosted gates and independent review remain required | -| #640 | `90995cea` (observed parent) | quantifies dashboard cases, persists explicit missing facts and observed lifecycle milestones, consumes producer-owned topic influence without local arithmetic, repairs runtime evidence-query joins/bind arity, restores mobile GNB reachability, and enforces coordinate/provenance/lifecycle contracts; this baseline refresh advances the head, so hosted gates and independent review must run again | +| #640 | `1fb65c2e` | quantifies dashboard cases, persists explicit missing facts and observed lifecycle milestones, consumes producer-owned topic influence without local arithmetic, repairs runtime evidence-query joins/bind arity and canonical Keyverse image build arguments, restores mobile GNB reachability, and enforces coordinate/provenance/lifecycle contracts; authenticated synthetic desktop/mobile acceptance passed, while hosted gates and independent review remain required | | #639 | `aee02dca` | repairs Running-action, Compose, and TEPP configuration contracts; hosted gates and independent review remain required | | #636 | `f7b9a65f` | publishes the calibrated external-lineage contract without a redundant explicit-child filter and repairs test import hygiene; hosted gates and independent review remain required | -| #632 | `a946f879` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required | +| #632 | `8b322992` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required | | #631 | `c0022c97` | decomposes ADR gaps and queue baseline; hosted gates and independent review remain required | | #629 | `4b4d6707` | releases provider work from database leases and bounds landing reads; hosted gates and independent review remain required | | #579 | `689a21b6` | delegates leftover interaction-map arithmetic to fast-mlsirm and persists only the consumer projection; hosted gates and independent review remain required | @@ -408,7 +415,7 @@ this file per §3.5 of the prior snapshot). | Shared frontend gate | The ADR 0109 login repair is on protected `main`; eight older branches carried the defect and received the same verified repair this loop (#521–#560) | Keep every future branch cut from post-repair bases; re-verify with frontend lint/test/build before push | | Identifying baseline regression | `main` gap file listed real post identifiers; separately, closed #506 and pre-existing public history contain a private runtime source-table identifier, while current `main` and #507 trees are clean | Land this non-identifying rewrite, then coordinate ADR 0001 history remediation with security/privacy owners; do not reproduce the value, force-push, or delete evidence ad hoc | | Authorized-corpus runtime | Repository tests use synthetic fixtures; private records remain outside git | Authenticated runtime validation returning only aggregate, non-identifying evidence | -| Concurrent web responsiveness | ADR 0204 releases pooled transactions during provider work. Candidate `b045a6e5` completed a synthetic authenticated 4-VU/30-second k6 run with 4,962 requests and zero failures. After schema/lifecycle repairs, `7e63d8c2` replayed through migration 0216, passed the real-PostgreSQL Dashboard contract, and completed 1,382 requests with zero failures (HTTP p95 593.53 ms; reader p95 637.94 ms) while the host remained cold-start loaded. These are local correctness/concurrency observations, not a product guarantee | Repeat on the protected merge SHA and a representative deployment/corpus with declared CPU, memory, database pool, worker concurrency, and raw output; approve an SLO only from that capacity evidence | +| Concurrent web responsiveness | ADR 0204 releases pooled transactions during provider work. Candidate `b045a6e5` completed a synthetic authenticated 4-VU/30-second k6 run with 4,962 requests and zero failures. After schema/lifecycle repairs, `7e63d8c2` replayed through migration 0216, passed the real-PostgreSQL Dashboard contract, and completed 1,382 requests with zero failures (HTTP p95 593.53 ms; reader p95 637.94 ms) while the host remained cold-start loaded. Candidate `1fb65c2e` additionally passed fresh authenticated desktop/mobile browser acceptance without HTTP or console failures. These are local correctness/concurrency observations, not a product guarantee | Repeat on the protected merge SHA and a representative deployment/corpus with declared CPU, memory, database pool, worker concurrency, and raw output; approve an SLO only from that capacity evidence | | Image understanding | Region, OCR, and description work exists across active heads (#405, #419), but current runtime acceptance has not yet proved table-image structure, complete region coverage, or summary/image readiness together | Orchestrator-backed rendered workflow, original/derived asset provenance, region-before-OCR processing, and honest unsupported states; reconcile ADR 0052's image-bearing summary readiness with ADR 0098 before changing sequencing | | Semantic source rendering | Paragraph, table, list, formula, and indentation work exists across stacks (#394, #427, #448–#450); #515 adds synthetic backend/frontend parity for deterministic rows/cells, footnote boundaries, and encoded scripts | Land the #427 → #515 stack, then gather authenticated browser evidence that list nesting, continuation alignment, and formula units render without authoring-layout artifacts | | Event and project semantics | Multi-project mentions, project-bound actions, 5W1H, requester/processor, and semantic relations exist in ADR 0036/0052/0100/0111/0129 and active stacks | Aggregate authenticated evidence must show distinct projects and events, explicit requester/processor and real R&R, normalized relative time, and product/entity relations without promoting attendance or co-occurrence | From 31d05313651ecfaa75877d7e046cba309be83e31 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 03:30:49 +0900 Subject: [PATCH 045/393] test: remove unused datetime imports --- tests/test_operations_case_analysis.py | 1 - tests/test_operations_dashboard.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_operations_case_analysis.py b/tests/test_operations_case_analysis.py index c85783a2e..a17e5a898 100644 --- a/tests/test_operations_case_analysis.py +++ b/tests/test_operations_case_analysis.py @@ -1,7 +1,6 @@ """Operational case semantic-response contract tests.""" import json -from datetime import UTC, datetime from lineageweave.operations_case_analysis import ( OperationsEvidenceSource, diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py index 30dc72664..840a6dedc 100644 --- a/tests/test_operations_dashboard.py +++ b/tests/test_operations_dashboard.py @@ -1,6 +1,6 @@ """Focused tests for the operational dashboard evidence projection.""" -from datetime import UTC, date, datetime, timezone +from datetime import date, datetime, timezone import pytest From 4bed57a78b13be061860a34c999b0fad7517c193 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 03:47:39 +0900 Subject: [PATCH 046/393] test(ask): isolate asynchronous queue settlement --- CHANGELOG.md | 7 ++++ backend/app/main.py | 22 ++++++------ backend/tests/test_api.py | 17 +++++---- docs/product-technical-gap-baseline.md | 9 +++++ pyproject.toml | 2 +- uv.lock | 48 ++++++++++++++++++-------- 6 files changed, 72 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fe0fe635..3a1c4e20e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ All notable changes to this project are documented here. Format follows ## [Unreleased] +### Changed + +- Async Ask queue tests now isolate queue settlement from semantic retrieval, + and the development test stack follows Starlette's maintained `httpx2` + `TestClient` contract. FastAPI 422 responses use the RFC 9110 constant, so + deprecation failures are repaired rather than suppressed. + ### Added - ADR 0210's Dashboard consumer now persists a normalized, exact-provenance diff --git a/backend/app/main.py b/backend/app/main.py index 8e2534017..9a8ad5045 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1232,7 +1232,7 @@ async def resolve_customer_master_hint( ) from exc if resolution is None: raise HTTPException( - status.HTTP_422_UNPROCESSABLE_ENTITY, + status.HTTP_422_UNPROCESSABLE_CONTENT, "this hint could not be resolved to a corroborated organization name", ) return resolution @@ -1591,7 +1591,7 @@ async def read_post( as_of_clock = parse_as_of_clock(as_of) except ValueError as exc: raise HTTPException( - status.HTTP_422_UNPROCESSABLE_ENTITY, + status.HTTP_422_UNPROCESSABLE_CONTENT, "as_of must be an ISO-8601 timestamp. Use the run cutoff, " "then compare the known body with the live body.", ) from exc @@ -2215,7 +2215,7 @@ async def read_ontology_neighborhood( try: cutoff_clock = parse_as_of_clock(knowledge_cutoff) except ValueError as exc: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc try: async with pool.acquire() as conn: neighborhood = await visible_ontology_neighborhood( @@ -2631,7 +2631,7 @@ async def compare_period_groupings( try: parse_period_code(period_code) except ValueError as exc: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc async with pool.acquire() as conn: rows = await fetch_period_comparison(conn, period_code) demo_entity_ids: set[str] = set() @@ -2687,7 +2687,7 @@ async def list_period_reports( """Available calibrated periods for one grouping kind (FIPC trend).""" _require_post_read(account) if grouping_kind not in GROUPING_KINDS: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "unknown grouping_kind") + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "unknown grouping_kind") async with pool.acquire() as conn: summaries = await list_period_report_summaries(conn, grouping_kind) demo_entity_ids: set[str] = set() @@ -2717,11 +2717,11 @@ async def read_period_reports( """Calibrated IRT scores for one grouping kind and calendar period.""" _require_post_read(account) if grouping_kind not in GROUPING_KINDS: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "unknown grouping_kind") + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "unknown grouping_kind") try: parse_period_code(period_code) except ValueError as exc: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc async with pool.acquire() as conn: reports = await fetch_period_reports(conn, grouping_kind, period_code) demo_entity_ids: set[str] = set() @@ -2794,11 +2794,11 @@ async def rebuild_period_report_endpoint( """Refit or FIPC-score every group in the period. post_admin only.""" _require_post_admin(account) if grouping_kind not in GROUPING_KINDS: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "unknown grouping_kind") + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "unknown grouping_kind") try: parse_period_code(period_code) except ValueError as exc: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc async with pool.acquire() as conn: async with conn.transaction(): reports = await rebuild_period_reports(conn, grouping_kind, period_code) @@ -3003,7 +3003,7 @@ async def chat_about_post( question = request.question.strip() if not question: raise HTTPException( - status.HTTP_422_UNPROCESSABLE_ENTITY, "question is required" + status.HTTP_422_UNPROCESSABLE_CONTENT, "question is required" ) post = await _load_visible_post(post_id, account, pool) post_metadata = build_post_llm_metadata(post_id, post) @@ -3596,7 +3596,7 @@ async def read_calendar( _require_post_read(account) if (window_start is None) ^ (window_end is None): raise HTTPException( - status.HTTP_422_UNPROCESSABLE_ENTITY, + status.HTTP_422_UNPROCESSABLE_CONTENT, "window_start and window_end must be supplied together", ) settings = load_settings() diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 5538f465b..cf0d7d512 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -5088,18 +5088,21 @@ def test_ask_queues_a_job_and_polls_it_to_a_settled_answer( """ import time as _time - from lineageweave.post_chat import ChatAnswer - class _FakeChatClient: available = True - def answer(self, question, sources): # noqa: ARG002 - contract shape - return ChatAnswer( - answer_text="A settled asynchronous answer.", - cited_post_ids=(sources[0].post_id,), - ) + async def _fake_compute_answer(*_args, **_kwargs): + return { + "answer_text": "A settled asynchronous answer.", + "cited_post_ids": [seeded_db["public_post_id"]], + "lineage_graph": {"nodes": [], "edges": [], "truncated": False}, + "cited_post_images": [], + } monkeypatch.setattr("backend.app.main._post_chat_client", lambda **_kwargs: _FakeChatClient()) + monkeypatch.setattr( + "backend.app.global_ask_queue.compute_global_ask_answer", _fake_compute_answer + ) headers = {"Authorization": f"Bearer {demo_analyst_token}"} submitted = client.post( "/api/ask", json={"question": "What happened with the public post?"}, headers=headers diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 7d32d15b3..bba660f5a 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -93,6 +93,15 @@ producer contracts. Both viewports had one `main`, one navigation landmark, zero unnamed controls, visible first-tab focus, no document horizontal overflow, no console errors, and no HTTP 4xx/5xx. Screenshots remain local and uncommitted. +The same candidate then ran the complete Python/backend suite with +`DeprecationWarning` promoted to an error: 1,372 tests passed and 17 declared +integration/provider tests skipped. The run exposed one stale async-queue test +that implicitly depended on unavailable semantic retrieval; the repaired test +injects the queue computation boundary, while dedicated retrieval tests retain +the no-keyword, fail-closed embedding contract. Starlette's maintained +`httpx2` TestClient dependency and FastAPI's RFC 9110 422 constant remove the +observed deprecations without suppression. This remains local exact-head +regression evidence, not hosted-gate or protected-main evidence. ### Exact open-PR boundary diff --git a/pyproject.toml b/pyproject.toml index e3b7a5b18..d8fb3a5bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,11 +37,11 @@ dev = [ "coverage>=7.6", "pyjwt[crypto]>=2.8.0", "pytest>=8.0", - "httpx>=0.27.0", # Closed-world SHACL validation of docs/ontology/lineageweave-kg.ttl # against lineageweave-kg-shapes.ttl (ADR 0207 decision 10). Pure # Python; OWL-RL reasoning included. "pyshacl>=0.26.0", + "httpx2>=2.12.0", ] backend = [ "fastapi>=0.115.0", diff --git a/uv.lock b/uv.lock index 6190681ba..50586f675 100644 --- a/uv.lock +++ b/uv.lock @@ -523,16 +523,16 @@ wheels = [ ] [[package]] -name = "httpcore" -version = "1.0.9" +name = "httpcore2" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, { name = "h11" }, + { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, ] [[package]] @@ -572,18 +572,29 @@ wheels = [ ] [[package]] -name = "httpx" -version = "0.28.1" +name = "httpx2" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] @@ -631,7 +642,7 @@ backend = [ ] dev = [ { name = "coverage" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "psycopg2-binary" }, { name = "pyjwt", extra = ["crypto"] }, { name = "pyshacl" }, @@ -646,7 +657,7 @@ requires-dist = [ { name = "cryptography", specifier = ">=42.0" }, { name = "fast-mlsirm", marker = "extra == 'backend'", git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=d025b7d237d8db7ca97a5611606c6285d5870895" }, { name = "fastapi", marker = "extra == 'backend'", specifier = ">=0.115.0" }, - { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, + { name = "httpx2", marker = "extra == 'dev'", specifier = ">=2.12.0" }, { name = "opentelemetry-api", specifier = ">=1.30.0" }, { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.30.0" }, { name = "opentelemetry-sdk", specifier = ">=1.30.0" }, @@ -1272,6 +1283,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/e0/ffbc0d61d68304602120998a5d660c8108464064bdedc814dc4be8410425/threadweave-0.1.0-py3-none-any.whl", hash = "sha256:03c31fa21873a9493687d81eab4ec067bf169dade7cff077b80df46fd0db3aaf", size = 14967, upload-time = "2026-07-12T03:59:57.088Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" From 0859f07c35ed75bbc81ae120e3b7b4465c0c2165 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 04:07:43 +0900 Subject: [PATCH 047/393] fix(dashboard): align topic readiness windows --- backend/app/operations_dashboard.py | 16 +++++++++++++--- tests/test_operations_dashboard.py | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py index 7a4dbf622..40093fe3a 100644 --- a/backend/app/operations_dashboard.py +++ b/backend/app/operations_dashboard.py @@ -501,7 +501,8 @@ async def _fetch_topic_context_dashboard( readiness = await conn.fetchrow( f""" with visible_post as ( - select post.post_id + select post.post_id, + coalesce(post.event_occurred_at, post.created_at) as occurred_at from source_post post where {visible_post_sql} ) @@ -513,7 +514,9 @@ async def _fetch_topic_context_dashboard( join analysis_run analysis on analysis.analysis_run_id = model.analysis_run_id join analysis_run_scope scope on scope.analysis_run_id = analysis.analysis_run_id join visible_post on visible_post.post_id = membership.source_post_id - where {authorized_model_scope} + where visible_post.occurred_at >= membership.valid_from + and visible_post.occurred_at < membership.valid_to + and {authorized_model_scope} ) as tepp_posterior_persisted, exists ( select 1 @@ -526,7 +529,14 @@ async def _fetch_topic_context_dashboard( join analysis_run analysis on analysis.analysis_run_id = model.analysis_run_id join analysis_run_scope scope on scope.analysis_run_id = analysis.analysis_run_id join visible_post on visible_post.post_id = membership.source_post_id - where {authorized_model_scope} + join topic_activity_interval activity + on activity.topic_model_run_id = influence.topic_model_run_id + and activity.topic_index = influence.topic_index + and visible_post.occurred_at >= activity.valid_from + and visible_post.occurred_at < activity.valid_to + where visible_post.occurred_at >= membership.valid_from + and visible_post.occurred_at < membership.valid_to + and {authorized_model_scope} ) as fast_mlsirm_influence_persisted """, *args, diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py index 840a6dedc..5bf0314b2 100644 --- a/tests/test_operations_dashboard.py +++ b/tests/test_operations_dashboard.py @@ -360,6 +360,22 @@ async def fetchrow(self, query: str, *args: object) -> dict[str, object]: assert result["topic_context"]["reason_code"] == "fast_mlsirm_influence_not_persisted" assert result["topic_context"]["topics"] == [] + +@pytest.mark.anyio +async def test_topic_readiness_uses_projection_temporal_windows() -> None: + """Readiness cannot count influence rows the projection must reject by time.""" + conn = _Connection() + await fetch_operations_dashboard(conn, []) + readiness_query = next( + query for query, _args in conn.queries if "tepp_posterior_persisted" in query + ) + assert "coalesce(post.event_occurred_at, post.created_at) as occurred_at" in readiness_query + assert "visible_post.occurred_at >= membership.valid_from" in readiness_query + assert "visible_post.occurred_at < membership.valid_to" in readiness_query + assert "join topic_activity_interval activity" in readiness_query + assert "visible_post.occurred_at >= activity.valid_from" in readiness_query + assert "visible_post.occurred_at < activity.valid_to" in readiness_query + @pytest.mark.anyio async def test_external_scope_is_bound_in_every_dashboard_query() -> None: """The external destination restricts data at the API query boundary.""" From 24c76c10ec442f534a4a3191e1165fa0ebd6b53c Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 04:09:32 +0900 Subject: [PATCH 048/393] fix(dashboard): preserve external metric denominator --- backend/app/operations_dashboard.py | 8 +---- pyproject.toml | 2 +- tests/test_operations_dashboard.py | 12 +++++--- uv.lock | 48 +++++++++-------------------- 4 files changed, 24 insertions(+), 46 deletions(-) diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py index 40093fe3a..fb281e203 100644 --- a/backend/app/operations_dashboard.py +++ b/backend/app/operations_dashboard.py @@ -169,12 +169,6 @@ async def fetch_operations_dashboard( select post.post_id from source_post post where {visible} - and ($5::boolean is false or exists ( - select 1 - from operations_case_classification scoped_classification - where scoped_classification.post_id = post.post_id - and scoped_classification.case_kind_code = 'external_information' - )) ), classified as ( select classification.post_id, classification.case_kind_code from operations_case_classification classification @@ -208,7 +202,7 @@ async def fetch_operations_dashboard( and job.status_code = 'post_content_ingestion_failed' )) as failed_analysis_count """, - *args, + *args[:4], ) case_rows = await conn.fetch( f""" diff --git a/pyproject.toml b/pyproject.toml index d8fb3a5bd..4685e1f97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ dev = [ # against lineageweave-kg-shapes.ttl (ADR 0207 decision 10). Pure # Python; OWL-RL reasoning included. "pyshacl>=0.26.0", - "httpx2>=2.12.0", + "httpx>=0.27.0", ] backend = [ "fastapi>=0.115.0", diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py index 5bf0314b2..61fa42e9f 100644 --- a/tests/test_operations_dashboard.py +++ b/tests/test_operations_dashboard.py @@ -377,15 +377,19 @@ async def test_topic_readiness_uses_projection_temporal_windows() -> None: assert "visible_post.occurred_at < activity.valid_to" in readiness_query @pytest.mark.anyio -async def test_external_scope_is_bound_in_every_dashboard_query() -> None: - """The external destination restricts data at the API query boundary.""" +async def test_external_scope_filters_cases_without_changing_total_population() -> None: + """External-only cases retain whole authorized-corpus metric denominators.""" conn = _Connection() await fetch_operations_dashboard( conn, ["corp"], ["pu"], date(2026, 8, 1), date(2026, 8, 31), external_only=True ) assert conn.queries - assert all("$5::boolean" in query for query, _ in conn.queries) - assert all(args[-1] is True for _, args in conn.queries) + metrics_query, metrics_args = conn.queries[0] + assert "$5::boolean" not in metrics_query + assert len(metrics_args) == 4 + for query, args in conn.queries[1:]: + assert "$5::boolean" in query + assert args[-1] is True @pytest.mark.anyio diff --git a/uv.lock b/uv.lock index 50586f675..6190681ba 100644 --- a/uv.lock +++ b/uv.lock @@ -523,16 +523,16 @@ wheels = [ ] [[package]] -name = "httpcore2" -version = "2.12.0" +name = "httpcore" +version = "1.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "certifi" }, { name = "h11" }, - { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] [[package]] @@ -572,29 +572,18 @@ wheels = [ ] [[package]] -name = "httpx2" -version = "2.12.0" +name = "httpx" +version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform != 'emscripten'" }, - { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, - { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, { name = "idna" }, - { name = "truststore", marker = "sys_platform != 'emscripten'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, -] - -[[package]] -name = "httpx2-jsfetch" -version = "1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] [[package]] @@ -642,7 +631,7 @@ backend = [ ] dev = [ { name = "coverage" }, - { name = "httpx2" }, + { name = "httpx" }, { name = "psycopg2-binary" }, { name = "pyjwt", extra = ["crypto"] }, { name = "pyshacl" }, @@ -657,7 +646,7 @@ requires-dist = [ { name = "cryptography", specifier = ">=42.0" }, { name = "fast-mlsirm", marker = "extra == 'backend'", git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=d025b7d237d8db7ca97a5611606c6285d5870895" }, { name = "fastapi", marker = "extra == 'backend'", specifier = ">=0.115.0" }, - { name = "httpx2", marker = "extra == 'dev'", specifier = ">=2.12.0" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, { name = "opentelemetry-api", specifier = ">=1.30.0" }, { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.30.0" }, { name = "opentelemetry-sdk", specifier = ">=1.30.0" }, @@ -1283,15 +1272,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/e0/ffbc0d61d68304602120998a5d660c8108464064bdedc814dc4be8410425/threadweave-0.1.0-py3-none-any.whl", hash = "sha256:03c31fa21873a9493687d81eab4ec067bf169dade7cff077b80df46fd0db3aaf", size = 14967, upload-time = "2026-07-12T03:59:57.088Z" }, ] -[[package]] -name = "truststore" -version = "0.10.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, -] - [[package]] name = "typing-extensions" version = "4.16.0" From 87152ff4717af22d15e28c2c9a24bdfe39edf591 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 04:11:32 +0900 Subject: [PATCH 049/393] fix(dashboard): align empty topic contract state --- backend/app/operations_dashboard.py | 7 ++++++- tests/test_operations_dashboard.py | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py index fb281e203..2f66db4a5 100644 --- a/backend/app/operations_dashboard.py +++ b/backend/app/operations_dashboard.py @@ -634,6 +634,11 @@ async def _fetch_topic_context_dashboard( ) if not rows: tepp_ready = bool(readiness and readiness["tepp_posterior_persisted"]) + # The readiness query can see an accepted influence row from a + # different selected run than the projection query. In an empty + # projection, report fast-mlsirm as unavailable for this exact + # visible/time window rather than claiming a persisted contract. + fast_mlsirm_ready = False return { "status_code": "unavailable", "reason_code": ( @@ -657,7 +662,7 @@ async def _fetch_topic_context_dashboard( "schema_version": "fast_mlsirm.topic_context_influence.v1", "state_code": ( "persisted" - if readiness and readiness["fast_mlsirm_influence_persisted"] + if fast_mlsirm_ready else "not_persisted" ), }, diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py index 61fa42e9f..cb0dd6f55 100644 --- a/tests/test_operations_dashboard.py +++ b/tests/test_operations_dashboard.py @@ -361,6 +361,26 @@ async def fetchrow(self, query: str, *args: object) -> dict[str, object]: assert result["topic_context"]["topics"] == [] +@pytest.mark.anyio +async def test_empty_projection_does_not_claim_fast_result_persisted() -> None: + """An empty visible projection must not contradict its contract state.""" + + class ReadyButEmptyConnection(_Connection): + async def fetchrow(self, query: str, *args: object) -> dict[str, object]: + if "tepp_posterior_persisted" in query: + self.queries.append((query, args)) + return { + "tepp_posterior_persisted": True, + "fast_mlsirm_influence_persisted": True, + } + return await super().fetchrow(query, *args) + + result = await fetch_operations_dashboard(ReadyButEmptyConnection(), []) + contracts = result["topic_context"]["required_contracts"] + assert contracts[0]["state_code"] == "persisted" + assert contracts[1]["state_code"] == "not_persisted" + + @pytest.mark.anyio async def test_topic_readiness_uses_projection_temporal_windows() -> None: """Readiness cannot count influence rows the projection must reject by time.""" From 2d50fa01abcfd80ab8caeffdcc842f1d8ea8113f Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 04:11:33 +0900 Subject: [PATCH 050/393] fix(test): retain Starlette httpx2 transport --- pyproject.toml | 2 +- uv.lock | 48 ++++++++++++++++++++++++++++++++++-------------- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4685e1f97..d8fb3a5bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ dev = [ # against lineageweave-kg-shapes.ttl (ADR 0207 decision 10). Pure # Python; OWL-RL reasoning included. "pyshacl>=0.26.0", - "httpx>=0.27.0", + "httpx2>=2.12.0", ] backend = [ "fastapi>=0.115.0", diff --git a/uv.lock b/uv.lock index 6190681ba..50586f675 100644 --- a/uv.lock +++ b/uv.lock @@ -523,16 +523,16 @@ wheels = [ ] [[package]] -name = "httpcore" -version = "1.0.9" +name = "httpcore2" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, { name = "h11" }, + { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, ] [[package]] @@ -572,18 +572,29 @@ wheels = [ ] [[package]] -name = "httpx" -version = "0.28.1" +name = "httpx2" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] @@ -631,7 +642,7 @@ backend = [ ] dev = [ { name = "coverage" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "psycopg2-binary" }, { name = "pyjwt", extra = ["crypto"] }, { name = "pyshacl" }, @@ -646,7 +657,7 @@ requires-dist = [ { name = "cryptography", specifier = ">=42.0" }, { name = "fast-mlsirm", marker = "extra == 'backend'", git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=d025b7d237d8db7ca97a5611606c6285d5870895" }, { name = "fastapi", marker = "extra == 'backend'", specifier = ">=0.115.0" }, - { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, + { name = "httpx2", marker = "extra == 'dev'", specifier = ">=2.12.0" }, { name = "opentelemetry-api", specifier = ">=1.30.0" }, { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.30.0" }, { name = "opentelemetry-sdk", specifier = ">=1.30.0" }, @@ -1272,6 +1283,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/e0/ffbc0d61d68304602120998a5d660c8108464064bdedc814dc4be8410425/threadweave-0.1.0-py3-none-any.whl", hash = "sha256:03c31fa21873a9493687d81eab4ec067bf169dade7cff077b80df46fd0db3aaf", size = 14967, upload-time = "2026-07-12T03:59:57.088Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" From 361641ece89f6b8d030188913fd13a41ab9c89df Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 07:38:35 +0900 Subject: [PATCH 051/393] fix(packaging): adopt SPDX license metadata --- CHANGELOG.d/2.20.0-backend-contract-regressions.md | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.d/2.20.0-backend-contract-regressions.md b/CHANGELOG.d/2.20.0-backend-contract-regressions.md index a391d991b..02c90e63b 100644 --- a/CHANGELOG.d/2.20.0-backend-contract-regressions.md +++ b/CHANGELOG.d/2.20.0-backend-contract-regressions.md @@ -1,3 +1,3 @@ ### Fixed -- Restored the runtime-only TEPP credential setting, kept API integration fixtures collision-free, aligned asynchronous Ask tests with semantic retrieval, replaced deprecated FastAPI 422 constants, and moved Starlette integration tests to its supported `httpx2` transport. +- Restored the runtime-only TEPP credential setting, kept API integration fixtures collision-free, aligned asynchronous Ask tests with semantic retrieval, replaced deprecated FastAPI 422 constants, moved Starlette integration tests to its supported `httpx2` transport, and adopted the SPDX license expression required by current packaging metadata. diff --git a/pyproject.toml b/pyproject.toml index 407679fd6..bc85fb6d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "lineageweave" version = "2.18.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" -license = { text = "MIT" } +license = "MIT" # Was >=3.10; fast-mlsirm (see [project.optional-dependencies].backend) # declares >=3.12 itself, so this floor must match or `uv`'s resolver # reports the whole project unsatisfiable. From 9ca0582995a2b75f67e4dcbd5f240fa0c537c586 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 07:56:47 +0900 Subject: [PATCH 052/393] fix(dashboard): meet minimum evidence touch target --- CHANGELOG.md | 2 ++ docs/doctoring/DESIGN_TOKEN_REFERENCES.md | 2 +- docs/operability/http-concurrency-evidence.md | 11 +++++++++++ docs/product-technical-gap-baseline.md | 10 ++++++++-- frontend/src/App.css | 6 ++++++ frontend/src/styles/tokens.test.ts | 8 ++++++++ 6 files changed, 36 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a695667a2..2f6202f99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to this project are documented here. Format follows ### Changed +- Dashboard evidence-link hit areas now honor the shared minimum control-size token on touch layouts. + - Async Ask queue tests now isolate queue settlement from semantic retrieval, and the development test stack follows Starlette's maintained `httpx2` `TestClient` contract. FastAPI 422 responses use the RFC 9110 constant, so diff --git a/docs/doctoring/DESIGN_TOKEN_REFERENCES.md b/docs/doctoring/DESIGN_TOKEN_REFERENCES.md index c22317628..43e8846dc 100644 --- a/docs/doctoring/DESIGN_TOKEN_REFERENCES.md +++ b/docs/doctoring/DESIGN_TOKEN_REFERENCES.md @@ -10,7 +10,7 @@ Ask evidence dialog, and the Storybook inventory. |---|---|---| | W3C Design Tokens Format Module 2025.10 | Name color, space, type, and radius once; consume those names from repeated objects. | `frontend/src/styles/tokens.css` defines `--color-*`, `--space-*`, `--size-control-min`, `--radius-chip`, `--radius-control`, `--radius-panel`, and `--font-*`. `CitationChip`, `PopupCloseButton`, `CutoffKnownBody`, and `LineageEntityPicker` read those names through `App.css`. | | Storybook for React & Vite | Catalog repeated controls so a buyer can try the next click without reading `App.tsx`. | `frontend/src/components/*.stories.tsx` and `docs/storybook-inventory.md`. | -| WCAG 2.2 | Give interactive controls programmatic names and announce an asynchronous evidence failure instead of leaving a perpetual loading state. | Component interaction tests exercise the named controls; `EvidencePanel` exposes its terminal failure with `role="alert"`. This is targeted evidence, not a claim of complete WCAG conformance. | +| WCAG 2.2 | Give interactive controls programmatic names, meet SC 2.5.8's 24×24 CSS-pixel minimum target, and announce an asynchronous evidence failure instead of leaving a perpetual loading state. | Component interaction tests exercise the named controls; Dashboard evidence links consume `--size-control-min`; `EvidencePanel` exposes its terminal failure with `role="alert"`. This is targeted evidence, not a claim of complete WCAG conformance. | | WAI-ARIA APG Dialog (Modal) Pattern | A surface marked `aria-modal="true"` must behave modally: focus moves inside, `Tab` and `Shift+Tab` remain inside, and `Escape` closes the layer. | `AskEvidenceLayerPopup` moves initial focus inside the dialog and explicitly cycles forward/backward keyboard focus between its actionable controls; component tests cover both focus-loop directions and Escape. Its evidence lists use dialog-specific accessible labels so assistive technology can distinguish the modal list from the still-rendered inline answer. | ## APA 7th references diff --git a/docs/operability/http-concurrency-evidence.md b/docs/operability/http-concurrency-evidence.md index bca6d5b3f..2af736e69 100644 --- a/docs/operability/http-concurrency-evidence.md +++ b/docs/operability/http-concurrency-evidence.md @@ -117,3 +117,14 @@ duplicate filter-option query; they do not demonstrate current-head latency, causality, capacity, or an SLO. ADR 0212 combines the two option projections into one database query; its physical plan remains to be measured exact-head. Repeat the synthetic k6 run on an exact-head image before comparing effects. + +## Operations Dashboard exact-head observation + +On 2026-08-26 KST, candidate `361641ec` ran from a freshly built, isolated +Compose project with the repository's 27-post synthetic dataset, 4 VUs, and a +30-second observation window. It completed 974 iterations and 3,898 HTTP +requests with zero failed requests and 3,896/3,896 successful reader checks. +HTTP p95 was 194.70 ms; ordinary-reader p95 was 204.06 ms; Ask enqueue was +104.90 ms. This local synthetic observation is not a capacity guarantee or an +approved SLO; repeat it on the protected merge SHA and representative declared +deployment capacity. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 326f3a39f..18050a478 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -72,6 +72,12 @@ required before protected delivery can be claimed. Authenticated authorized-corpus acceptance remains separate and may return only aggregate, non-identifying evidence to this repository. +The current candidate was also re-rendered from the isolated synthetic stack +at 1440×1100 and 402×1200 after the touch-target repair. Both viewports had +document width equal to viewport width, no browser console/page errors, and no +visible button or link below the shared 24px WCAG 2.2 minimum target token. +The screenshots remain local `/tmp` audit artifacts and are not committed. + At the repaired dashboard head, the `EvidenceReady` and `NarrowViewport` stories were re-rendered locally with synthetic data at desktop and iPhone 13 viewports. The desktop shows separate Event/post values and evidence @@ -417,12 +423,12 @@ this file per §3.5 of the prior snapshot). | Gap | Current evidence | Acceptance requirement | | --- | --- | --- | -| Protected release | 3 open PRs at snapshot: #627 and #628 are current-main performance follow-ups, while reopened #579 retains hosted and independent-review gates | Terminal exact-head checks, no unresolved threads, independent exact-head approvals, protected squash-merge SHA | +| Protected release | 11 open PRs at the 07:26 KST snapshot; the exact-head inventory in section 1 records their current evidence boundaries | Terminal exact-head checks, no unresolved threads, independent exact-head approvals, protected squash-merge SHA | | Evidence-grounded operations workspace | Protected-main #614 delivers governed semantic Ask, live Similar VOC, disjoint pending/failed analysis metrics, full Storybook state inventory, and current desktop/mobile screenshot evidence. Authorized-corpus backfill acceptance remains unavailable | Perform authenticated authorized-corpus acceptance with aggregate evidence and retain fail-closed no-match behavior | | Shared frontend gate | The ADR 0109 login repair is on protected `main`; eight older branches carried the defect and received the same verified repair this loop (#521–#560) | Keep every future branch cut from post-repair bases; re-verify with frontend lint/test/build before push | | Identifying baseline regression | `main` gap file listed real post identifiers; separately, closed #506 and pre-existing public history contain a private runtime source-table identifier, while current `main` and #507 trees are clean | Land this non-identifying rewrite, then coordinate ADR 0001 history remediation with security/privacy owners; do not reproduce the value, force-push, or delete evidence ad hoc | | Authorized-corpus runtime | Repository tests use synthetic fixtures; private records remain outside git | Authenticated runtime validation returning only aggregate, non-identifying evidence | -| Concurrent web responsiveness | ADR 0204 releases pooled transactions during provider work. Candidate `b045a6e5` completed a synthetic authenticated 4-VU/30-second k6 run with 4,962 requests and zero failures. After schema/lifecycle repairs, `7e63d8c2` replayed through migration 0216, passed the real-PostgreSQL Dashboard contract, and completed 1,382 requests with zero failures (HTTP p95 593.53 ms; reader p95 637.94 ms) while the host remained cold-start loaded. Candidate `1fb65c2e` additionally passed fresh authenticated desktop/mobile browser acceptance without HTTP or console failures. These are local correctness/concurrency observations, not a product guarantee | Repeat on the protected merge SHA and a representative deployment/corpus with declared CPU, memory, database pool, worker concurrency, and raw output; approve an SLO only from that capacity evidence | +| Concurrent web responsiveness | ADR 0204 releases pooled transactions during provider work. Candidate `361641ec` completed a freshly built, isolated, authenticated 4-VU/30-second k6 run over 27 synthetic posts: 3,898 requests, 0 failures, HTTP p95 194.70 ms, reader p95 204.06 ms, and Ask enqueue 104.90 ms. Earlier candidate observations remain in the operability record. These are local correctness/concurrency observations, not a product guarantee | Repeat on the protected merge SHA and a representative deployment/corpus with declared CPU, memory, database pool, worker concurrency, and raw output; approve an SLO only from that capacity evidence | | Image understanding | Region, OCR, and description work exists across active heads (#405, #419), but current runtime acceptance has not yet proved table-image structure, complete region coverage, or summary/image readiness together | Orchestrator-backed rendered workflow, original/derived asset provenance, region-before-OCR processing, and honest unsupported states; reconcile ADR 0052's image-bearing summary readiness with ADR 0098 before changing sequencing | | Semantic source rendering | ADR 0223 and migration 0221 give new paragraph, list, table, MathML formula, and caller-parsed conversation-turn units explicit persisted kinds without rewriting historical rows; image regions remain ordered normalized children under ADR 0091. This branch is candidate evidence, not protected-main delivery | Land the exact-head candidate, then prove an authorized semantic-only query retrieves each persisted unit kind and gather authenticated browser evidence that nesting, continuation alignment, formula units, and image regions retain source order | | Event and project semantics | Multi-project mentions, project-bound actions, 5W1H, requester/processor, and semantic relations exist in ADR 0036/0052/0100/0111/0129 and active stacks | Aggregate authenticated evidence must show distinct projects and events, explicit requester/processor and real R&R, normalized relative time, and product/entity relations without promoting attendance or co-occurrence | diff --git a/frontend/src/App.css b/frontend/src/App.css index a3a13d531..0bfe4f13c 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -225,6 +225,12 @@ background: var(--color-btn-secondary-hover); } +.btn-link { + display: inline-flex; + align-items: center; + min-height: var(--size-control-min); +} + /* Language Switcher */ .language-switcher { display: inline-flex; diff --git a/frontend/src/styles/tokens.test.ts b/frontend/src/styles/tokens.test.ts index 435c973ad..0f08f1c68 100644 --- a/frontend/src/styles/tokens.test.ts +++ b/frontend/src/styles/tokens.test.ts @@ -174,6 +174,14 @@ describe("design tokens", () => { expect(citationChipBlock).toContain("display: inline-flex"); expect(citationChipBlock).toContain("align-items: center"); }); + + it("gives Dashboard evidence links the shared minimum touch target", () => { + const rule = appCss.match(/\.btn-link\s*\{[^}]*\}/)?.[0] ?? ""; + expect(rule, ".btn-link rule not found in App.css").not.toBe(""); + expect(rule).toContain("min-height: var(--size-control-min)"); + expect(rule).toContain("display: inline-flex"); + expect(rule).toContain("align-items: center"); + }); }); describe("secondary disclosure toggle touch targets", () => { From bc4a251d37d4732c8f8a125cbc032f7a817968f6 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 07:57:48 +0900 Subject: [PATCH 053/393] docs(gaps): record temporal producer prerequisites --- docs/product-technical-gap-baseline.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 18050a478..10889f57d 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -17,7 +17,7 @@ | Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback; authenticated runtime acceptance pending | | Similar VOC, customer cohort, prior action | Persisted repeat-issue candidate semantics plus orchestrator pair adjudication and extractive evidence | Candidate live post endpoint and post-detail UI implemented; authenticated runtime acceptance pending | | TEPP independent Event Lineage anchor | Accepted, persisted TEPP criterion bound to exact snapshot/cutoff before fast-mlsirm activation | Consumer PR #606 is on protected main; TEPP producer PR #237 remains open, so no end-to-end accepted artifact is release evidence yet | -| Temporal Lineage topics and multilevel important posts | ADR 0210; TEPP posterior topic/plausible-value contract followed by fast-mlsirm observed-information case-deletion influence | This stacked candidate adds normalized persistence, exact run/snapshot/cutoff binding, pre-aggregation scope authorization, API diagnostics, and populated/unavailable Storybook surfaces. TEPP PR #247 remains open; #248 was closed because diagonal Laplace moments cannot represent the joint posterior, while stacked #251/#252 add fail-closed and joint-precision prerequisites only. fast-mlsirm still has no accepted influence result. Runtime therefore remains honestly unavailable with no local Python or fallback score. | +| Temporal Lineage topics and multilevel important posts | ADR 0210; TEPP posterior topic/plausible-value contract followed by fast-mlsirm observed-information case-deletion influence | This stacked candidate adds normalized persistence, exact run/snapshot/cutoff binding, pre-aggregation scope authorization, API diagnostics, and populated/unavailable Storybook surfaces. TEPP PR #247 remains open at `063f10f3`; stacked #251–#254 provide fail-closed input validation, full joint precision, deterministic joint plausible-value draws, and the canonical research register, while complete provenance assembly remains gated. fast-mlsirm PR #1418 validates the Rust consumer envelope but intentionally returns `EstimatorUnavailable` until the scientific estimator lands. Runtime therefore remains honestly unavailable with no local Python or fallback score. | ### Technical contract and flow From 88c0632fb46a11579b47441f12e77e5527499a9d Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 08:09:32 +0900 Subject: [PATCH 054/393] fix(dashboard): scope external headline metrics --- backend/app/operations_dashboard.py | 26 +++++++++++++++++++------- tests/test_operations_dashboard.py | 9 +++++---- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py index 2f66db4a5..07fc80349 100644 --- a/backend/app/operations_dashboard.py +++ b/backend/app/operations_dashboard.py @@ -176,33 +176,45 @@ async def fetch_operations_dashboard( join source_post evidence_post on evidence_post.post_id = classification.evidence_post_id where {visible_evidence} + ), scoped_post as ( + select visible_post.post_id + from visible_post + where $5::boolean is false + or exists ( + select 1 + from classified + where classified.post_id = visible_post.post_id + and classified.case_kind_code = 'external_information' + ) ) - select (select count(*) from visible_post) as total_post_count, + select (select count(*) from scoped_post) as total_post_count, (select count(*) from post_summary_event summary_event where exists ( select 1 from classified where classified.post_id = summary_event.post_id + and ($5::boolean is false + or classified.case_kind_code = 'external_information') )) as total_event_count, (select count(distinct post_id) from classified where case_kind_code = 'external_information') as external_post_count, - (select count(*) from visible_post + (select count(*) from scoped_post where not exists ( select 1 from operations_case_analysis analysis - where analysis.post_id = visible_post.post_id + where analysis.post_id = scoped_post.post_id ) and not exists ( select 1 from post_content_ingestion_job job - where job.post_id = visible_post.post_id + where job.post_id = scoped_post.post_id and job.status_code = 'post_content_ingestion_failed' )) as pending_analysis_count, - (select count(*) from visible_post + (select count(*) from scoped_post where exists ( select 1 from post_content_ingestion_job job - where job.post_id = visible_post.post_id + where job.post_id = scoped_post.post_id and job.status_code = 'post_content_ingestion_failed' )) as failed_analysis_count """, - *args[:4], + *args, ) case_rows = await conn.fetch( f""" diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py index cb0dd6f55..5ec0a0543 100644 --- a/tests/test_operations_dashboard.py +++ b/tests/test_operations_dashboard.py @@ -397,16 +397,17 @@ async def test_topic_readiness_uses_projection_temporal_windows() -> None: assert "visible_post.occurred_at < activity.valid_to" in readiness_query @pytest.mark.anyio -async def test_external_scope_filters_cases_without_changing_total_population() -> None: - """External-only cases retain whole authorized-corpus metric denominators.""" +async def test_external_scope_filters_cases_and_headline_population() -> None: + """External-only metrics and cases share one authorized population.""" conn = _Connection() await fetch_operations_dashboard( conn, ["corp"], ["pu"], date(2026, 8, 1), date(2026, 8, 31), external_only=True ) assert conn.queries metrics_query, metrics_args = conn.queries[0] - assert "$5::boolean" not in metrics_query - assert len(metrics_args) == 4 + assert "scoped_post" in metrics_query + assert "$5::boolean" in metrics_query + assert metrics_args[-1] is True for query, args in conn.queries[1:]: assert "$5::boolean" in query assert args[-1] is True From ab4359039263cc73ef34181c8dcf17e86f4ee584 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 08:18:35 +0900 Subject: [PATCH 055/393] fix(dashboard): retain external coverage denominator --- CHANGELOG.md | 1 + backend/app/operations_dashboard.py | 2 +- backend/tests/test_operations_dashboard_postgres.py | 2 ++ tests/test_operations_dashboard.py | 6 ++++-- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f6202f99..823515c43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project are documented here. Format follows ### Changed - Dashboard evidence-link hit areas now honor the shared minimum control-size token on touch layouts. +- The external-information GNB keeps all authorized in-period posts as its coverage denominator while filtering the displayed case rows. - Async Ask queue tests now isolate queue settlement from semantic retrieval, and the development test stack follows Starlette's maintained `httpx2` diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py index 07fc80349..ae8f3eb23 100644 --- a/backend/app/operations_dashboard.py +++ b/backend/app/operations_dashboard.py @@ -187,7 +187,7 @@ async def fetch_operations_dashboard( and classified.case_kind_code = 'external_information' ) ) - select (select count(*) from scoped_post) as total_post_count, + select (select count(*) from visible_post) as total_post_count, (select count(*) from post_summary_event summary_event where exists ( diff --git a/backend/tests/test_operations_dashboard_postgres.py b/backend/tests/test_operations_dashboard_postgres.py index ef529867f..fad533606 100644 --- a/backend/tests/test_operations_dashboard_postgres.py +++ b/backend/tests/test_operations_dashboard_postgres.py @@ -38,10 +38,12 @@ async def test_operations_dashboard_sql_binds_against_postgres() -> None: ) is None: pytest.skip(f"requires the migration that creates {table_name}") result = await fetch_operations_dashboard(connection, []) + external_result = await fetch_operations_dashboard(connection, [], external_only=True) finally: await connection.close() assert result["total_post_count"] >= 0 + assert external_result["total_post_count"] == result["total_post_count"] assert result["topic_context"]["status_code"] in {"accepted", "unavailable"} diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py index 5ec0a0543..4a5b9c0bf 100644 --- a/tests/test_operations_dashboard.py +++ b/tests/test_operations_dashboard.py @@ -397,8 +397,8 @@ async def test_topic_readiness_uses_projection_temporal_windows() -> None: assert "visible_post.occurred_at < activity.valid_to" in readiness_query @pytest.mark.anyio -async def test_external_scope_filters_cases_and_headline_population() -> None: - """External-only metrics and cases share one authorized population.""" +async def test_external_scope_filters_cases_without_shrinking_coverage_denominator() -> None: + """External-only cases retain all visible posts as the percentage denominator.""" conn = _Connection() await fetch_operations_dashboard( conn, ["corp"], ["pu"], date(2026, 8, 1), date(2026, 8, 31), external_only=True @@ -406,6 +406,8 @@ async def test_external_scope_filters_cases_and_headline_population() -> None: assert conn.queries metrics_query, metrics_args = conn.queries[0] assert "scoped_post" in metrics_query + assert "count(*) from visible_post) as total_post_count" in metrics_query + assert "count(*) from scoped_post) as total_post_count" not in metrics_query assert "$5::boolean" in metrics_query assert metrics_args[-1] is True for query, args in conn.queries[1:]: From 65bb0ac6bb1ddc198a89cf15dcc374dd66651e95 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 08:25:14 +0900 Subject: [PATCH 056/393] docs(changelog): keep one unreleased changed section --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 823515c43..d145e6986 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,8 +119,6 @@ All notable changes to this project are documented here. Format follows authorized leftover store as the period-report list; they do not invent a leftover score. -### Changed - - ADRs 0011 and 0065 now include APA 7th References for the dated W3C PROV-O and PROV-DM Recommendations (30 April 2013). Decisions are unchanged. From 4ce167d13a0efc99b578f012b39739d9d419f5f5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 16:31:47 -0700 Subject: [PATCH 057/393] refactor(measurement): consume Rust residual interaction map (#670) Co-authored-by: Codex --- AGENTS.md | 5 +- CHANGELOG.md | 6 + docs/adr/0048-persist-lsirm-leftover-pairs.md | 14 +- docs/adr/0148-leftover-map-axis-share.md | 10 +- ...-externalize-local-mathematical-compute.md | 11 +- ...hon-mathematical-compute-boundary-audit.md | 8 +- docs/product-technical-gap-baseline.md | 1 + lineageweave/leftover_pairs.py | 426 +++--------- lineageweave/period_report.py | 35 +- pyproject.toml | 2 +- tests/test_leftover_pairs.py | 650 ++---------------- uv.lock | 6 +- 12 files changed, 198 insertions(+), 976 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c927f9e61..0b9e33828 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -271,8 +271,9 @@ stops startup instead of leaving a healthy-looking partial schema, and application code must not compensate for a missing table. Period leftover pairs (ADR 0017 / 0018 / 0048 / 0049 / 0119 / 0158 / 0162 / -0163 / 0164 / 0182 / 0201) are computed in `lineageweave/leftover_pairs.py` from the -residual after a real GRM/GPCM score, never invented. Distances are +0163 / 0164 / 0182 / 0201 / 0208) consume fast-mlsirm's Rust-owned residual +interaction map after a real GRM/GPCM score, never invented. LineageWeave only +attaches product identifiers and selects returned cells. Distances are Euclidean on the two-dimensional Gabriel leftover map; missing cells stay out of the factorization. Closest and farthest post–criterion pairs persist to `report_leftover_pair` with signed residual `R`, observed diff --git a/CHANGELOG.md b/CHANGELOG.md index d145e6986..fbef5686e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project are documented here. Format follows ### Changed +- Period-report leftover maps now consume fast-mlsirm's protected Rust + residual-interaction and expected-response contracts. Local Python Gabriel + SVD, distance, reconstruction, share, expectation, and duplicate likelihood + arithmetic were removed; owner failures remain unavailable rather than + triggering a local substitute. + - Dashboard evidence-link hit areas now honor the shared minimum control-size token on touch layouts. - The external-information GNB keeps all authorized in-period posts as its coverage denominator while filtering the displayed case rows. diff --git a/docs/adr/0048-persist-lsirm-leftover-pairs.md b/docs/adr/0048-persist-lsirm-leftover-pairs.md index 613545db4..62d2a5e15 100644 --- a/docs/adr/0048-persist-lsirm-leftover-pairs.md +++ b/docs/adr/0048-persist-lsirm-leftover-pairs.md @@ -6,7 +6,8 @@ [ADR 0163](0163-leftover-observed-expected.md) (observed Y and expected E); [ADR 0164](0164-leftover-map-rank.md) (full map rank); [ADR 0182](0182-leftover-map-unexplained.md) (unexplained leftover U); -[ADR 0185](0185-leftover-map-cross-share.md) (leftover-map cross share) +[ADR 0185](0185-leftover-map-cross-share.md) (leftover-map cross share); and +[ADR 0208](0208-externalize-local-mathematical-compute.md) (Rust owner boundary) ## Context @@ -17,15 +18,15 @@ leftover interaction `−γ‖ξ_p − ζ_i‖` on the person–item map. Closes pairs are the smallest Euclidean leftover-map distances; farthest pairs are the largest. -`fast-mlsirm` implements the leftover term inside MLSRM fitting but -exposes no leftover-pair API. LineageWeave must not fork LSIRM or -invent a second IRT fit. Buyers still need a durable, clickable +`fast-mlsirm` now exposes the Rust-owned residual interaction-map contract. +LineageWeave must not fork its factorization or invent a second IRT fit. +Buyers still need a durable, clickable answer to “which post–criterion pair is unexpectedly aligned, and which pair is unexpectedly opposed?” ## Decision -After a real GRM/GPCM score, compute the residual matrix +After a real GRM/GPCM score, consume fast-mlsirm's Rust-computed residual matrix `R = Y − E[Y|θ, item]` from the already-fitted category probabilities. A Gabriel (1971) biplot of the **complete-case** submatrix of `R` supplies person positions `ξ` and item positions @@ -34,7 +35,8 @@ they are never filled with zero. Persist exactly one `closest` and one `farthest` observed cell per period report in `report_leftover_pair` (3NF, two-or-more-word `snake_case`). -tests do not import `period_report` or `fast_mlsirm`. Distances are +LineageWeave attaches product identifiers and selects the closest/farthest +returned cells; it does not reproduce the numerical formulas. Distances are Euclidean on the two leftover-map axes (ADR 0119). Each leftover row also names observed `Y` and expected `E[Y|θ, item]` so residual reconciles to `Y − E` (ADR 0163), and names the full singular-value diff --git a/docs/adr/0148-leftover-map-axis-share.md b/docs/adr/0148-leftover-map-axis-share.md index 2540ddf91..f99f1420f 100644 --- a/docs/adr/0148-leftover-map-axis-share.md +++ b/docs/adr/0148-leftover-map-axis-share.md @@ -16,9 +16,9 @@ share is a report-level property of the residual SVD, not a post-identifying leftover score and not a second theta. Denormalizing it onto each leftover pair would violate 3NF. -`fast-mlsirm` still exposes no leftover-pair or leftover-map API. -LineageWeave must not fork LSIRM or invent leftover numbers when the -residual is rank-0. +`fast-mlsirm` exposes the Rust-owned residual interaction-map API. +LineageWeave must consume its singular values and shares without reproducing +the factorization or inventing leftover numbers when the residual is rank-0. ## Decision @@ -36,8 +36,8 @@ Cascade the rows with `report_period_score`. Axes are aggregate and non-identifying: ABAC that hides leftover pairs does not hide axis share. Do not store a second theta. Do not invent leftover numbers. -The biplot lives in `lineageweave/leftover_pairs.py` so leftover tests -do not import `period_report` or `fast_mlsirm`. +The biplot lives in fast-mlsirm's Rust core. `leftover_pairs.py` only projects +the returned array indices onto authorized post and criterion identifiers. ## Consequences diff --git a/docs/adr/0208-externalize-local-mathematical-compute.md b/docs/adr/0208-externalize-local-mathematical-compute.md index 42a5a0591..31d5b13ac 100644 --- a/docs/adr/0208-externalize-local-mathematical-compute.md +++ b/docs/adr/0208-externalize-local-mathematical-compute.md @@ -54,7 +54,7 @@ different responsibility. Missing, malformed, non-converged, mixed-snapshot, or unsupported results fail closed. It never repairs, normalizes, estimates, or substitutes a numerical result. -5. **No big-bang rewrite.** Existing local computation is frozen as named +5. **No big-bang rewrite.** Remaining local computation is frozen as named migration debt in `docs/doctoring/python-mathematical-compute-boundary-audit.md`. Each owner contract lands and proves recovery/equivalence before the corresponding @@ -69,6 +69,14 @@ different responsibility. Operational bounds may remain only as disclosed resource limits and may not determine a scientific score or ground truth. +## Implemented migration slices + +- The residual interaction map consumes fast-mlsirm's protected-main + `residual_interaction_map` and `polytomous_expected_response` contracts. + Gabriel SVD, axis inertia, distance, reconstruction, unexplained residual, + cross share, and coverage arithmetic were deleted from LineageWeave Python. + Product-side identifier attachment and closest/farthest selection remain. + ## Stacked delivery order 1. Owner PRs publish versioned request/result schemas, model identity, @@ -113,4 +121,3 @@ https://doi.org/10.1007/s11336-021-09762-5 Roberts, M. E., Stewart, B. M., & Tingley, D. (2019). stm: An R package for structural topic models. *Journal of Statistical Software, 91*(2), 1–40. https://doi.org/10.18637/jss.v091.i02 - diff --git a/docs/doctoring/python-mathematical-compute-boundary-audit.md b/docs/doctoring/python-mathematical-compute-boundary-audit.md index f1dbeee83..74e129eec 100644 --- a/docs/doctoring/python-mathematical-compute-boundary-audit.md +++ b/docs/doctoring/python-mathematical-compute-boundary-audit.md @@ -3,8 +3,8 @@ **Exact-head audit date:** 2026-08-25 **Normative decision:** [ADR 0208](../adr/0208-externalize-local-mathematical-compute.md) -This inventory names migration debt; it is not evidence that the current -Python paths satisfy the Rust/GPU requirement. +This inventory names remaining migration debt and completed owner slices; it +does not relabel still-local Python paths as Rust/GPU compliant. ## Product-boundary sources read @@ -23,8 +23,8 @@ Python paths satisfy the Rust/GPU requirement. | Current LineageWeave path | Local computation | Owner | Consumer replacement | Principal callers / tests | |---|---|---|---|---| | `lineageweave/channel_weight_estimation.py` | dichotomization, synthetic simulation, MLS2PLM input construction, expected item information and normalization | fast-mlsirm, conditional on TEPP anchor | versioned anchored-weight artifact; strict digest/convergence validation | estimation scripts, seed/server/rebuild paths; `tests/test_channel_weight_estimation.py`, estimator-script tests | -| `lineageweave/period_report.py` | response matrix, GRM/GPCM fit/FIPC/EAP, likelihood, category expectation, information ordering | fast-mlsirm | period-measurement artifact with item bank, scores, uncertainty, diagnostics | report ingestion and demo seed; period-report and report API tests | -| `lineageweave/leftover_pairs.py` | residual matrix, complete-case selection, SVD/Gabriel coordinates, distances, reconstruction, axis shares | fast-mlsirm | residual-interaction artifact with observed/expected identity and coverage | `period_report.py`, report ingestion/seed; `tests/test_leftover_pairs.py`, report tests | +| `lineageweave/period_report.py` | response matrix and owner-call orchestration remain; local category expectation and duplicate likelihood arithmetic removed | fast-mlsirm | `polytomous_expected_response`; diagnostics-owned held-out log likelihood; full period artifact remains debt | report ingestion and demo seed; period-report and report API tests | +| `lineageweave/leftover_pairs.py` | **migrated:** identifier projection and closest/farthest selection only | fast-mlsirm | protected-main Rust `residual_interaction_map` with residual, coverage, SVD/Gabriel coordinates, distances, reconstruction and shares | `period_report.py`, report ingestion/seed; owner contract and consumer projection tests | | `lineageweave/embedding_client.py` and `backend/app/post_chat_ingestion.py` | cosine similarity, vector norms, maximum semantic score | RankWeave retrieval-score contract | ranked evidence envelope over ABAC-visible semantic units | reconstruction text channel and Global Ask retrieval; embedding/post-chat tests | | `lineageweave/knowledge_graph.py` | random walk with restart, convergence delta, adaptive relevance cutoff | RankWeave graph-ranking contract | ranked-node artifact with contribution and convergence evidence | related-person/entity API paths; knowledge-graph tests | | `lineageweave/reconstruct.py` | channel-weight renormalization, candidate-score fusion and minimum-score decision | RankWeave fusion; TEPP supplies independent lineage criterion | accepted edge-ranking artifact; LineageWeave persists selected edge and channel provenance | lineage rebuild/start/seed/server; reconstruct, persistence, API tests | diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 10889f57d..69074b9d0 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -438,6 +438,7 @@ this file per §3.5 of the prior snapshot). | SKOS organization aliases | Catalog binding and chip caption live on #480 / #482 | One catalog row per corroborated org; companion caption is hint-only until bound | | Event Lineage evidence | Channel evidence and Allen relations live on #387 / #484 | Persist channel scores, explain them in the popup, never invent a fused score | | Scientific measurement | Durable accepted TEPP receipts and LineageWeave #614's exact accepted snapshot/cutoff/run/pair-count consumer are protected; TEPP #237 remains open, so no registered producer artifact exists yet. #387 removes inferred/default persistence weights, but several older reconstruction tests still pass hand-authored numeric dictionaries that are not estimator evidence | Land TEPP #237 through its protected gate, then replace remaining reconstruction-test constants with provenance-bearing fast-mlsirm estimates over synthetic fixtures. Retain true-parameter RMSE recovery as the acceptance bar | +| Python mathematical-compute boundary | ADR 0208's first deletion slice consumes fast-mlsirm protected-main Rust `residual_interaction_map` and `polytomous_expected_response`; local Gabriel SVD, distance, reconstruction, shares, expected-category and duplicate likelihood formulas are deleted. The doctoring inventory still names period calibration, channel weighting, cosine, graph ranking, and fusion debt | Land this stacked consumer through protected gates, then migrate each remaining construct to its owning repository contract and require an empty transition inventory before claiming the boundary complete | | Asynchronous authorization | Protected `main` rebuilds Global Ask worker scope after the bearer token leaves the request; #468 now persists exact Keyverse organization/process-unit scope in 3NF child tables and intersects it with current affiliations | Land #468 through the protected gate; prove a second affiliation and a revoked process unit cannot widen delayed-job evidence | | Planned-facility intent | Planned-facility relationship intent remains only on closed, unmerged #490; earlier stack-only merges were not protected delivery | Recreate the evidence-backed slice on a current base and land through protected `main` before a release claim | | Accessibility and responsive UX | #602 delivered base post-detail modal semantics; #605 adds selected-post refocus, collapsed/hidden/inert/CSS-invisible focus exclusion across both modal types, readable evidence separators, focused tests, and desktop/mobile Storybook screenshots | Land #605 through the protected gate, then complete screen-reader and authenticated Playwright acceptance on the exact release head | diff --git a/lineageweave/leftover_pairs.py b/lineageweave/leftover_pairs.py index 070416fe2..87a563bc1 100644 --- a/lineageweave/leftover_pairs.py +++ b/lineageweave/leftover_pairs.py @@ -1,34 +1,8 @@ -"""Jeon leftover post–criterion pairs after a main-effect IRT. +"""Project Rust-owned residual interaction maps into LineageWeave rows. -Implements ADR 0048 as amended by ADR 0119, ADR 0163, ADR 0164, ADR 0182, -and ADR 0185. - -Does not import ``fast_mlsirm`` or ``period_report``. A Gabriel biplot -of the residual ``R = Y − E[Y|θ, item]`` supplies person and item -positions. Missing response cells are excluded from the factorization; -they are never treated as zero residuals. Each pair names observed -``Y`` and expected ``E`` so residual always reconciles to ``Y − E``. -Pair distances are Euclidean on the two leftover-map axes (Jeon et al., -2021); unused axes pad with zero rather than inventing a second -component, and hidden SVD axes after the second are dropped. Each pair -also names the full leftover-map rank so a rank-0 collapse is not read -as leftover structure. Axis share is the Gabriel inertia of the first -two leftover-map axes (ADR 0148). Complete-case coverage (ADR 0168) -names how many scored posts entered that rectangle; without a -complete-case rectangle there is no leftover pair to name, and the -report carries coverage counts instead of a center-distance stand-in -pair. Each pair also names unexplained leftover ``U = R − R̂`` after -two-axis Gabriel reconstruction ``R̂ = ξ_{1:2} · ζ_{1:2}`` so the -leftover cell the map does not reconstruct is not confused with -leftover residual ``R`` or leftover-map distance ``d``. Each pair -further names leftover-map cross share ``x = 2 R̂ U / R²`` of the raw -residual after that same truncated two-axis reconstruction, -so the identity remainder left by the truncation is not confused with -leftover residual ``R``, leftover-map distance ``d``, or unexplained -leftover ``U``. Explained leftover share ``e = R̂² / R²`` and -unexplained leftover share ``s = U² / R²`` are not persisted. Signed -reconstruction ``R̂`` is persisted so ``U + R̂ = R`` stays auditable. ``x`` -may be negative when reconstruction and unexplained leftover have opposite signs. +The numerical factorization belongs to :mod:`fast_mlsirm`. This module owns +only product identifiers, closest/farthest selection, and persistence-shaped +records (ADR 0208). """ from __future__ import annotations @@ -36,17 +10,16 @@ from dataclasses import dataclass import numpy as np +from fast_mlsirm import residual_interaction_map PAIR_KIND_CLOSEST = "closest" PAIR_KIND_FARTHEST = "farthest" -_LEFTOVER_SINGULAR_FLOOR = 1e-12 -_RESIDUAL_RECONCILE_TOLERANCE = 1e-6 _LEFTOVER_MAP_AXES = 2 @dataclass(frozen=True) class LeftoverPair: - """One post–criterion pair on the leftover interaction map.""" + """One post-criterion pair on the leftover interaction map.""" pair_kind: str post_id: str @@ -88,25 +61,8 @@ def leftover_pairs_from_residual( matrix: np.ndarray, expected: np.ndarray, ) -> tuple[LeftoverPair, ...]: - """Closest and farthest leftover-map pairs from residual SVD biplot. + """Return closest and farthest rows from the Rust-owned interaction map.""" - Jeon et al. (2021) leftover interaction is ``−γ‖ξ_p − ζ_i‖``. This - estimator places persons and items from the residual after IRT main - effects (Gabriel, 1971). Only observed cells become pairs. Distances - use the two leftover-map axes; a rank-0 residual still emits a - stable closest/farthest pair so seed is not empty and does not - invent a leftover score. Stored residual equals observed ``Y`` minus - expected ``E[Y|θ, item]``. Stored leftover-map rank is the number - of Gabriel singular values above the floor. When Gabriel coordinates - exist, unexplained leftover ``U = R − R̂`` names the leftover cell - the two-axis map does not reconstruct, and leftover-map cross share - ``x = 2 R̂ U / R²`` names the identity remainder of raw residual - ``R`` after two-axis reconstruction ``R̂ = ξ_{1:2} · ζ_{1:2}`` and - unexplained leftover ``U = R − R̂``. Signed ``R̂`` is persisted with - ``U`` so their raw-residual identity stays auditable. Without a complete-case map there is no pair - to name (ADR 0168); the caller reads coverage counts instead of a - center-distance stand-in pair. - """ pairs, _axes = leftover_map_from_residual(post_ids, item_codes, matrix, expected) return pairs @@ -117,331 +73,109 @@ def leftover_map_from_residual( matrix: np.ndarray, expected: np.ndarray, ) -> tuple[tuple[LeftoverPair, ...], tuple[LeftoverMapAxis, ...]]: - """Leftover pairs plus the first two Gabriel leftover-map axis shares. - - Axis share is ``σ_k² / Σ_j σ_j²`` for leftover-map axes 1 and 2. - Rank-0 residuals emit two zero-share axes so seed can name leftover-map - structure without inventing a leftover score. - """ - if matrix.shape != (len(post_ids), len(item_codes)): - raise ValueError( - f"matrix shape {matrix.shape} does not match {len(post_ids)} posts × {len(item_codes)} items" + """Project a Rust-owned two-axis Gabriel map into report records.""" + + _validate_shapes(post_ids, item_codes, matrix, expected) + result = residual_interaction_map(matrix, expected, axis_count=_LEFTOVER_MAP_AXES) + axes = tuple( + LeftoverMapAxis( + axis_index=index + 1, + leftover_singular_value=( + float(result.singular_values[index]) + if index < len(result.singular_values) + else 0.0 + ), + leftover_share=float(result.axis_shares[index]), ) - if expected.shape != matrix.shape: - raise ValueError(f"expected shape {expected.shape} does not match matrix {matrix.shape}") - - residual = matrix.astype(np.float64) - expected.astype(np.float64) - observed_mask = (~np.isnan(matrix)) & np.isfinite(residual) & np.isfinite(expected) - observed: list[tuple[int, int]] = [ - (person, item) - for person in range(matrix.shape[0]) - for item in range(matrix.shape[1]) - if observed_mask[person, item] - ] - if not observed: + for index in range(_LEFTOVER_MAP_AXES) + ) + if not len(result.person_indices) or not len(result.item_indices): return (), () - keep_person, keep_item = _complete_case_masks(observed_mask) - person_index = np.flatnonzero(keep_person) - item_index = np.flatnonzero(keep_item) - if person_index.size > 0 and item_index.size > 0: - center = float(np.mean(residual[np.ix_(person_index, item_index)])) - else: - center = float(np.mean([residual[person, item] for person, item in observed])) - person_pos, item_pos, singular = _complete_case_positions( - residual, center, keep_person, keep_item - ) - axes = leftover_map_axes_from_singular(singular) - leftover_map_rank = int(singular.size) - candidates: list[ - tuple[ - float, str, str, float, float, float, - float | None, float | None, float | None, - ] - ] = [] - if person_pos is not None and item_pos is not None: - person_index = np.flatnonzero(keep_person) - item_index = np.flatnonzero(keep_item) - person_xy = _pad_map_axes(person_pos) - item_xy = _pad_map_axes(item_pos) - local_person = {int(person): local for local, person in enumerate(person_index)} - local_item = {int(item): local for local, item in enumerate(item_index)} - for person, item in observed: - if person not in local_person or item not in local_item: - continue - distance = float( - np.linalg.norm(person_xy[local_person[person]] - item_xy[local_item[item]]) - ) - if not np.isfinite(distance): - continue - reconstruction = float( - np.dot(person_xy[local_person[person]], item_xy[local_item[item]]) - ) - residual_cell = float(residual[person, item]) - unexplained = _unexplained_leftover(residual_cell, reconstruction) - share = _leftover_map_cross_share(residual_cell, reconstruction) + rank = int(len(result.singular_values)) + candidates: list[tuple[float, str, str, float, float, float, float, float | None, float]] = [] + for local_person, person in enumerate(result.person_indices): + for local_item, item in enumerate(result.item_indices): + observed = float(matrix[int(person), int(item)]) + model_expected = float(expected[int(person), int(item)]) + residual = float(result.residual[local_person, local_item]) + cross_share = float(result.cross_share[local_person, local_item]) candidates.append( - _candidate_row( - post_ids, - item_codes, - matrix, - expected, + ( + float(result.distance[local_person, local_item]), + post_ids[int(person)], + item_codes[int(item)], residual, - person, - item, - distance, - unexplained, - share, - reconstruction if np.isfinite(reconstruction) else None, + observed, + model_expected, + float(result.unexplained[local_person, local_item]), + cross_share if np.isfinite(cross_share) else None, + float(result.reconstruction[local_person, local_item]), ) ) - if not candidates: - # ADR 0168: without a complete-case Gabriel map there is no - # leftover pair to name. The report carries coverage counts - # instead of a center-distance stand-in pair. - return (), () closest = min(candidates, key=lambda row: (row[0], row[1], row[2])) farthest = max(candidates, key=lambda row: (row[0], row[1], row[2])) - pairs = ( - _pair_from_candidate(PAIR_KIND_CLOSEST, closest, leftover_map_rank), - _pair_from_candidate(PAIR_KIND_FARTHEST, farthest, leftover_map_rank), + return ( + (_pair(PAIR_KIND_CLOSEST, closest, rank), _pair(PAIR_KIND_FARTHEST, farthest, rank)), + axes, ) - return pairs, axes - - -def _unexplained_leftover(residual: float, reconstruction: float) -> float | None: - """Return ``U = R − R̂`` when both terms are finite; otherwise omit.""" - if not np.isfinite(reconstruction): - return None - unexplained = residual - reconstruction - if not np.isfinite(unexplained): - return None - return float(unexplained) - - -def _leftover_map_cross_share(residual: float, reconstruction: float) -> float | None: - """Return ``x = 2 R̂ U / R²`` when both terms are finite; otherwise omit. - Unexplained leftover ``U = R − R̂`` is computed internally. - Truncated two-axis reconstruction of a higher-rank cell keeps a - cross term ``2 R̂ U``, so per-cell ``e + s ≠ 1``. The identity - remainder ``x`` names that cross term as a share of raw residual. - ``x`` may be negative when reconstruction and unexplained - leftover have opposite signs; a negative finite share is stored, - not omitted. - """ - if not np.isfinite(residual) or not np.isfinite(reconstruction): - return None - unexplained = float(residual - reconstruction) - # Threshold on absolute magnitudes, not squares: squaring first makes the - # effective floor sqrt(1e-12) = 1e-6 and collapses small-but-finite cells - # (e.g. R = 1e-7 with a valid cross term) to an omitted badge. - if abs(residual) > _LEFTOVER_SINGULAR_FLOOR: - share = float(2.0 * reconstruction * unexplained / (residual * residual)) - return share if np.isfinite(share) else None - if abs(reconstruction) <= _LEFTOVER_SINGULAR_FLOOR and abs(unexplained) <= _LEFTOVER_SINGULAR_FLOOR: - return 0.0 - return None - -def _candidate_row( +def leftover_map_coverage_from_residual( post_ids: list[str], item_codes: tuple[str, ...], matrix: np.ndarray, expected: np.ndarray, - residual: np.ndarray, - person: int, - item: int, - distance: float, - leftover_map_unexplained: float | None, - leftover_map_cross_share: float | None, - leftover_map_reconstruction: float | None, -) -> tuple[ - float, str, str, float, float, float, - float | None, float | None, float | None, -]: - """One observed cell: distance, ids, residual, Y, E, U, cross share, R̂.""" - leftover_residual = float(residual[person, item]) - observed_response = float(matrix[person, item]) - expected_response = float(expected[person, item]) - if abs(leftover_residual - (observed_response - expected_response)) >= _RESIDUAL_RECONCILE_TOLERANCE: - raise ValueError("leftover residual must equal observed Y minus expected E") - return ( - max(distance, 0.0), - post_ids[person], - item_codes[item], - leftover_residual, - observed_response, - expected_response, - leftover_map_unexplained, - leftover_map_cross_share, - leftover_map_reconstruction, - ) - +) -> LeftoverMapCoverage: + """Project Rust-owned complete-case coverage into one report record.""" -def _pair_from_candidate( - pair_kind: str, - row: tuple[ - float, str, str, float, float, float, - float | None, float | None, float | None, - ], - leftover_map_rank: int, -) -> LeftoverPair: - """Build a leftover pair from a candidate row.""" - if leftover_map_rank < 0: - raise ValueError("leftover map rank must be a non-negative integer") - return LeftoverPair( - pair_kind=pair_kind, - post_id=row[1], - criterion_code=row[2], - leftover_distance=row[0], - leftover_residual=row[3], - observed_response=row[4], - expected_response=row[5], - leftover_map_rank=leftover_map_rank, - leftover_map_unexplained=row[6], - leftover_map_cross_share=row[7], - leftover_map_reconstruction=row[8], + _validate_shapes(post_ids, item_codes, matrix, expected) + result = residual_interaction_map(matrix, expected, axis_count=_LEFTOVER_MAP_AXES) + map_posts = int(len(result.person_indices)) + map_items = int(len(result.item_indices)) + return LeftoverMapCoverage( + map_post_count=map_posts, + scored_post_count=int(result.scored_person_count), + map_item_count=map_items, + scored_item_count=int(result.scored_item_count), + incomplete_post_count=int(result.scored_person_count) - map_posts, + incomplete_item_count=int(result.scored_item_count) - map_items, ) -def leftover_map_axes_from_singular(singular: np.ndarray) -> tuple[LeftoverMapAxis, ...]: - """Gabriel axis inertia for leftover-map axes 1 and 2. - - Share is ``σ_k² / Σ_j σ_j²``. Values at or below the leftover - singular floor do not enter the denominator. Rank-0 residuals emit - two zero-share axes. - """ - values = np.asarray(singular, dtype=np.float64).reshape(-1) - kept = values[np.isfinite(values) & (values > _LEFTOVER_SINGULAR_FLOOR)] - total = float(np.sum(kept * kept)) if kept.size else 0.0 - axes: list[LeftoverMapAxis] = [] - for index in (1, 2): - value = float(kept[index - 1]) if kept.size >= index else 0.0 - if not np.isfinite(value) or value < 0.0: - value = 0.0 - share = (value * value / total) if total > 0.0 else 0.0 - axes.append( - LeftoverMapAxis( - axis_index=index, - leftover_singular_value=value, - leftover_share=max(share, 0.0), - ) - ) - return tuple(axes) - - -def leftover_map_coverage_from_residual( +def _validate_shapes( post_ids: list[str], item_codes: tuple[str, ...], matrix: np.ndarray, expected: np.ndarray, -) -> LeftoverMapCoverage: - """Name how many scored posts entered the complete-case leftover map. +) -> None: + """Reject identifier and matrix shapes that cannot be projected safely.""" - Gabriel (1971) factorizes the complete-case residual rectangle. - Missing cells stay out of that rectangle; they are never filled - with zero. ``map_post_count`` is the number of posts that entered - the factorization. ``scored_post_count`` is posts with at least - one observed cell. Incomplete rows are excluded, never zeroed. - """ if matrix.shape != (len(post_ids), len(item_codes)): raise ValueError( - f"matrix shape {matrix.shape} does not match {len(post_ids)} posts × {len(item_codes)} items" + f"matrix shape {matrix.shape} does not match {len(post_ids)} posts x {len(item_codes)} items" ) if expected.shape != matrix.shape: raise ValueError(f"expected shape {expected.shape} does not match matrix {matrix.shape}") - residual = matrix.astype(np.float64) - expected.astype(np.float64) - # Identical mask to leftover_map_from_residual's: a cell the pair map - # scores must be exactly a cell coverage counts, so map_post_count and - # the caption can never drift apart if expected ever goes non-finite. - observed_mask = (~np.isnan(matrix)) & np.isfinite(residual) & np.isfinite(expected) - scored_post_count = int(observed_mask.any(axis=1).sum()) - scored_item_count = int(observed_mask.any(axis=0).sum()) - keep_person, keep_item = _complete_case_masks(observed_mask) - map_post_count = int(keep_person.sum()) - map_item_count = int(keep_item.sum()) - return LeftoverMapCoverage( - map_post_count=map_post_count, - scored_post_count=scored_post_count, - map_item_count=map_item_count, - scored_item_count=scored_item_count, - incomplete_post_count=scored_post_count - map_post_count, - incomplete_item_count=scored_item_count - map_item_count, - ) - - -def _complete_case_masks(observed: np.ndarray) -> tuple[np.ndarray, np.ndarray]: - """Drop incomplete rows, then incomplete columns among remaining rows.""" - keep_person = observed.any(axis=1) - keep_item = observed.any(axis=0) - if np.any(keep_item): - keep_person = keep_person & observed[:, keep_item].all(axis=1) - if np.any(keep_person): - keep_item = keep_item & observed[keep_person, :].all(axis=0) - else: - keep_item = np.zeros_like(keep_item) - return keep_person, keep_item - - -def _complete_case_positions( - residual: np.ndarray, - center: float, - keep_person: np.ndarray, - keep_item: np.ndarray, -) -> tuple[np.ndarray | None, np.ndarray | None, np.ndarray]: - """Gabriel coordinates on the complete-case residual rectangle only.""" - person_index = np.flatnonzero(keep_person) - item_index = np.flatnonzero(keep_item) - empty_singular = np.zeros(0, dtype=np.float64) - if person_index.size == 0 or item_index.size == 0: - return None, None, empty_singular - filled = residual[np.ix_(person_index, item_index)] - center - return _leftover_map_positions(filled) - - -def _leftover_map_positions( - filled: np.ndarray, -) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - """Gabriel coordinates ordered by descending singular value. - NumPy's SVD contract returns singular values largest-first, so filtering - by the numerical floor preserves a prefix and the first two columns remain - the two leading leftover-map axes. Rank-0 residuals collapse to the origin. - """ - n_persons, n_items = filled.shape - empty_singular = np.zeros(0, dtype=np.float64) - if n_persons == 0 or n_items == 0 or not np.any(np.abs(filled) > _LEFTOVER_SINGULAR_FLOOR): - return ( - np.zeros((n_persons, 1), dtype=np.float64), - np.zeros((n_items, 1), dtype=np.float64), - empty_singular, - ) - left, singular, right = np.linalg.svd(filled, full_matrices=False) - keep = singular > _LEFTOVER_SINGULAR_FLOOR - if not np.any(keep): - return ( - np.zeros((n_persons, 1), dtype=np.float64), - np.zeros((n_items, 1), dtype=np.float64), - empty_singular, - ) - scale = np.sqrt(singular[keep]) - person_pos = left[:, keep] * scale - item_pos = right[keep, :].T * scale - return person_pos, item_pos, singular[keep] - - -def _pad_map_axes(positions: np.ndarray) -> np.ndarray: - """Pad or truncate Gabriel coordinates to two leftover-map axes. +def _pair( + kind: str, + row: tuple[float, str, str, float, float, float, float, float | None, float], + rank: int, +) -> LeftoverPair: + """Attach product identifiers to one Rust-computed candidate cell.""" - Unused axes pad with zero rather than inventing a second component. - Hidden SVD axes after the second are dropped so reconstruction is - ``ξ_{1:2} · ζ_{1:2}``, not the full-rank inner product. That - reconstruction is persisted with unexplained leftover and cross share so - the raw-residual identity remains auditable. - """ - padded = np.zeros((positions.shape[0], _LEFTOVER_MAP_AXES), dtype=np.float64) - width = min(_LEFTOVER_MAP_AXES, positions.shape[1]) - padded[:, :width] = positions[:, :width] - return padded + return LeftoverPair( + pair_kind=kind, + post_id=row[1], + criterion_code=row[2], + leftover_distance=row[0], + leftover_residual=row[3], + observed_response=row[4], + expected_response=row[5], + leftover_map_rank=rank, + leftover_map_unexplained=row[6], + leftover_map_cross_share=row[7], + leftover_map_reconstruction=row[8], + ) diff --git a/lineageweave/period_report.py b/lineageweave/period_report.py index 5e3244c1c..09dc6f838 100644 --- a/lineageweave/period_report.py +++ b/lineageweave/period_report.py @@ -24,8 +24,8 @@ share is Gabriel inertia ``σ_k² / Σ_j σ_j²`` of residual SVD axes 1 and 2 (ADR 0148). Complete-case coverage (ADR 0168) names how many scored posts entered the factorization; incomplete rows are excluded, -never filled with zero. ``fast-mlsirm`` has no leftover-pair API; this -module does not invent a second IRT fit and does not fork LSIRM. +never filled with zero. ``fast-mlsirm.residual_interaction_map`` owns that +arithmetic in Rust; this module only attaches product identifiers. This module is pure compute. Persistence lives in ``backend/app/report_ingestion.py``. TEPP is not used here; temporal @@ -43,6 +43,7 @@ fixed_item_calibration_diagnostics, information_polytomous, polytomous_category_probabilities, + polytomous_expected_response, score_polytomous, validate_irt_response_matrix, ) @@ -185,28 +186,6 @@ def item_bank_from_fit(fit: PolytomousFit, item_codes: tuple[str, ...], source_p ) -def observed_response_loglik(matrix: np.ndarray, probs: np.ndarray) -> float: - """Sum log P(y_ij) over observed cells; missing cells are skipped.""" - loglik = 0.0 - n_persons, n_items = matrix.shape - for person in range(n_persons): - for item in range(n_items): - category = matrix[person, item] - if np.isnan(category): - continue - index = int(category) - loglik += float(np.log(max(probs[person, item, index], 1e-12))) - return loglik - - -def expected_category_matrix(matrix: np.ndarray, probs: np.ndarray) -> np.ndarray: - """E[Y_pi] = sum_k k P(Y=k | θ_p, item_i); missing cells stay NaN.""" - n_categories = probs.shape[2] - categories = np.arange(n_categories, dtype=np.float64) - expected = np.tensordot(probs, categories, axes=([2], [0])) - return np.where(np.isnan(matrix), np.nan, expected) - - def leftover_map_for_fit( post_ids: list[str], item_codes: tuple[str, ...], @@ -216,8 +195,7 @@ def leftover_map_for_fit( fit: PolytomousFit, ) -> tuple[tuple[LeftoverPair, ...], tuple[LeftoverMapAxis, ...]]: """Leftover pairs and leftover-map axis share from fitted GRM/GPCM.""" - probs = _category_probabilities(model, theta, fit) - expected = expected_category_matrix(matrix, probs) + expected = polytomous_expected_response(fit, theta) return leftover_map_from_residual(post_ids, item_codes, matrix, expected) @@ -243,8 +221,7 @@ def leftover_map_coverage_for_fit( fit: PolytomousFit, ) -> LeftoverMapCoverage: """Complete-case leftover-map coverage from the fitted main effects.""" - probs = _category_probabilities(model, theta, fit) - expected = expected_category_matrix(matrix, probs) + expected = polytomous_expected_response(fit, theta) return leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected) @@ -368,7 +345,7 @@ def score_period_on_bank( mean_theta_sd=float(theta.std(ddof=0)), post_count=len(post_ids), item_count=len(item_bank.item_codes), - fit_loglik=observed_response_loglik(matrix, probs), + fit_loglik=float(diagnostics.best["heldout_loglik"]), fit_converged=True, calibration_score=float(diagnostics.best["calibration_score"]), member_scores=_member_scores(post_ids, scores), diff --git a/pyproject.toml b/pyproject.toml index bc85fb6d0..091ac2ce6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ backend = [ # yet; pinned to a specific commit, same pattern as rankweave. Ships a # PyO3/maturin Rust core with no fallback wheel, so building this from # source needs a Rust toolchain -- see backend/Dockerfile. - "fast-mlsirm @ git+https://github.com/ContextualWisdomLab/fast-mlsirm.git@d025b7d237d8db7ca97a5611606c6285d5870895", + "fast-mlsirm @ git+https://github.com/ContextualWisdomLab/fast-mlsirm.git@256470c7d1df4910a018841499a74d88b751a774", ] [tool.setuptools.packages.find] diff --git a/tests/test_leftover_pairs.py b/tests/test_leftover_pairs.py index a1080e172..dfd0eec61 100644 --- a/tests/test_leftover_pairs.py +++ b/tests/test_leftover_pairs.py @@ -1,615 +1,109 @@ -"""Leftover post–criterion pairs after the main-effect IRT. - -Covers ADR 0048 as amended by ADR 0119, ADR 0148, ADR 0163, ADR 0164, -ADR 0182, and ADR 0185. - -Uses a constructed residual matrix so the closest and farthest pair -are known without calling ``fit_polytomous``. Loads -``leftover_pairs.py`` by path so package ``__init__`` / ``period_report`` -/ ``fast_mlsirm`` stay out of this module. -""" +"""Consumer tests for the Rust-owned residual interaction-map boundary.""" from __future__ import annotations -import ast -import importlib.util -import sys -from pathlib import Path +import inspect import numpy as np import pytest -_LEFTOVER_PATH = Path(__file__).resolve().parents[1] / "lineageweave" / "leftover_pairs.py" -_LEFTOVER_SINGULAR_FLOOR = 1e-12 -_LEFTOVER_MAP_AXES = 2 - - -def _load_leftover(): - """Load only the dependency-light leftover module under test.""" - source = _LEFTOVER_PATH.read_text(encoding="utf-8") - imported = [] - for node in ast.parse(source).body: - if isinstance(node, ast.Import): - imported.extend(alias.name.split(".", 1)[0] for alias in node.names) - elif isinstance(node, ast.ImportFrom): - imported.append((node.module or "").split(".", 1)[0]) - assert "fast_mlsirm" not in imported - assert "period_report" not in imported - spec = importlib.util.spec_from_file_location("lineageweave_leftover_pairs", _LEFTOVER_PATH) - 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 - - -leftover = _load_leftover() -PAIR_KIND_CLOSEST = leftover.PAIR_KIND_CLOSEST -PAIR_KIND_FARTHEST = leftover.PAIR_KIND_FARTHEST -leftover_pairs_from_residual = leftover.leftover_pairs_from_residual -leftover_map_from_residual = leftover.leftover_map_from_residual -leftover_map_axes_from_singular = leftover.leftover_map_axes_from_singular -leftover_map_coverage_from_residual = leftover.leftover_map_coverage_from_residual - - -def _assert_residual_reconciles(pair) -> None: - """Assert the persisted residual is exactly grounded in named Y and E.""" - assert pair.leftover_residual == pytest.approx( - pair.observed_response - pair.expected_response, abs=1e-6 - ) - - -def _assert_never_persists_hidden_shares(pair) -> None: - """The cross-share/reconstruction path never persists unsupported shares.""" - assert not hasattr(pair, "leftover_map_explained_share") - assert not hasattr(pair, "leftover_map_unexplained_share") - - -def _gabriel_positions(filled: np.ndarray) -> tuple[np.ndarray, np.ndarray]: - """Independent Gabriel coordinates used to prove leftover_distance axes.""" - left, singular, right = np.linalg.svd(filled, full_matrices=False) - keep = singular > _LEFTOVER_SINGULAR_FLOOR - scale = np.sqrt(singular[keep]) - return left[:, keep] * scale, right[keep, :].T * scale - - -def _pad_map_axes(positions: np.ndarray) -> np.ndarray: - """Independently pad or truncate coordinates to two map axes.""" - padded = np.zeros((positions.shape[0], _LEFTOVER_MAP_AXES), dtype=np.float64) - width = min(_LEFTOVER_MAP_AXES, positions.shape[1]) - padded[:, :width] = positions[:, :width] - return padded - - -def test_leftover_residual_biplot_separates_aligned_and_opposed_cells() -> None: - """A rank-1 leftover spike puts the aligned cell closest and the opposed cell farthest.""" - post_ids = ["post-a", "post-b", "post-c"] - item_codes = ("item_near", "item_mid", "item_far") - matrix = np.array( - [ - [2.0, 0.0, -2.0], - [0.0, 0.0, 0.0], - [-2.0, 0.0, 2.0], - ], - dtype=np.float64, - ) - expected = np.zeros_like(matrix) - pairs = leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) - assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST] - closest, farthest = pairs - assert closest.leftover_distance < farthest.leftover_distance - assert closest.leftover_distance == pytest.approx(0.0, abs=1e-9) - assert (farthest.post_id, farthest.criterion_code) in { - ("post-a", "item_far"), - ("post-c", "item_near"), - } - assert farthest.leftover_residual == pytest.approx(-2.0) - assert farthest.observed_response == pytest.approx(-2.0) - assert farthest.expected_response == pytest.approx(0.0) - assert farthest.leftover_distance == pytest.approx(2.0 * np.sqrt(2.0), rel=1e-6) - assert closest.leftover_map_unexplained == pytest.approx(0.0, abs=1e-6) - assert farthest.leftover_map_unexplained == pytest.approx(0.0, abs=1e-6) - # Closest is the origin cell (R = 0, R̂ = 0, U = 0): 0/0 stores 0. - assert closest.leftover_map_cross_share == pytest.approx(0.0, abs=1e-6) - # Rank-1 reconstructed opposed cell: U = 0 so x = 0. - assert farthest.leftover_map_cross_share == pytest.approx(0.0, abs=1e-6) - assert closest.leftover_map_reconstruction == pytest.approx(0.0, abs=1e-6) - assert farthest.leftover_map_reconstruction == pytest.approx(-2.0, abs=1e-6) - for pair in pairs: - _assert_residual_reconciles(pair) - _assert_never_persists_hidden_shares(pair) - assert pair.leftover_map_rank == 1 - coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected) - assert coverage.map_post_count == 3 - assert coverage.scored_post_count == 3 - assert coverage.incomplete_post_count == 0 - assert coverage.map_item_count == 3 - assert coverage.scored_item_count == 3 - +from lineageweave import leftover_pairs as leftover -def test_zero_residual_still_emits_stable_leftover_pairs() -> None: - """A rank-zero map retains deterministic closest and farthest rows.""" - post_ids = ["alpha-post", "beta-post"] - item_codes = ("item_one", "item_two") - matrix = np.ones((2, 2), dtype=np.float64) - expected = np.ones((2, 2), dtype=np.float64) - pairs = leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) - assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST] - assert pairs[0].leftover_distance == pytest.approx(0.0) - assert pairs[1].leftover_distance == pytest.approx(0.0) - assert pairs[0].post_id == "alpha-post" - assert pairs[0].criterion_code == "item_one" - assert pairs[0].observed_response == pytest.approx(1.0) - assert pairs[0].expected_response == pytest.approx(1.0) - assert pairs[1].post_id == "beta-post" - assert pairs[1].criterion_code == "item_two" - assert pairs[0].leftover_map_unexplained == pytest.approx(0.0) - assert pairs[1].leftover_map_unexplained == pytest.approx(0.0) - assert pairs[0].leftover_map_cross_share == pytest.approx(0.0) - assert pairs[1].leftover_map_cross_share == pytest.approx(0.0) - assert pairs[0].leftover_map_reconstruction == pytest.approx(0.0) - assert pairs[1].leftover_map_reconstruction == pytest.approx(0.0) - for pair in pairs: - _assert_residual_reconciles(pair) - assert pair.leftover_map_rank == 0 - coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected) - assert coverage.map_post_count == 2 - assert coverage.scored_post_count == 2 - assert coverage.incomplete_post_count == 0 - - -def test_rank_zero_nonzero_constant_residual_keeps_raw_identity() -> None: - """Centering a constant nonzero residual gives R̂=0 while U remains R.""" - matrix = np.ones((2, 2), dtype=np.float64) - pairs = leftover_pairs_from_residual( - ["post-a", "post-b"], - ("item-a", "item-b"), - matrix, - np.zeros_like(matrix), - ) - assert pairs - for pair in pairs: - assert pair.leftover_map_rank == 0 - assert pair.leftover_residual == pytest.approx(1.0) - assert pair.leftover_map_reconstruction == pytest.approx(0.0) - assert pair.leftover_map_unexplained == pytest.approx(1.0) - assert pair.leftover_map_unexplained + pair.leftover_map_reconstruction == pytest.approx( - pair.leftover_residual - ) +def test_rust_map_projects_pairs_axes_and_coverage() -> None: + """Rust evidence is attached to stable product identifiers without recomputation.""" - -def test_partial_observation_does_not_treat_missing_as_zero_residual() -> None: - """A missing cell must not enter the Gabriel factorization as 0.""" - post_ids = ["aligned-post", "opposed-post", "sparse-post"] - item_codes = ("item_near", "item_far") - matrix = np.array( - [ - [2.0, -2.0], - [-2.0, 2.0], - [2.0, np.nan], - ], - dtype=np.float64, - ) + matrix = np.array([[2.0, -2.0], [-2.0, 2.0], [2.0, np.nan]]) expected = np.zeros_like(matrix) - pairs = leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) - assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST] - closest, farthest = pairs - assert {pair.post_id for pair in pairs} <= {"aligned-post", "opposed-post"} - assert closest.leftover_distance == pytest.approx(0.0, abs=1e-9) - assert farthest.leftover_distance == pytest.approx(2.0 * np.sqrt(2.0), rel=1e-6) - assert farthest.leftover_residual == pytest.approx(-2.0) - assert (farthest.post_id, farthest.criterion_code) in { - ("aligned-post", "item_far"), - ("opposed-post", "item_near"), - } - for pair in pairs: - _assert_residual_reconciles(pair) - assert pair.leftover_map_rank == 1 - assert pair.leftover_map_unexplained == pytest.approx(0.0, abs=1e-6) - assert pair.leftover_map_cross_share == pytest.approx(0.0, abs=1e-6) - coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected) - assert coverage.map_post_count == 2 - assert coverage.scored_post_count == 3 - assert coverage.incomplete_post_count == 1 - assert coverage.map_item_count == 2 - assert coverage.scored_item_count == 2 - assert coverage.incomplete_item_count == 0 - - -def test_leftover_is_empty_without_observed_cells() -> None: - """An entirely missing response matrix yields no invented pair.""" - post_ids = ["post-empty"] - item_codes = ("item_one",) - matrix = np.array([[np.nan]], dtype=np.float64) - expected = np.array([[0.0]], dtype=np.float64) - assert leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) == () - coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected) - assert coverage.map_post_count == 0 - assert coverage.scored_post_count == 0 - assert coverage.incomplete_post_count == 0 - assert coverage.map_item_count == 0 - assert coverage.scored_item_count == 0 - assert coverage.incomplete_item_count == 0 + post_ids = ["post-a", "post-b", "post-sparse"] + item_codes = ("item-a", "item-b") - -def test_leftover_residual_equals_observed_minus_expected() -> None: - """Named Y and E on leftover pairs must reconcile to R = Y − E.""" - post_ids = ["public-post", "spec-post"] - item_codes = ("sales_lead_specificity", "general_sentiment_negative") - matrix = np.array( - [ - [2.4, 0.0], - [0.0, 0.9], - ], - dtype=np.float64, - ) - expected = np.array( - [ - [2.0, 0.0], - [0.0, 2.0], - ], - dtype=np.float64, - ) - pairs = leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) - assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST] - by_cell = {(pair.post_id, pair.criterion_code): pair for pair in pairs} - closest_cell = by_cell[("public-post", "sales_lead_specificity")] - farthest_cell = by_cell[("spec-post", "general_sentiment_negative")] - assert closest_cell.observed_response == pytest.approx(2.4) - assert closest_cell.expected_response == pytest.approx(2.0) - assert closest_cell.leftover_residual == pytest.approx(0.4) - assert farthest_cell.observed_response == pytest.approx(0.9) - assert farthest_cell.expected_response == pytest.approx(2.0) - assert farthest_cell.leftover_residual == pytest.approx(-1.1) - for pair in pairs: - _assert_residual_reconciles(pair) - - -def test_leftover_residual_rejects_database_tolerance_boundary() -> None: - """Python must reject the exact boundary excluded by the DB check.""" - with pytest.raises(ValueError, match="observed Y minus expected E"): - leftover._candidate_row( - ["public-post"], - ("sales_lead_specificity",), - np.array([[0.0]]), - np.array([[0.0]]), - np.array([[1e-6]]), - 0, - 0, - 0.0, - None, - None, - None, - ) - - -def test_leftover_pairs_empty_without_complete_case_map() -> None: - """No complete-case rectangle (ADR 0168): no stand-in pair, coverage instead.""" - post_ids = ["sparse-a", "sparse-b"] - item_codes = ("item_near", "item_far") - matrix = np.array( - [ - [2.0, np.nan], - [np.nan, -2.0], - ], - dtype=np.float64, + pairs, axes = leftover.leftover_map_from_residual( + post_ids, item_codes, matrix, expected ) - expected = np.zeros_like(matrix) - assert leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) == () - coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected) - assert coverage.map_post_count == 0 - assert coverage.scored_post_count == 2 - assert coverage.incomplete_post_count == 2 - - -def test_rank_one_nonzero_center_is_disclosed_by_raw_residual_cross_share() -> None: - """Raw-residual cross share retains the mean omitted by centered SVD.""" - post_ids = ["post-a", "post-b", "post-c"] - item_codes = ("item_near", "item_mid", "item_far") - matrix = np.array( - [ - [5.0, 3.0, 1.0], - [3.0, 3.0, 3.0], - [1.0, 3.0, 5.0], - ], - dtype=np.float64, + coverage = leftover.leftover_map_coverage_from_residual( + post_ids, item_codes, matrix, expected ) - expected = np.zeros_like(matrix) - assert float(np.mean(matrix)) == pytest.approx(3.0) - pairs = leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) - assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST] - closest, farthest = pairs - person_full, item_full, _singular = leftover._leftover_map_positions(matrix - np.mean(matrix)) - reconstruction = leftover._pad_map_axes(person_full) @ leftover._pad_map_axes(item_full).T - post_index = {post_id: index for index, post_id in enumerate(post_ids)} - item_index = {code: index for index, code in enumerate(item_codes)} - for pair in pairs: - residual = pair.leftover_residual - recon = float(reconstruction[post_index[pair.post_id], item_index[pair.criterion_code]]) - expected_share = 0.0 if residual == 0.0 and recon == 0.0 else 2.0 * recon * (residual - recon) / residual**2 - assert pair.leftover_map_cross_share == pytest.approx(expected_share, abs=1e-6) - assert farthest.leftover_residual != pytest.approx(0.0) - assert farthest.leftover_map_cross_share != pytest.approx(farthest.leftover_residual) - for pair in pairs: - _assert_residual_reconciles(pair) - _assert_never_persists_hidden_shares(pair) - -def test_rank_one_leftover_map_puts_all_inertia_on_axis_one() -> None: - """A rank-1 residual must report leftover-map share 1 on axis 1, 0 on axis 2.""" - post_ids = ["post-a", "post-b", "post-c"] - item_codes = ("item_near", "item_mid", "item_far") - matrix = np.array( - [ - [2.0, 0.0, -2.0], - [0.0, 0.0, 0.0], - [-2.0, 0.0, 2.0], - ], - dtype=np.float64, - ) - expected = np.zeros_like(matrix) - pairs, axes = leftover_map_from_residual(post_ids, item_codes, matrix, expected) - assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST] + assert [pair.pair_kind for pair in pairs] == ["closest", "farthest"] + assert {pair.post_id for pair in pairs} <= {"post-a", "post-b"} assert [axis.axis_index for axis in axes] == [1, 2] assert axes[0].leftover_share == pytest.approx(1.0) assert axes[1].leftover_share == pytest.approx(0.0) - assert axes[0].leftover_singular_value > 0.0 - assert axes[1].leftover_singular_value == pytest.approx(0.0) - assert leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) == pairs - - -def test_zero_residual_emits_two_zero_share_leftover_map_axes() -> None: - post_ids = ["alpha-post", "beta-post"] - item_codes = ("item_one", "item_two") - matrix = np.ones((2, 2), dtype=np.float64) - expected = np.ones((2, 2), dtype=np.float64) - pairs, axes = leftover_map_from_residual(post_ids, item_codes, matrix, expected) - assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST] - assert [axis.axis_index for axis in axes] == [1, 2] - assert axes[0].leftover_share == pytest.approx(0.0) - assert axes[1].leftover_share == pytest.approx(0.0) - assert axes[0].leftover_singular_value == pytest.approx(0.0) - assert axes[1].leftover_singular_value == pytest.approx(0.0) - - -def test_leftover_map_axes_from_singular_use_gabriel_inertia() -> None: - """Share is σ_k² / Σ_j σ_j² from the actual singular values, never a leftover score.""" - singular = np.array([3.0, 1.0, 0.5], dtype=np.float64) - total = float(np.sum(singular * singular)) - axes = leftover_map_axes_from_singular(singular) - assert [axis.axis_index for axis in axes] == [1, 2] - assert axes[0].leftover_singular_value == pytest.approx(3.0) - assert axes[1].leftover_singular_value == pytest.approx(1.0) - assert axes[0].leftover_share == pytest.approx(9.0 / total) - assert axes[1].leftover_share == pytest.approx(1.0 / total) - assert leftover_map_axes_from_singular(np.zeros(0))[0].leftover_share == pytest.approx(0.0) - assert leftover_map_from_residual( - ["post-empty"], - ("item_one",), - np.array([[np.nan]], dtype=np.float64), - np.array([[0.0]], dtype=np.float64), - ) == ((), ()) - - -def test_rank_four_pair_distances_match_two_dimensional_gabriel_coords() -> None: - """Jeon leftover_distance is Euclidean on the 2D map, not the full SVD rank.""" - post_ids = ["post-a", "post-b", "post-c", "post-d"] - item_codes = ("item-a", "item-b", "item-c", "item-d") - matrix = np.array( - [ - [4.0, 1.0, 0.0, -1.0], - [0.0, 3.0, 1.0, -2.0], - [-2.0, 0.0, 2.0, 1.0], - [1.0, -1.0, 0.0, 4.0], - ], - dtype=np.float64, - ) - expected = np.zeros_like(matrix) - filled = matrix - float(np.mean(matrix)) - person_full, item_full = _gabriel_positions(filled) - assert person_full.shape[1] == 4 - person_map = _pad_map_axes(person_full) - item_map = _pad_map_axes(item_full) - full_distances = np.linalg.norm(person_full[:, None, :] - item_full[None, :, :], axis=2) - map_distances = np.linalg.norm(person_map[:, None, :] - item_map[None, :, :], axis=2) - assert float(np.max(np.abs(full_distances - map_distances))) > 1e-6 - - pairs = leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) - assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST] - post_index = {post_id: index for index, post_id in enumerate(post_ids)} - item_index = {code: index for index, code in enumerate(item_codes)} + assert coverage == leftover.LeftoverMapCoverage(2, 3, 2, 2, 1, 0) for pair in pairs: - assert pair.leftover_map_rank == 4 - person = post_index[pair.post_id] - item = item_index[pair.criterion_code] - assert pair.leftover_distance == pytest.approx(float(map_distances[person, item])) - assert pair.leftover_distance != pytest.approx( - float(full_distances[person, item]), abs=1e-9 + assert pair.leftover_residual == pytest.approx( + pair.observed_response - pair.expected_response ) - - farthest_map = np.unravel_index(int(np.argmax(map_distances)), map_distances.shape) - farthest = pairs[1] - assert (post_index[farthest.post_id], item_index[farthest.criterion_code]) == farthest_map + assert pair.leftover_map_unexplained is not None + assert pair.leftover_map_reconstruction is not None -def test_unexplained_and_cross_share_are_identity_remainder_terms() -> None: - """Unexplained U is R − R̂; cross share is 2 R̂ U / R². Neither is R or d. +def test_rank_zero_keeps_deterministic_pairs_without_inventing_an_axis() -> None: + """The product selection remains deterministic when Rust reports rank zero.""" - Uses the same rank-4 matrix as the two-axis distance proof above: R̂ is - the two-axis Gabriel reconstruction ``person_map @ item_map.T``, built - from the same padded coordinates ``leftover_distance`` already uses. - """ - post_ids = ["post-a", "post-b", "post-c", "post-d"] - item_codes = ("item-a", "item-b", "item-c", "item-d") - matrix = np.array( - [ - [4.0, 1.0, 0.0, -1.0], - [0.0, 3.0, 1.0, -2.0], - [-2.0, 0.0, 2.0, 1.0], - [1.0, -1.0, 0.0, 4.0], - ], - dtype=np.float64, + matrix = np.ones((2, 2)) + pairs, axes = leftover.leftover_map_from_residual( + ["alpha", "beta"], ("one", "two"), matrix, matrix ) - expected = np.zeros_like(matrix) - center = float(np.mean(matrix)) - filled = matrix - center - person_full, item_full, singular = leftover._leftover_map_positions(filled) - rank = int(singular.size) - assert person_full.shape[1] >= 3 - person_map = leftover._pad_map_axes(person_full) - item_map = leftover._pad_map_axes(item_full) - reconstruction = person_map @ item_map.T - full_inner = person_full @ item_full.T - map_distances = np.linalg.norm(person_map[:, None, :] - item_map[None, :, :], axis=2) - assert float(np.max(np.abs(reconstruction - filled))) > 1e-6 - assert float(np.max(np.abs(reconstruction - full_inner))) > 1e-6 - assert abs(center) > 1e-6 - - pairs = leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) - assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST] - post_index = {post_id: index for index, post_id in enumerate(post_ids)} - item_index = {code: index for index, code in enumerate(item_codes)} - saw_nonzero_cross = False - for pair in pairs: - person = post_index[pair.post_id] - item = item_index[pair.criterion_code] - recon = float(reconstruction[person, item]) - # Raw unexplained leftover U = R − R̂ (ADR 0182). - expected_unexplained = float(pair.leftover_residual) - recon - assert pair.leftover_map_unexplained == pytest.approx(expected_unexplained) - assert pair.leftover_map_unexplained != pytest.approx(pair.leftover_residual) - assert pair.leftover_map_unexplained != pytest.approx(pair.leftover_distance) - assert pair.leftover_map_reconstruction == pytest.approx(recon) - assert pair.leftover_map_unexplained + pair.leftover_map_reconstruction == pytest.approx( - pair.leftover_residual - ) - # Raw-residual cross share x = 2 R̂ U / R² (ADR 0185). - residual = float(pair.leftover_residual) - expected_share = (2.0 * recon * expected_unexplained) / (residual * residual) - explained_share = (recon * recon) / (residual * residual) - unexplained_share = (expected_unexplained * expected_unexplained) / (residual * residual) - assert pair.leftover_map_cross_share == pytest.approx(expected_share) - assert explained_share + unexplained_share + expected_share == pytest.approx(1.0) - if abs(expected_share) > 1e-6: - saw_nonzero_cross = True - assert pair.leftover_map_cross_share != pytest.approx(pair.leftover_residual) - assert pair.leftover_map_cross_share != pytest.approx(pair.leftover_distance) - # Distance is Euclidean on the two leftover-map axes (ADR 0119), the - # same basis the reconstruction above uses -- not the full-rank - # Gabriel inner product. - assert pair.leftover_distance == pytest.approx(float(map_distances[person, item])) - assert pair.leftover_map_rank == rank - _assert_never_persists_hidden_shares(pair) - assert saw_nonzero_cross - - -def test_cross_share_stores_negative_finite_identity_remainder() -> None: - """A negative identity remainder is stored, never omitted or clamped.""" - assert leftover._leftover_map_cross_share(1.0, 2.0) == pytest.approx(-4.0) - assert leftover._leftover_map_cross_share(2.0, 2.0) == pytest.approx(0.0) - assert leftover._leftover_map_cross_share(0.0, 0.0) == pytest.approx(0.0) - assert leftover._leftover_map_cross_share(float("nan"), 1.0) is None - assert leftover._leftover_map_cross_share(1.0, float("inf")) is None + assert [(pair.post_id, pair.criterion_code) for pair in pairs] == [ + ("alpha", "one"), + ("beta", "two"), + ] + assert all(pair.leftover_map_rank == 0 for pair in pairs) + assert all(pair.leftover_distance == pytest.approx(0.0) for pair in pairs) + assert all(axis.leftover_share == pytest.approx(0.0) for axis in axes) -def test_pad_map_axes_truncates_hidden_svd_components() -> None: - """Axes after the second leftover-map axis do not enter reconstruction.""" - padded = leftover._pad_map_axes(np.array([[1.0, 2.0, 9.0]], dtype=np.float64)) - assert padded.shape == (1, 2) - assert padded[0].tolist() == pytest.approx([1.0, 2.0]) +def test_no_complete_case_rectangle_returns_coverage_without_pairs() -> None: + """Sparse evidence remains unavailable instead of becoming zero-filled math.""" -def test_rejects_response_and_expectation_shape_mismatches() -> None: - """Scientific inputs must match their declared post and criterion axes.""" - with pytest.raises(ValueError, match="matrix shape"): - leftover_pairs_from_residual( - ["post-a"], - ("item-a",), - np.zeros((2, 1), dtype=np.float64), - np.zeros((2, 1), dtype=np.float64), - ) - with pytest.raises(ValueError, match="expected shape"): - leftover_pairs_from_residual( - ["post-a"], - ("item-a",), - np.zeros((1, 1), dtype=np.float64), - np.zeros((1, 2), dtype=np.float64), - ) - - -def test_nonfinite_map_distance_emits_no_pair( - monkeypatch: pytest.MonkeyPatch, + matrix = np.array([[1.0, np.nan], [np.nan, 1.0]]) + expected = np.zeros_like(matrix) + post_ids = ["post-a", "post-b"] + item_codes = ("item-a", "item-b") + + assert leftover.leftover_pairs_from_residual( + post_ids, item_codes, matrix, expected + ) == () + assert leftover.leftover_map_coverage_from_residual( + post_ids, item_codes, matrix, expected + ) == leftover.LeftoverMapCoverage(0, 2, 0, 2, 2, 2) + + +@pytest.mark.parametrize( + ("matrix", "expected", "message"), + [ + (np.zeros((1, 2)), np.zeros((1, 2)), "matrix shape"), + (np.zeros((1, 1)), np.zeros((2, 1)), "expected shape"), + ], +) +def test_identifier_and_matrix_shape_mismatch_fails_closed( + matrix: np.ndarray, expected: np.ndarray, message: str ) -> None: - """An unusable factorization coordinate cannot become persisted distance.""" - monkeypatch.setattr( - leftover, - "_complete_case_positions", - lambda *_args: ( - np.array([[np.inf]], dtype=np.float64), - np.array([[-np.inf]], dtype=np.float64), - np.array([1.0], dtype=np.float64), - ), - ) - pairs = leftover_pairs_from_residual( - ["post-a"], - ("item-a",), - np.array([[1.0]], dtype=np.float64), - np.array([[0.0]], dtype=np.float64), - ) - assert pairs == () - - -def test_empty_observation_mask_has_no_complete_case_axes() -> None: - """The complete-case helpers preserve an empty scientific boundary.""" - observed = np.zeros((1, 1), dtype=bool) - keep_person, keep_item = leftover._complete_case_masks(observed) - assert not keep_person.any() - assert not keep_item.any() - person_pos, item_pos, singular = leftover._complete_case_positions( - np.zeros((1, 1), dtype=np.float64), - 0.0, - keep_person, - keep_item, - ) - assert person_pos is None - assert item_pos is None - assert singular.size == 0 + """A result that cannot bind to product identifiers is rejected.""" + with pytest.raises(ValueError, match=message): + leftover.leftover_map_from_residual(["post"], ("item",), matrix, expected) -def test_leftover_map_rank_rejects_negative_rank() -> None: - """Python must reject a leftover-map rank excluded by the DB check.""" - with pytest.raises(ValueError, match="non-negative integer"): - leftover._pair_from_candidate( - PAIR_KIND_CLOSEST, - (0.0, "public-post", "sales_lead_specificity", 0.0, 1.0, 1.0, None), - -1, - ) +def test_owner_failure_is_not_replaced_by_local_math(monkeypatch: pytest.MonkeyPatch) -> None: + """A missing Rust result remains an explicit dependency failure.""" -def test_small_finite_residual_keeps_cross_share() -> None: - """A tiny-but-finite residual keeps its cross share. + def unavailable(*_args: object, **_kwargs: object) -> object: + raise RuntimeError("compiled Rust core unavailable") - Squaring before the floor made the effective threshold 1e-6, so - R = 1e-7 with reconstruction 5e-8 collapsed to an omitted badge - even though x = 0.5 is well-defined (coderabbit review thread). - """ - share = leftover._leftover_map_cross_share(1e-7, 5e-8) - assert share == pytest.approx(0.5) + monkeypatch.setattr(leftover, "residual_interaction_map", unavailable) + with pytest.raises(RuntimeError, match="compiled Rust core unavailable"): + leftover.leftover_map_from_residual( + ["post"], ("item",), np.zeros((1, 1)), np.zeros((1, 1)) + ) -def test_leftover_is_unavailable_without_a_complete_case_rectangle() -> None: - """Observed cells alone cannot invent Gabriel positions or map coverage.""" - post_ids = ["post-a", "post-b"] - item_codes = ("item-one", "item-two") - matrix = np.array([[1.0, np.nan], [np.nan, 1.0]], dtype=np.float64) - expected = np.zeros_like(matrix) +def test_consumer_contains_no_factorization_formula() -> None: + """Regression guard: numerical interaction-map policy stays upstream.""" - assert leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) == () - coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected) - assert coverage.map_post_count == 0 - assert coverage.scored_post_count == 2 - assert coverage.map_item_count == 0 - assert coverage.scored_item_count == 2 - assert coverage.incomplete_post_count == 2 - assert coverage.incomplete_item_count == 2 + source = inspect.getsource(leftover) + for forbidden in ("np.linalg", "np.dot", "np.sqrt", "np.mean", "np.sum"): + assert forbidden not in source diff --git a/uv.lock b/uv.lock index 98ac77e48..9ca8e35cc 100644 --- a/uv.lock +++ b/uv.lock @@ -470,8 +470,8 @@ wheels = [ [[package]] name = "fast-mlsirm" -version = "0.8.0" -source = { git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=d025b7d237d8db7ca97a5611606c6285d5870895#d025b7d237d8db7ca97a5611606c6285d5870895" } +version = "0.9.0" +source = { git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=256470c7d1df4910a018841499a74d88b751a774#256470c7d1df4910a018841499a74d88b751a774" } dependencies = [ { name = "numpy" }, ] @@ -655,7 +655,7 @@ requires-dist = [ { name = "certifi", specifier = ">=2024.0.0" }, { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.6" }, { name = "cryptography", specifier = ">=42.0" }, - { name = "fast-mlsirm", marker = "extra == 'backend'", git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=d025b7d237d8db7ca97a5611606c6285d5870895" }, + { name = "fast-mlsirm", marker = "extra == 'backend'", git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=256470c7d1df4910a018841499a74d88b751a774" }, { name = "fastapi", marker = "extra == 'backend'", specifier = ">=0.141.1" }, { name = "httpx2", marker = "extra == 'dev'", specifier = ">=2.12.0" }, { name = "opentelemetry-api", specifier = ">=1.30.0" }, From fa604e797c4746aadbb4819fff63726469a505f4 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 08:35:01 +0900 Subject: [PATCH 058/393] docs(changelog): keep provenance references under changed --- CHANGELOG.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbef5686e..f7f1be1ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ All notable changes to this project are documented here. Format follows and the development test stack follows Starlette's maintained `httpx2` `TestClient` contract. FastAPI 422 responses use the RFC 9110 constant, so deprecation failures are repaired rather than suppressed. +- ADRs 0011 and 0065 now include APA 7th References for the dated W3C + PROV-O and PROV-DM Recommendations (30 April 2013). Decisions are + unchanged. ### Added @@ -125,10 +128,6 @@ All notable changes to this project are documented here. Format follows authorized leftover store as the period-report list; they do not invent a leftover score. -- ADRs 0011 and 0065 now include APA 7th References for the dated W3C - PROV-O and PROV-DM Recommendations (30 April 2013). Decisions are - unchanged. - ### Fixed - Full-corpus Event Lineage rebuilds now count candidate pairs before provider From dda5cf48d6008bf537df9cf8ce7df99f847958ff Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 08:44:32 +0900 Subject: [PATCH 059/393] fix(dashboard): preserve unavailable evidence state --- backend/app/operations_dashboard.py | 10 +++++++++- backend/app/post_content_worker.py | 3 ++- lineageweave/period_report.py | 14 +++++++++++--- tests/test_operations_dashboard.py | 12 ++++++------ tests/test_post_content_worker.py | 2 ++ 5 files changed, 30 insertions(+), 11 deletions(-) diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py index ae8f3eb23..449d41757 100644 --- a/backend/app/operations_dashboard.py +++ b/backend/app/operations_dashboard.py @@ -282,7 +282,15 @@ async def fetch_operations_dashboard( join source_post post on post.post_id = missing.post_id where {visible} and ($5::boolean is false or missing.case_kind_code = 'external_information') - order by missing.post_id, missing.case_kind_code, missing.fact_type_code + union all + select fact.post_id, fact.case_kind_code, fact.fact_type_code + from operations_case_fact fact + join source_post post on post.post_id = fact.post_id + join source_post evidence_post on evidence_post.post_id = fact.evidence_post_id + where {visible} + and not ({visible_evidence}) + and ($5::boolean is false or fact.case_kind_code = 'external_information') + order by post_id, case_kind_code, fact_type_code """, *args, ) diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py index 10f8e958a..5cf7407b0 100644 --- a/backend/app/post_content_worker.py +++ b/backend/app/post_content_worker.py @@ -72,6 +72,7 @@ def can_see(row: asyncpg.Record) -> bool: sources = await gather_chat_sources(conn, post_id, can_see, vision_client) if not sources: return () + source_post_ids = [UUID(source.post_id) for source in sources] source_times = { str(row["post_id"]): ( row["observed_at"], @@ -83,7 +84,7 @@ def can_see(row: asyncpg.Record) -> bool: "select post_id, event_occurred_at, " "coalesce(event_occurred_at, created_at) as observed_at " "from source_post where post_id = any($1::uuid[])", - [source.post_id for source in sources], + source_post_ids, ) } if any(source.post_id not in source_times for source in sources): diff --git a/lineageweave/period_report.py b/lineageweave/period_report.py index 09dc6f838..7e5c973f7 100644 --- a/lineageweave/period_report.py +++ b/lineageweave/period_report.py @@ -127,6 +127,14 @@ class PeriodReport: leftover_map_coverage: LeftoverMapCoverage | None = None +def _diagnostic_float(diagnostics: object, key: str) -> float: + """Read a required fast-mlsirm diagnostic without inventing a fallback.""" + best = getattr(diagnostics, "best", None) + if not isinstance(best, dict) or key not in best: + raise RuntimeError(f"fast-mlsirm diagnostic contract missing {key!r}") + return float(best[key]) + + def assemble_response_matrix( post_ids: list[str], rows: list[tuple[str, str, int]], @@ -297,7 +305,7 @@ def calibrate_period_report( item_count=len(item_codes), fit_loglik=float(fit.loglik), fit_converged=bool(fit.converged), - calibration_score=float(diagnostics.best["calibration_score"]), + calibration_score=_diagnostic_float(diagnostics, "calibration_score"), member_scores=_member_scores(post_ids, scores), item_bank=item_bank, link_method=LINK_METHOD_FREE, @@ -345,9 +353,9 @@ def score_period_on_bank( mean_theta_sd=float(theta.std(ddof=0)), post_count=len(post_ids), item_count=len(item_bank.item_codes), - fit_loglik=float(diagnostics.best["heldout_loglik"]), + fit_loglik=_diagnostic_float(diagnostics, "heldout_loglik"), fit_converged=True, - calibration_score=float(diagnostics.best["calibration_score"]), + calibration_score=_diagnostic_float(diagnostics, "calibration_score"), member_scores=_member_scores(post_ids, scores), item_bank=item_bank, link_method=LINK_METHOD_FIPC, diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py index 4a5b9c0bf..c7df470c8 100644 --- a/tests/test_operations_dashboard.py +++ b/tests/test_operations_dashboard.py @@ -31,6 +31,12 @@ async def fetchrow(self, query: str, *args: object) -> dict[str, int]: async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: self.queries.append((query, args)) + if "operations_case_missing_fact missing" in query: + return [{ + "post_id": "00000000-0000-0000-0000-000000000001", + "case_kind_code": "claim_investigation", + "fact_type_code": "sales_pool", + }] if "operations_case_fact fact" in query: return [ { @@ -44,12 +50,6 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: "relation_target_kind_code": None, } ] - if "operations_case_missing_fact missing" in query: - return [{ - "post_id": "00000000-0000-0000-0000-000000000001", - "case_kind_code": "claim_investigation", - "fact_type_code": "sales_pool", - }] if "operations_case_milestone milestone" in query: return [ { diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py index b14fe75df..3a9d122df 100644 --- a/tests/test_post_content_worker.py +++ b/tests/test_post_content_worker.py @@ -6,6 +6,7 @@ from contextlib import asynccontextmanager from datetime import UTC, datetime from types import SimpleNamespace +from uuid import UUID import pytest @@ -127,6 +128,7 @@ async def gather(*_args): class SourceConnection(_Connection): async def fetch(self, query: str, *_args: object): assert "coalesce(event_occurred_at, created_at) as observed_at" in query + assert isinstance(_args[0][0], UUID) return [{ "post_id": "00000000-0000-0000-0000-000000000001", "event_occurred_at": observed_at, From d70a778854e5250aa7008d5c51dac5e338a35cf0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 17:01:42 -0700 Subject: [PATCH 060/393] refactor(rankings): consume RankWeave-owned RRF evidence (#673) Co-authored-by: Codex --- CHANGELOG.md | 5 + docs/adr/0024-rankweave-fusion-fail-closed.md | 17 +- ...-externalize-local-mathematical-compute.md | 5 + ...hon-mathematical-compute-boundary-audit.md | 2 +- docs/product-technical-gap-baseline.md | 2 +- lineageweave/rankweave_client.py | 147 +++++++++++------- pyproject.toml | 2 +- tests/test_rankweave_client.py | 62 ++++++-- uv.lock | 4 +- 9 files changed, 167 insertions(+), 79 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7f1be1ec..7c33678fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ All notable changes to this project are documented here. Format follows ### Changed +- Rankings now call RankWeave's parameter-free classic RRF path when no + calibrated weights exist and project RankWeave-owned channel contributions; + the prior local contribution arithmetic and invalid all-ones call to the + convex-weight API were removed. + - Period-report leftover maps now consume fast-mlsirm's protected Rust residual-interaction and expected-response contracts. Local Python Gabriel SVD, distance, reconstruction, share, expectation, and duplicate likelihood diff --git a/docs/adr/0024-rankweave-fusion-fail-closed.md b/docs/adr/0024-rankweave-fusion-fail-closed.md index c03f73b45..15c875eee 100644 --- a/docs/adr/0024-rankweave-fusion-fail-closed.md +++ b/docs/adr/0024-rankweave-fusion-fail-closed.md @@ -28,18 +28,19 @@ tables, and does not bind the demo IdP to production Keyverse. rank-only channels: temporal (newest first) and lexical (token overlap with the synthetic demo query `pricing quote delivery`). Hidden posts are omitted from every channel. Never invent a score. -3. Fusion is weighted RRF with Cormack et al. (2009) η = 60 and - Samuel et al. (2025) unequal-channel weights (`temporal` 0.25, - `lexical` 0.75). The buyer sees 1-based `fused_rank` and the post - title — not a TEPP theta. +3. With no calibrated weights, fusion calls RankWeave's parameter-free + `reciprocal_rank_fuse` with Cormack et al. (2009) η = 60. An explicit + psychometrically estimated convex vector calls + `weighted_reciprocal_rank_fuse`. The buyer sees 1-based `fused_rank` and + the post title — not a TEPP theta. 4. After login, Rankings sits above Calendar. Unavailable copy is **Rankings · RankWeave not available**. An accepted hit lists the title; click opens that `source_post`. 5. Accepted hits also disclose owned-channel evidence (ADR 0167): - 1-based `channel_rank` and Cormack contribution - `weight / (η + rank)` for each channel the post actually appears - in. Missing channels are omitted. RankWeave extra fields are - ignored. Copy states this is not a calibrated score. + 1-based `channel_rank` and RankWeave-owned Cormack contribution for each + channel the post actually appears in. Missing channels are omitted. + Transport extra fields are ignored. Copy states this is not a calibrated + score. ## Consequences diff --git a/docs/adr/0208-externalize-local-mathematical-compute.md b/docs/adr/0208-externalize-local-mathematical-compute.md index 31d5b13ac..1c5d9adda 100644 --- a/docs/adr/0208-externalize-local-mathematical-compute.md +++ b/docs/adr/0208-externalize-local-mathematical-compute.md @@ -76,6 +76,11 @@ different responsibility. Gabriel SVD, axis inertia, distance, reconstruction, unexplained residual, cross share, and coverage arithmetic were deleted from LineageWeave Python. Product-side identifier attachment and closest/farthest selection remain. +- Rankings call RankWeave's classic or convex-weighted RRF owner path and + project its exact channel contributions. LineageWeave no longer evaluates + the reciprocal-rank contribution formula. RankWeave's Rust CPU/GPU migration + remains open, so this slice is owner-bound but not yet final execution-contract + compliance. ## Stacked delivery order diff --git a/docs/doctoring/python-mathematical-compute-boundary-audit.md b/docs/doctoring/python-mathematical-compute-boundary-audit.md index 74e129eec..108e136b2 100644 --- a/docs/doctoring/python-mathematical-compute-boundary-audit.md +++ b/docs/doctoring/python-mathematical-compute-boundary-audit.md @@ -28,7 +28,7 @@ does not relabel still-local Python paths as Rust/GPU compliant. | `lineageweave/embedding_client.py` and `backend/app/post_chat_ingestion.py` | cosine similarity, vector norms, maximum semantic score | RankWeave retrieval-score contract | ranked evidence envelope over ABAC-visible semantic units | reconstruction text channel and Global Ask retrieval; embedding/post-chat tests | | `lineageweave/knowledge_graph.py` | random walk with restart, convergence delta, adaptive relevance cutoff | RankWeave graph-ranking contract | ranked-node artifact with contribution and convergence evidence | related-person/entity API paths; knowledge-graph tests | | `lineageweave/reconstruct.py` | channel-weight renormalization, candidate-score fusion and minimum-score decision | RankWeave fusion; TEPP supplies independent lineage criterion | accepted edge-ranking artifact; LineageWeave persists selected edge and channel provenance | lineage rebuild/start/seed/server; reconstruct, persistence, API tests | -| `lineageweave/rankweave_client.py` | channel construction, token overlap, RRF weights and contribution arithmetic | RankWeave | strict ranking artifact exposing owner-computed contributions | `/api/rankings`, frontend Rankings; `tests/test_rankweave_client.py` and frontend tests | +| `lineageweave/rankweave_client.py` | channel construction and token overlap remain; **owner-bound:** classic/weighted RRF and contribution arithmetic now come from RankWeave #47, whose Python core still awaits the required Rust CPU/GPU migration | RankWeave | Rust-backed strict ranking artifact exposing owner-computed contributions and owned channel construction | `/api/rankings`, frontend Rankings; `tests/test_rankweave_client.py` and frontend tests | `lineageweave/post_evaluation.py` imports fast-mlsirm only for its published judge contract and `to_irt_row` projection. It performs no fitted numerical diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 69074b9d0..3ac41f4a0 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -438,7 +438,7 @@ this file per §3.5 of the prior snapshot). | SKOS organization aliases | Catalog binding and chip caption live on #480 / #482 | One catalog row per corroborated org; companion caption is hint-only until bound | | Event Lineage evidence | Channel evidence and Allen relations live on #387 / #484 | Persist channel scores, explain them in the popup, never invent a fused score | | Scientific measurement | Durable accepted TEPP receipts and LineageWeave #614's exact accepted snapshot/cutoff/run/pair-count consumer are protected; TEPP #237 remains open, so no registered producer artifact exists yet. #387 removes inferred/default persistence weights, but several older reconstruction tests still pass hand-authored numeric dictionaries that are not estimator evidence | Land TEPP #237 through its protected gate, then replace remaining reconstruction-test constants with provenance-bearing fast-mlsirm estimates over synthetic fixtures. Retain true-parameter RMSE recovery as the acceptance bar | -| Python mathematical-compute boundary | ADR 0208's first deletion slice consumes fast-mlsirm protected-main Rust `residual_interaction_map` and `polytomous_expected_response`; local Gabriel SVD, distance, reconstruction, shares, expected-category and duplicate likelihood formulas are deleted. The doctoring inventory still names period calibration, channel weighting, cosine, graph ranking, and fusion debt | Land this stacked consumer through protected gates, then migrate each remaining construct to its owning repository contract and require an empty transition inventory before claiming the boundary complete | +| Python mathematical-compute boundary | ADR 0208's first deletion slice consumes fast-mlsirm protected-main Rust `residual_interaction_map` and `polytomous_expected_response`; local Gabriel SVD, distance, reconstruction, shares, expected-category and duplicate likelihood formulas are deleted. This stack also removes local RRF contribution arithmetic and corrects the invalid all-ones call to RankWeave's convex API through RankWeave #47 (`f92c3a2c`), but RankWeave #47 is still Python and therefore is not the final Rust CPU/GPU execution contract. The doctoring inventory still names period calibration, channel weighting, cosine, graph ranking, and fusion debt | Land the owner and stacked consumer through protected gates; then move RankWeave's calculation core to Rust CPU/GPU and migrate each remaining construct to its owning repository contract before requiring an empty transition inventory | | Asynchronous authorization | Protected `main` rebuilds Global Ask worker scope after the bearer token leaves the request; #468 now persists exact Keyverse organization/process-unit scope in 3NF child tables and intersects it with current affiliations | Land #468 through the protected gate; prove a second affiliation and a revoked process unit cannot widen delayed-job evidence | | Planned-facility intent | Planned-facility relationship intent remains only on closed, unmerged #490; earlier stack-only merges were not protected delivery | Recreate the evidence-backed slice on a current base and land through protected `main` before a release claim | | Accessibility and responsive UX | #602 delivered base post-detail modal semantics; #605 adds selected-post refocus, collapsed/hidden/inert/CSS-invisible focus exclusion across both modal types, readable evidence separators, focused tests, and desktop/mobile Storybook screenshots | Land #605 through the protected gate, then complete screen-reader and authenticated Playwright acceptance on the exact release head | diff --git a/lineageweave/rankweave_client.py b/lineageweave/rankweave_client.py index 4c157aa57..fde71810a 100644 --- a/lineageweave/rankweave_client.py +++ b/lineageweave/rankweave_client.py @@ -130,22 +130,25 @@ def ranking_channel_evidence( ) -> tuple["RankingChannelEvidence", ...]: """Explain one fused hit from owned channel ranks. - Contribution is Cormack et al. (2009) weighted RRF: - ``weight / (η + rank)`` with 1-based rank. A channel the post is - missing from, or a non-positive weight, is omitted. RankWeave extra - fields are ignored so a missing signal cannot be invented. + RankWeave owns the Cormack contribution arithmetic. A channel the post is + missing from, or a non-positive weight, is omitted. Transport extra fields + are ignored so a missing signal cannot be invented. """ - collected: list[tuple[str, int, float, float]] = [] - for signal_code, ordered_ids in channels.items(): - weight = float(weights.get(signal_code) or 0.0) - if weight <= 0: - continue - try: - channel_rank = [str(item_id) for item_id in ordered_ids].index(post_id) + 1 - except ValueError: - continue - contribution = weight / (eta + channel_rank) - collected.append((signal_code, channel_rank, weight, contribution)) + return _owner_channel_evidence(channels, weights, eta).get(post_id, ()) + + +def _evidence_from_owner_hit(hit: object) -> tuple["RankingChannelEvidence", ...]: + """Project one RankWeave result without recalculating a contribution.""" + collected = [ + ( + str(contribution.channel_name), + int(contribution.rank), + float(contribution.weight), + float(contribution.contribution), + ) + for contribution in getattr(hit, "channel_contributions", ()) + if contribution.rank is not None and contribution.weight > 0 + ] collected.sort(key=lambda item: (-item[3], item[0])) return tuple( RankingChannelEvidence( @@ -162,6 +165,19 @@ def ranking_channel_evidence( ) +def _owner_channel_evidence( + channels: Mapping[str, Sequence[str]], + weights: Mapping[str, float], + eta: int, +) -> dict[str, tuple["RankingChannelEvidence", ...]]: + """Index one RankWeave owner calculation by item identifier.""" + return { + item_id: _evidence_from_owner_hit(hit) + for hit in _owner_rrf_hits(channels, weights, eta) + if (item_id := _item_id_from_hit(hit)) + } + + @dataclass(frozen=True) class RankingChannelEvidence: """One owned-channel contribution to a fused ranking hit.""" @@ -216,6 +232,38 @@ def to_json(self) -> list[dict[str, Any]]: return [item.to_json() for item in self.items] +def _owner_rrf_hits( + channels: Mapping[str, Sequence[str]], + weights: Mapping[str, float], + eta: int, + *, + limit: int | None = None, +) -> list[object]: + """Return RankWeave-owned classic or convex-weighted RRF results.""" + try: + rw = _import_rankweave() + if all(float(weights.get(name) or 0.0) == 1.0 for name in channels): + return list( + rw.reciprocal_rank_fuse( + channels, + limit=limit, + rank_constant_eta=eta, + ) + ) + return list( + rw.weighted_reciprocal_rank_fuse( + channels, + weights, + limit=limit, + rank_constant_eta=eta, + ) + ) + except Exception as exc: + raise RankWeaveNotAvailable( + "rankweave_not_available: reciprocal-rank fusion failed" + ) from exc + + def _item_id_from_hit(hit: object) -> str: if isinstance(hit, Mapping): return str(hit.get("item_id") or hit.get("post_id") or "").strip() @@ -249,6 +297,11 @@ def project_ranking_list( # Parameter-free classic RRF default (ADR 0200 point 1): every # channel weighs 1.0 unless the caller passes an estimated set. owned_weights = weights or {name: 1.0 for name in owned_channels} + evidence_by_post_id = _owner_channel_evidence( + owned_channels, + owned_weights, + DEFAULT_RANK_CONSTANT_ETA, + ) for hit in raw: post_id = _item_id_from_hit(hit) title = str(titles_by_id.get(post_id) or "").strip() @@ -260,9 +313,7 @@ def project_ranking_list( post_id=post_id, post_title=title, fused_rank=len(items) + 1, - channel_evidence=ranking_channel_evidence( - post_id, owned_channels, owned_weights - ), + channel_evidence=evidence_by_post_id.get(post_id, ()), ) ) return RankingList(items=tuple(items)) @@ -276,13 +327,6 @@ def __call__( channels: dict[str, list[str]], weights: dict[str, float], ) -> list[dict[str, Any]]: - try: - rw = _import_rankweave() - except ImportError as exc: - raise RankWeaveNotAvailable( - "rankweave_not_available: rankweave package is not installed. " - "Never invent a fused score." - ) from exc usable = { name: [item_id for item_id in ranks if str(item_id).strip()] for name, ranks in channels.items() @@ -297,28 +341,13 @@ def __call__( raise RankWeaveNotAvailable( "rankweave_not_available: no positive channel weights remain" ) - try: - hits = rw.weighted_reciprocal_rank_fuse( - usable, - active_weights, - limit=DEFAULT_RANKING_LIMIT, - rank_constant_eta=DEFAULT_RANK_CONSTANT_ETA, - ) - except TypeError: - try: - hits = rw.weighted_reciprocal_rank_fuse( - usable, - active_weights, - limit=DEFAULT_RANKING_LIMIT, - ) - except Exception as exc: - raise RankWeaveNotAvailable( - "rankweave_not_available: weighted_reciprocal_rank_fuse failed" - ) from exc - except Exception as exc: - raise RankWeaveNotAvailable( - "rankweave_not_available: weighted_reciprocal_rank_fuse failed" - ) from exc + usable = {name: ranks for name, ranks in usable.items() if name in active_weights} + hits = _owner_rrf_hits( + usable, + active_weights, + DEFAULT_RANK_CONSTANT_ETA, + limit=DEFAULT_RANKING_LIMIT, + ) projected: list[dict[str, Any]] = [] for hit in hits: item_id = _item_id_from_hit(hit) @@ -353,12 +382,11 @@ def fuse_rankings( ) -> RankingList: """Fuse the channels; parameter-free classic RRF by default. - No hand-picked weight exists (ADR 0200 point 1): without an - explicit ``weights`` argument every channel gets weight 1.0, - which reduces weighted RRF to Cormack et al.'s (2009) - parameter-free reciprocal rank fusion -- the paper's own - finding is that the unweighted form outperforms trained - alternatives, so there is no arbitrary number to justify. + No hand-picked weight exists (ADR 0200 point 1): without an explicit + ``weights`` argument the adapter calls Cormack et al.'s (2009) + parameter-free reciprocal rank fusion. The paper's own finding is + that the unweighted form outperforms trained alternatives, so there + is no arbitrary number to justify. Callers holding a psychometrically estimated set may still pass it explicitly; the disclosed per-channel evidence carries whichever weights actually fused. @@ -372,9 +400,16 @@ def fuse_rankings( raise RankWeaveNotAvailable( "rankweave_not_available: ranking transport failed" ) from exc - return project_ranking_list( - raw, titles_by_id, channels=channels, weights=active_weights - ) + try: + return project_ranking_list( + raw, titles_by_id, channels=channels, weights=active_weights + ) + except RankWeaveNotAvailable: + raise + except Exception as exc: + raise RankWeaveNotAvailable( + "rankweave_not_available: ranking projection failed" + ) from exc def as_api_payload( self, diff --git a/pyproject.toml b/pyproject.toml index 091ac2ce6..110858e16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ "pillow>=12.3.0", # RankWeave has no PyPI release yet; pinned to a specific commit (not a # floating branch ref) for reproducible installs, per org convention. - "rankweave @ git+https://github.com/ContextualWisdomLab/RankWeave.git@61c49c50d3b4a24fc9bd7c6d3a7f2f4ba19d7be6", + "rankweave @ git+https://github.com/ContextualWisdomLab/RankWeave.git@f92c3a2c7c50f3b5f4499dfdc94987d4e023a4fa", # Explicit CA bundle for http_client HTTPS posts -- some interpreter # distributions don't reliably inherit the OS trust store. "certifi>=2024.0.0", diff --git a/tests/test_rankweave_client.py b/tests/test_rankweave_client.py index f3598ca91..3d05c76ba 100644 --- a/tests/test_rankweave_client.py +++ b/tests/test_rankweave_client.py @@ -1,9 +1,8 @@ """Fail-closed RankWeave ranking port. -RankWeave is an in-process weighted-RRF library. LineageWeave fuses -only visible posts. A hidden post is omitted from every channel. The -client never invents a fused score or a theta. Channel evidence is -computed from owned rank lists (Cormack 2009), not RankWeave extras. +RankWeave owns classic and convex-weighted RRF calculation. LineageWeave sends +only visible posts and projects the owner's contributions from owned channel +inputs. The client never invents a fused score or a theta. """ from __future__ import annotations @@ -170,6 +169,18 @@ def fake_transport( assert "fused_score" not in serialized +def test_library_transport_uses_classic_rrf_without_convex_weights() -> None: + payload = build_rankweave_client().as_api_payload( + [PUBLIC, QUOTE], + can_see_post=lambda _row: True, + ) + + assert payload["status"] == "accepted" + assert payload["rankings"][0]["channel_evidence"] == _lexical_then_temporal( + "post-2", 1 + ) + + def test_library_transport_projects_monkeypatched_rrf( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -177,19 +188,51 @@ def test_library_transport_projects_monkeypatched_rrf( class FakeRw: @staticmethod - def weighted_reciprocal_rank_fuse( + def reciprocal_rank_fuse( channels: dict[str, list[str]], - weights: dict[str, float], limit: int = 20, rank_constant_eta: int = 60, ) -> list: captured["channels"] = channels - captured["weights"] = weights captured["limit"] = limit captured["eta"] = rank_constant_eta return [ - SimpleNamespace(item_id="post-2", fused_score=0.99, theta=1.2), - SimpleNamespace(item_id="post-1"), + SimpleNamespace( + item_id="post-2", + fused_score=0.99, + theta=1.2, + channel_contributions=( + SimpleNamespace( + channel_name="lexical", + rank=1, + weight=1.0, + contribution=1.0 / 61, + ), + SimpleNamespace( + channel_name="temporal", + rank=1, + weight=1.0, + contribution=1.0 / 61, + ), + ), + ), + SimpleNamespace( + item_id="post-1", + channel_contributions=( + SimpleNamespace( + channel_name="lexical", + rank=2, + weight=1.0, + contribution=1.0 / 62, + ), + SimpleNamespace( + channel_name="temporal", + rank=2, + weight=1.0, + contribution=1.0 / 62, + ), + ), + ), ] monkeypatch.setattr( @@ -201,7 +244,6 @@ def weighted_reciprocal_rank_fuse( ) assert captured["eta"] == 60 - assert set(captured["weights"].values()) == {1.0} assert payload["rankings"][0]["post_title"] == ( "Pricing renegotiation: revised quote sent" ) diff --git a/uv.lock b/uv.lock index 9ca8e35cc..2225d1d16 100644 --- a/uv.lock +++ b/uv.lock @@ -667,7 +667,7 @@ requires-dist = [ { name = "pyjwt", extras = ["crypto"], marker = "extra == 'dev'", specifier = ">=2.8.0" }, { name = "pyshacl", marker = "extra == 'dev'", specifier = ">=0.26.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, - { name = "rankweave", git = "https://github.com/ContextualWisdomLab/RankWeave.git?rev=61c49c50d3b4a24fc9bd7c6d3a7f2f4ba19d7be6" }, + { name = "rankweave", git = "https://github.com/ContextualWisdomLab/RankWeave.git?rev=f92c3a2c7c50f3b5f4499dfdc94987d4e023a4fa" }, { name = "rdflib", specifier = ">=7.0.0" }, { name = "redis", marker = "extra == 'backend'", specifier = ">=5.0.0" }, { name = "threadweave", specifier = ">=0.1.0" }, @@ -1218,7 +1218,7 @@ wheels = [ [[package]] name = "rankweave" version = "0.18.0" -source = { git = "https://github.com/ContextualWisdomLab/RankWeave.git?rev=61c49c50d3b4a24fc9bd7c6d3a7f2f4ba19d7be6#61c49c50d3b4a24fc9bd7c6d3a7f2f4ba19d7be6" } +source = { git = "https://github.com/ContextualWisdomLab/RankWeave.git?rev=f92c3a2c7c50f3b5f4499dfdc94987d4e023a4fa#f92c3a2c7c50f3b5f4499dfdc94987d4e023a4fa" } [[package]] name = "rdflib" From 66ba68859830891e5cee3ee3e7cc4a5b33bc58b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 17:16:13 -0700 Subject: [PATCH 061/393] fix(rankings): reuse trusted owner fusion envelope (#674) * refactor(rankings): consume RankWeave-owned RRF evidence * fix(rankings): reject explicit empty weight evidence * fix(rankings): reuse trusted owner fusion envelope * fix(rankings): reject empty weights before transport --------- Co-authored-by: Codex --- lineageweave/rankweave_client.py | 82 ++++++++++++++++++++++---------- tests/test_rankweave_client.py | 36 +++++++++++--- 2 files changed, 88 insertions(+), 30 deletions(-) diff --git a/lineageweave/rankweave_client.py b/lineageweave/rankweave_client.py index fde71810a..1a9b6f34a 100644 --- a/lineageweave/rankweave_client.py +++ b/lineageweave/rankweave_client.py @@ -42,10 +42,14 @@ class RankWeaveNotAvailable(RuntimeError): reason = "rankweave_not_available" +class _ClassicWeights(dict[str, float]): + """Mark caller-omitted weights selecting classic Cormack RRF.""" + + def _no_transport( _channels: dict[str, list[str]], _weights: dict[str, float], -) -> list[dict[str, Any]]: +) -> object: raise RankWeaveNotAvailable( "rankweave_not_available: RankWeave ranking port is not configured. " "Pass RANKWEAVE_DISABLED=0 (default) or a transport= callable. " @@ -173,7 +177,12 @@ def _owner_channel_evidence( """Index one RankWeave owner calculation by item identifier.""" return { item_id: _evidence_from_owner_hit(hit) - for hit in _owner_rrf_hits(channels, weights, eta) + for hit in _owner_rrf_hits( + channels, + weights, + eta, + classic=isinstance(weights, _ClassicWeights), + ) if (item_id := _item_id_from_hit(hit)) } @@ -232,17 +241,25 @@ def to_json(self) -> list[dict[str, Any]]: return [item.to_json() for item in self.items] +@dataclass(frozen=True) +class _OwnerRankingEnvelope: + """RankWeave hits produced by the trusted in-process adapter.""" + + hits: tuple[object, ...] + + def _owner_rrf_hits( channels: Mapping[str, Sequence[str]], weights: Mapping[str, float], eta: int, *, limit: int | None = None, + classic: bool = False, ) -> list[object]: """Return RankWeave-owned classic or convex-weighted RRF results.""" try: rw = _import_rankweave() - if all(float(weights.get(name) or 0.0) == 1.0 for name in channels): + if classic: return list( rw.reciprocal_rank_fuse( channels, @@ -287,22 +304,34 @@ def project_ranking_list( owns. Transport extra fields are ignored so RankWeave cannot invent a missing signal. """ - if not isinstance(raw, list): + if isinstance(raw, _OwnerRankingEnvelope): + raw_hits = list(raw.hits) + evidence_by_post_id = { + item_id: _evidence_from_owner_hit(hit) + for hit in raw_hits + if (item_id := _item_id_from_hit(hit)) + } + elif isinstance(raw, list): + raw_hits = raw + if not raw_hits: + return RankingList(items=()) + owned_channels = channels or {} + evidence_by_post_id = _owner_channel_evidence( + owned_channels, + ( + weights + if weights is not None + else _ClassicWeights({name: 1.0 for name in owned_channels}) + ), + DEFAULT_RANK_CONSTANT_ETA, + ) + else: raise RankWeaveNotAvailable( "rankweave_not_available: ranking envelope is not a hit list" ) items: list[RankedPost] = [] seen: set[str] = set() - owned_channels = channels or {} - # Parameter-free classic RRF default (ADR 0200 point 1): every - # channel weighs 1.0 unless the caller passes an estimated set. - owned_weights = weights or {name: 1.0 for name in owned_channels} - evidence_by_post_id = _owner_channel_evidence( - owned_channels, - owned_weights, - DEFAULT_RANK_CONSTANT_ETA, - ) - for hit in raw: + for hit in raw_hits: post_id = _item_id_from_hit(hit) title = str(titles_by_id.get(post_id) or "").strip() if not post_id or not title or post_id in seen: @@ -326,14 +355,15 @@ def __call__( self, channels: dict[str, list[str]], weights: dict[str, float], - ) -> list[dict[str, Any]]: + ) -> object: + classic = isinstance(weights, _ClassicWeights) usable = { name: [item_id for item_id in ranks if str(item_id).strip()] for name, ranks in channels.items() if ranks } if not usable: - return [] + return _OwnerRankingEnvelope(hits=()) active_weights = { name: weights[name] for name in usable if name in weights and weights[name] > 0 } @@ -347,13 +377,9 @@ def __call__( active_weights, DEFAULT_RANK_CONSTANT_ETA, limit=DEFAULT_RANKING_LIMIT, + classic=classic, ) - projected: list[dict[str, Any]] = [] - for hit in hits: - item_id = _item_id_from_hit(hit) - if item_id: - projected.append({"item_id": item_id}) - return projected + return _OwnerRankingEnvelope(hits=tuple(hits)) def build_rankweave_client(disabled: bool = False) -> "RankWeaveClient": @@ -369,7 +395,7 @@ class RankWeaveClient: def __init__( self, transport: Callable[ - [dict[str, list[str]], dict[str, float]], list[dict[str, Any]] + [dict[str, list[str]], dict[str, float]], object ] = _no_transport, ) -> None: self._transport = transport @@ -391,7 +417,15 @@ def fuse_rankings( it explicitly; the disclosed per-channel evidence carries whichever weights actually fused. """ - active_weights = weights or {name: 1.0 for name in channels} + if weights is not None and not weights: + raise RankWeaveNotAvailable( + "rankweave_not_available: explicit channel weights are empty" + ) + active_weights = ( + weights + if weights is not None + else _ClassicWeights({name: 1.0 for name in channels}) + ) try: raw = self._transport(channels, active_weights) except RankWeaveNotAvailable: diff --git a/tests/test_rankweave_client.py b/tests/test_rankweave_client.py index 3d05c76ba..a223615dc 100644 --- a/tests/test_rankweave_client.py +++ b/tests/test_rankweave_client.py @@ -181,6 +181,18 @@ def test_library_transport_uses_classic_rrf_without_convex_weights() -> None: ) +def test_explicit_empty_weight_vector_fails_before_transport() -> None: + def transport(*_args: object) -> object: + pytest.fail("invalid explicit weights must not cross the transport boundary") + + with pytest.raises(RankWeaveNotAvailable, match="rankweave_not_available"): + RankWeaveClient(transport=transport).fuse_rankings( + {"temporal": ["post-1"], "lexical": ["post-1"]}, + {"post-1": "Public post"}, + weights={}, + ) + + def test_library_transport_projects_monkeypatched_rrf( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -193,6 +205,7 @@ def reciprocal_rank_fuse( limit: int = 20, rank_constant_eta: int = 60, ) -> list: + captured["calls"] = int(captured.get("calls", 0)) + 1 captured["channels"] = channels captured["limit"] = limit captured["eta"] = rank_constant_eta @@ -244,6 +257,7 @@ def reciprocal_rank_fuse( ) assert captured["eta"] == 60 + assert captured["calls"] == 1 assert payload["rankings"][0]["post_title"] == ( "Pricing renegotiation: revised quote sent" ) @@ -282,6 +296,16 @@ def test_unknown_envelope_fails_closed() -> None: project_ranking_list({"hits": [{"item_id": "spoofed"}]}, {"spoofed": "x"}) +def test_empty_transport_result_does_not_start_an_owner_calculation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "lineageweave.rankweave_client._import_rankweave", + lambda: pytest.fail("empty projection must not call RankWeave"), + ) + assert project_ranking_list([], {}).items == () + + def test_unknown_hit_id_is_dropped_not_repaired() -> None: ranking = project_ranking_list( [{"item_id": "invented"}, {"item_id": "post-2"}], @@ -297,12 +321,12 @@ def test_ranking_channel_evidence_uses_cormack_weighted_rrf() -> None: evidence = ranking_channel_evidence( "post-1", {"temporal": ["post-1"], "lexical": ["post-1"]}, - {"temporal": 1.0, "lexical": 1.0}, + {"temporal": 0.5, "lexical": 0.5}, eta=60, ) by_code = {item.signal_code: item for item in evidence} - assert by_code["lexical"].contribution == 1.0 / 61 - assert by_code["temporal"].contribution == 1.0 / 61 + assert by_code["lexical"].contribution == 0.5 / 61 + assert by_code["temporal"].contribution == 0.5 / 61 assert by_code["lexical"].channel_rank == 1 assert by_code["temporal"].channel_rank == 1 assert by_code["lexical"].rank == 1 @@ -342,7 +366,7 @@ def test_project_ranking_list_ignores_transport_extra_fields() -> None: ], {"post-1": "Public post"}, channels={"temporal": ["post-1"], "lexical": ["post-2"]}, - weights={"temporal": 1.0, "lexical": 1.0}, + weights={"temporal": 0.5, "lexical": 0.5}, ) payload = ranking.to_json() assert payload[0]["channel_evidence"] == [ @@ -350,8 +374,8 @@ def test_project_ranking_list_ignores_transport_extra_fields() -> None: "signal_code": "temporal", "signal_label": "Newest first", "channel_rank": 1, - "weight": 1.0, - "contribution": 1.0 / 61, + "weight": 0.5, + "contribution": 0.5 / 61, "rank": 1, } ] From 674f28051fffcd765f992ba373646f32d5317372 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 09:17:11 +0900 Subject: [PATCH 062/393] test(auth): refresh tokens for long suites --- backend/tests/test_api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index f7127ab4a..21f3f327d 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -273,8 +273,9 @@ def _fetch_demo_analyst_token() -> str: return token_response["access_token"] -@pytest.fixture(scope="module") +@pytest.fixture def demo_analyst_token() -> str: + """Return a fresh token so long-running suites cannot outlive its TTL.""" return _fetch_demo_analyst_token() From 0f0cf4a80c6acb130e8176f31618ccc2f14dc947 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 09:27:51 +0900 Subject: [PATCH 063/393] ops(compose): pin canonical project lifecycle --- AGENTS.md | 14 +++++++ docker-compose.yml | 2 + .../0224-canonical-compose-project-name.md | 39 +++++++++++++++++++ docs/adr/README.md | 1 + tests/test_frontend_container_contract.py | 7 ++++ 5 files changed, 63 insertions(+) create mode 100644 docs/adr/0224-canonical-compose-project-name.md diff --git a/AGENTS.md b/AGENTS.md index 0b9e33828..8de0e0aca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -237,6 +237,20 @@ vector degrades that pair back to difflib; it never fabricates a score. ## Tests +### Isolated Compose lifecycle + +- The canonical standalone Compose project is `lineageweave` (ADR 0224). +- A test, review, or stacked-PR environment may use an explicit isolated + project name only while that environment is needed. Once its stated test or + review objective has succeeded, preserve the relevant evidence and port any + required behavior into the canonical Compose contract, then run `docker + compose -p down` so its containers and network do not become + a second production-looking stack. +- Never use `down -v` or otherwise delete named volumes without separate, + explicit authorization. Resolve the exact project from Compose labels before + cleanup; never target a glob, directory root, or another agent's active + environment. + ```bash # backend extra compiles fast-mlsirm's PyO3 core -- needs rustc 1.97.1 # (see backend/Dockerfile). Without it, pip falls over at build time. diff --git a/docker-compose.yml b/docker-compose.yml index e2990df32..924bd3092 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,5 @@ +name: lineageweave + services: postgres: # Built (not bind-mounted) so the keycloak-db init script and the diff --git a/docs/adr/0224-canonical-compose-project-name.md b/docs/adr/0224-canonical-compose-project-name.md new file mode 100644 index 000000000..c906e2124 --- /dev/null +++ b/docs/adr/0224-canonical-compose-project-name.md @@ -0,0 +1,39 @@ +# ADR 0224: Pin the canonical Compose project name + +## Status + +Accepted — 2026-08-26 + +## Context + +Docker Compose otherwise derives its project name from the checkout directory. +Stacked PR worktrees therefore produced several production-looking +`lineageweave-*` projects with duplicated networks and stateful services. That +made the product preview ambiguous and could point operators at the wrong +PostgreSQL or identity service. + +## Decision + +The repository Compose file declares `name: lineageweave`. Normal `docker +compose` commands therefore converge on one canonical standalone stack, +regardless of the checkout directory name. + +An isolated test or review environment may override the name explicitly with +`docker compose -p `. Such an override is test infrastructure, +not another canonical deployment. After its declared objective succeeds, its +evidence and any required behavior are retained, then its containers and +network are removed with `docker compose -p down`. Named +volumes are never deleted as part of project-name consolidation without a +separate, explicit authorization. + +The canonical standalone stack retains its synthetic local Keycloak fallback. +Organization-integrated deployments configure the central Keyverse issuer as +required by ADR 0028 and ADR 0156; the Compose project name does not change the +identity-provider trust boundary. + +## Consequences + +- `docker compose up` consistently creates the `lineageweave` project. +- Preview and operational instructions have one unambiguous project name. +- Parallel review stacks must opt into a distinct name and ports deliberately. +- Existing noncanonical volumes remain recoverable until separately retired. diff --git a/docs/adr/README.md b/docs/adr/README.md index eadef2874..492097d78 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -22,6 +22,7 @@ decision from them. | Evidence operations Dashboard (`/`) | [0206](0206-evidence-operations-dashboard.md) | | [`temporal-topic-context-influence-research.md`](../temporal-topic-context-influence-research.md) | [0210](0210-temporal-topic-context-influence-dashboard.md) | | [`python-mathematical-compute-boundary-audit.md`](../doctoring/python-mathematical-compute-boundary-audit.md) | [0208](0208-externalize-local-mathematical-compute.md) | +| Canonical Docker Compose project (`name: lineageweave`) | [0224](0224-canonical-compose-project-name.md) | [0011](0011-prov-o-standard-relations.md) and [0065](0065-prov-o-provenance-boundary.md) cite the dated W3C PROV-O and PROV-DM Recommendations (https://www.w3.org/TR/2013/REC-prov-o-20130430/ and https://www.w3.org/TR/2013/REC-prov-dm-20130430/). diff --git a/tests/test_frontend_container_contract.py b/tests/test_frontend_container_contract.py index 8970f34f7..546198405 100644 --- a/tests/test_frontend_container_contract.py +++ b/tests/test_frontend_container_contract.py @@ -6,6 +6,13 @@ _ROOT = Path(__file__).resolve().parents[1] +def test_compose_uses_the_canonical_project_name() -> None: + """Directory names must not create duplicate production-like stacks.""" + compose = (_ROOT / "docker-compose.yml").read_text(encoding="utf-8") + + assert compose.startswith("name: lineageweave\n") + + def test_compose_and_frontend_image_share_vite_build_arguments() -> None: """Compose overrides must reach the names Vite reads during its build.""" compose = (_ROOT / "docker-compose.yml").read_text(encoding="utf-8") From 72ef6dd6fde7863340af925ba068acd4a9138185 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 09:39:29 +0900 Subject: [PATCH 064/393] fix(rankings): keep legacy transport evidence unavailable --- lineageweave/rankweave_client.py | 18 +++++------------- tests/test_rankweave_client.py | 23 ++++++++++------------- 2 files changed, 15 insertions(+), 26 deletions(-) diff --git a/lineageweave/rankweave_client.py b/lineageweave/rankweave_client.py index 1a9b6f34a..2abc612a0 100644 --- a/lineageweave/rankweave_client.py +++ b/lineageweave/rankweave_client.py @@ -300,9 +300,10 @@ def project_ranking_list( ) -> RankingList: """Accept transport output. Unknown shapes fail closed. Hidden ids drop. - Channel evidence is attached from ``channels`` LineageWeave already - owns. Transport extra fields are ignored so RankWeave cannot invent - a missing signal. + Channel evidence is accepted only from the trusted in-process owner + envelope. Legacy list transports retain their ordering but expose an + empty breakdown; re-fusing their inputs could diverge from that ordering. + Transport extra fields are ignored so a transport cannot invent a signal. """ if isinstance(raw, _OwnerRankingEnvelope): raw_hits = list(raw.hits) @@ -315,16 +316,7 @@ def project_ranking_list( raw_hits = raw if not raw_hits: return RankingList(items=()) - owned_channels = channels or {} - evidence_by_post_id = _owner_channel_evidence( - owned_channels, - ( - weights - if weights is not None - else _ClassicWeights({name: 1.0 for name in owned_channels}) - ), - DEFAULT_RANK_CONSTANT_ETA, - ) + evidence_by_post_id = {} else: raise RankWeaveNotAvailable( "rankweave_not_available: ranking envelope is not a hit list" diff --git a/tests/test_rankweave_client.py b/tests/test_rankweave_client.py index a223615dc..c79dba809 100644 --- a/tests/test_rankweave_client.py +++ b/tests/test_rankweave_client.py @@ -155,13 +155,13 @@ def fake_transport( "post_id": "post-2", "post_title": "Pricing renegotiation: revised quote sent", "fused_rank": 1, - "channel_evidence": _lexical_then_temporal("post-2", 1), + "channel_evidence": [], }, { "post_id": "post-1", "post_title": "Public post", "fused_rank": 2, - "channel_evidence": _lexical_then_temporal("post-1", 2), + "channel_evidence": [], }, ] serialized = json.dumps(payload) @@ -355,7 +355,13 @@ def test_ranking_channel_evidence_tie_breaks_by_signal_code() -> None: assert evidence[0].contribution == evidence[1].contribution == 0.5 / 61 -def test_project_ranking_list_ignores_transport_extra_fields() -> None: +def test_project_ranking_list_does_not_refuse_legacy_transport_for_evidence( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "lineageweave.rankweave_client._owner_channel_evidence", + lambda *_args, **_kwargs: pytest.fail("legacy ordering must not be re-fused"), + ) ranking = project_ranking_list( [ { @@ -369,16 +375,7 @@ def test_project_ranking_list_ignores_transport_extra_fields() -> None: weights={"temporal": 0.5, "lexical": 0.5}, ) payload = ranking.to_json() - assert payload[0]["channel_evidence"] == [ - { - "signal_code": "temporal", - "signal_label": "Newest first", - "channel_rank": 1, - "weight": 0.5, - "contribution": 0.5 / 61, - "rank": 1, - } - ] + assert payload[0]["channel_evidence"] == [] serialized = json.dumps(payload) assert "theta" not in serialized assert "invented" not in serialized From c6d882fe9c83ad5460c4c02b03d32f97c56a3291 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 09:42:48 +0900 Subject: [PATCH 065/393] fix(dashboard): keep implementation details out of customer copy --- AGENTS.md | 8 +++++ .../adr/0206-evidence-operations-dashboard.md | 15 ++++++--- docs/product-technical-gap-baseline.md | 8 ++--- .../components/OperationsDashboard.test.tsx | 12 ++++--- .../src/components/OperationsDashboard.tsx | 31 +++++++------------ 5 files changed, 43 insertions(+), 31 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8de0e0aca..1486c6ccf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,14 @@ documents unless an ADR explicitly promotes a decision from them -- to its governing ADR. Update research notes as literature changes; never use them to introduce an untracked architecture decision. +Customer-facing copy must help the reader take the next product action. Do +not expose implementation boundaries, provider or package names, schema +versions, internal status/reason codes, environment variables, transport +setup, hashes, or developer remediation instructions as explanatory UI copy. +Keep that evidence in governed audit/admin surfaces and logs; translate a +customer-visible state into the source, decision, retry, or administrator +action the reader can actually take. + ## Hard rule: no real data in repository artifacts This repository ships **synthetic fixtures only** (`lineageweave/fixtures.py`) diff --git a/docs/adr/0206-evidence-operations-dashboard.md b/docs/adr/0206-evidence-operations-dashboard.md index a1235c232..5e48ba6c2 100644 --- a/docs/adr/0206-evidence-operations-dashboard.md +++ b/docs/adr/0206-evidence-operations-dashboard.md @@ -78,10 +78,17 @@ provenance. a fact cannot be both. Missing facts carry no invented value or evidence span and inherit the analysis run and authorized-source boundary through their classification parent. -9. Project journeys group events only by an explicit source project or stored - semantic project mention. A multi-project post may appear in multiple - journeys. Unbound events remain visible as unassigned evidence and are not - attached to the nearest project. +9. Project membership uses only an explicit source project or stored semantic + project mention. A multi-project post may appear in multiple groups; + unbound events remain unassigned. A chronological sort of those records is + only a **project-observed-event list**, not a Project Journey. Project + Journey starts, predecessors, branches, and transitions consume a + provenance-bearing TEPP TDT/CHRONOS result. Previous projects, customer + requests, procurement notices, negotiated/direct bidding, external + sensing, internal discussions, and sales leads are all admissible starts or + predecessors when the accepted TEPP artifact and source evidence connect + them. LineageWeave never chooses a fixed first stage or promotes nearest-date + ordering to a lineage edge. 10. A repeat-issue result carries both the issue-pattern evidence and any source-supported improvement action. Its Dashboard flow is As-Is evidence to To-Be action: rebid history retrieval, originating-order/specification diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 3ac41f4a0..00008e677 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -12,7 +12,7 @@ | Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification and `claim_received` → `cause_confirmed` milestones with cited spans and observed source clocks | Stacked candidate reports open/resolved/evidence-missing counts and exact elapsed time only for paired observed endpoints; every required answer and endpoint is cited or explicitly missing; authenticated synthetic runtime passed, while authorized-corpus re-analysis remains pending | | Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus separate rebid-response and handover milestone pairs | Stacked candidate reports open/resolved/evidence-missing rebid and handover lifecycles without a delay threshold or invented elapsed endpoint; authenticated synthetic runtime passed, while authorized-corpus re-analysis remains pending | | External information count/rate and sales/project relation | ADR 0206; semantic `external_information` classification inside Dashboard GNB | Candidate GNB destination filters the Dashboard to external evidence; no separate Board by product decision; authenticated synthetic runtime passed with the honest zero-result state | -| Project-specific journey | Explicit source/semantic project membership plus event-time ordering | Candidate API preserves every explicit project membership and the UI orders each journey chronologically; authenticated synthetic runtime passed | +| Project-specific journey | Explicit source/semantic project membership plus provenance-bearing TEPP TDT/CHRONOS predecessor, branch, and transition results | Candidate API preserves every explicit project membership, but its local event-time sort is only an observed-event list. It is no longer labeled as a Project Journey. Full journey delivery remains open until the accepted TEPP producer artifact is persisted and rendered; no fixed sales/order start or nearest-date edge is accepted. | | Repeat issue to design improvement | `repeat_issue`, `issue_pattern`, and `improvement_action` cited facts | Candidate semantic contract; design-system connector acceptance pending | | Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback; authenticated runtime acceptance pending | | Similar VOC, customer cohort, prior action | Persisted repeat-issue candidate semantics plus orchestrator pair adjudication and extractive evidence | Candidate live post endpoint and post-detail UI implemented; authenticated runtime acceptance pending | @@ -50,9 +50,9 @@ snapshot. The `f0b96029` Storybook build was rendered at 1440×1100 and 402×1200 with synthetic evidence; `416fd19d` changes only post-navigation request isolation. Desktop inspection showed all four case kinds, five non-conflated metrics, -project-journey ordering, cited facts, and evidence actions without horizontal +project-observed-event ordering, cited facts, and evidence actions without horizontal card overflow. Narrow inspection showed two-column metrics, readable cards and -44px-class actions; the project journey remains intentionally horizontally +44px-class actions; the project event list remains intentionally horizontally scrollable. No identifying runtime record or screenshot is committed. The `EvidenceReady`, `NarrowViewport`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` scenes cover the ADR 0206 state inventory. @@ -82,7 +82,7 @@ At the repaired dashboard head, the `EvidenceReady` and `NarrowViewport` stories were re-rendered locally with synthetic data at desktop and iPhone 13 viewports. The desktop shows separate Event/post values and evidence actions; the narrow view preserves readable cards and 44px-class actions while -keeping the multi-step project journey horizontally scrollable. These images +keeping the multi-step project event list horizontally scrollable. These images remain local audit evidence and are not committed. An authenticated synthetic OIDC audit then found that the mobile breakpoint hid the entire GNB despite having no drawer implementation. Candidate diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx index c61954710..08dc61138 100644 --- a/frontend/src/components/OperationsDashboard.test.tsx +++ b/frontend/src/components/OperationsDashboard.test.tsx @@ -113,11 +113,13 @@ describe("OperationsDashboardView", () => { expect(screen.getByText("업무 관계 · 프로젝트")).toBeInTheDocument(); }); - it("places multi-project evidence in every explicit journey and orders events oldest first", () => { + it("places multi-project evidence in every observed-event group without calling it a journey", () => { const later = { ...data.cases[0], post_id: "post-later", occurred_at: "2026-08-20T00:00:00Z", project_names: ["Synthetic Grid Upgrade", "Synthetic Relay Renewal"] }; const earlier = { ...data.cases[0], post_id: "post-earlier", occurred_at: "2026-08-01T00:00:00Z", project_names: ["Synthetic Grid Upgrade"] }; render( undefined} />); + expect(screen.getByRole("heading", { name: "프로젝트별 관측 Event" })).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "프로젝트 여정" })).not.toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Synthetic Relay Renewal" })).toBeInTheDocument(); const primaryJourney = screen.getByRole("heading", { name: "Synthetic Grid Upgrade" }).parentElement; expect(primaryJourney?.querySelectorAll("time")[0]).toHaveAttribute("datetime", earlier.occurred_at); @@ -133,8 +135,9 @@ describe("OperationsDashboardView", () => { it("keeps unavailable topic measurement actionable without a fallback score", () => { render( undefined} />); - expect(screen.getByText("Topic model influence를 아직 표시할 수 없습니다.")).toBeInTheDocument(); - expect(screen.getByText("TEPP posterior topic 계약 결과를 먼저 완료하세요.")).toBeInTheDocument(); + expect(screen.getByText("글 영향도를 아직 확인할 수 없습니다.")).toBeInTheDocument(); + expect(screen.getByText("분석 대상 글의 사건 시점과 조직 소속을 확인한 뒤 다시 분석하세요.")).toBeInTheDocument(); + expect(screen.queryByText(/TEPP|fast-mlsirm|topic_context_posterior/)).not.toBeInTheDocument(); expect(screen.queryByText(/추정 점수/)).not.toBeInTheDocument(); }); @@ -169,7 +172,8 @@ describe("OperationsDashboardView", () => { render(); expect(screen.getAllByText("4.25")).toHaveLength(2); expect(screen.getByText((_, element) => element?.tagName === "LI" && element.textContent === "2026-08-01 · birth")).toBeInTheDocument(); - expect(screen.getByText("tepp-snapshot")).toBeInTheDocument(); + expect(screen.getByText("분석 기준 확인")).toBeInTheDocument(); + expect(screen.queryByText(/tepp-snapshot|fast-mlsirm|rust_gpu/)).not.toBeInTheDocument(); await userEvent.click(screen.getAllByRole("button", { name: "근거 글 열기" })[1]); expect(onOpenPost).toHaveBeenCalledWith("post-2"); }); diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx index fc26540d4..e86529a98 100644 --- a/frontend/src/components/OperationsDashboard.tsx +++ b/frontend/src/components/OperationsDashboard.tsx @@ -72,7 +72,7 @@ export function OperationsDashboard({ accessToken, externalOnly = false, onOpenP /** Renders a completed Dashboard response for runtime and Storybook scenes. */ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost }: { data: OperationsDashboardResponse; externalOnly?: boolean; onOpenPost: (postId: string) => void }) { const cases = externalOnly ? data.cases.filter((item) => item.case_kind_code === "external_information") : data.cases; - const journeys = Object.entries( + const observedProjectEvents = Object.entries( cases.reduce>((groups, item) => { const projects = item.project_names ?? (item.project_name ? [item.project_name] : []); projects.forEach((project) => (groups[project] ??= []).push(item)); @@ -122,10 +122,10 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost {!externalOnly ? ( ) : null} - {!externalOnly && journeys.length ? ( -
    -

    프로젝트 여정

    - {journeys.map(([project, events]) => ( + {!externalOnly && observedProjectEvents.length ? ( +
    +

    프로젝트별 관측 Event

    + {observedProjectEvents.map(([project, events]) => (

    {project}

      @@ -193,20 +193,17 @@ export function TopicContextInfluence({ data, onOpenPost }: { data: OperationsDa return (
      -

      TEPP · fast-mlsirm

      시간 흐름별 Topic model influence

      +

      글 영향도

      시간 흐름별 Topic model influence

      사업 가치가 아닌, 해당 글을 제외했을 때 Topic·조직 수준 모형이 변하는 정도입니다.

      {topicContext.status_code === "unavailable" ? (
      - Topic model influence를 아직 표시할 수 없습니다. -

      {topicContext.next_action}

      -
        {topicContext.required_contracts.map((contract) => ( -
      • {contract.authority} · {contract.schema_version} · {contract.state_code === "persisted" ? "저장 완료" : "승인 결과 없음"}
      • - ))}
      + 글 영향도를 아직 확인할 수 없습니다. +

      분석 대상 글의 사건 시점과 조직 소속을 확인한 뒤 다시 분석하세요.

      ) : ( <> -

      {topicContext.next_action}

      +

      각 글의 영향도와 불확실성을 비교하고 원문 근거를 확인하세요.

      {topicContext.topics.map((topic) => (
      @@ -247,14 +244,10 @@ export function TopicContextInfluence({ data, onOpenPost }: { data: OperationsDa
      {topicContext.model_run ? (
      - 모형·실행 근거 + 분석 기준 확인
      -
      TEPP run
      {topicContext.model_run.tepp_run_id}
      -
      TEPP snapshot
      {topicContext.model_run.tepp_snapshot_id}
      -
      Snapshot
      {topicContext.model_run.source_snapshot_sha256}
      -
      Knowledge cutoff
      -
      Posterior draws
      {topicContext.model_run.posterior_draw_count} · {topicContext.model_run.posterior_draw_set_id}
      -
      fast-mlsirm
      {topicContext.model_run.fast_mlsirm_version} · {topicContext.model_run.compute_backend_code} · {topicContext.model_run.precision_code}
      +
      반영 기준 시각
      +
      Topic 수
      {topicContext.model_run.topic_count}
      ) : null} From 9698186560dd6bbb4c6b3694097ef186240c10a8 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 09:46:40 +0900 Subject: [PATCH 066/393] fix(ui): translate internal ranking states into customer actions --- frontend/src/App.test.tsx | 10 ++++---- frontend/src/App.tsx | 12 +++++----- frontend/src/i18n.test.ts | 7 +++++- frontend/src/i18n.ts | 48 +++++++++++++++++++++++---------------- 4 files changed, 46 insertions(+), 31 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index fa941721d..414cadd5e 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -3088,11 +3088,13 @@ describe("App, authenticated", () => { ); }); - it("names RankWeave unavailability on home rankings instead of inventing a fused score", async () => { + it("gives a customer action when ranking evidence is unavailable", async () => { stubBackend(); render(); - expect(await screen.findByText("Rankings · RankWeave not available")).toBeInTheDocument(); + expect(await screen.findByText("Rankings are not ready. Refresh after the source evidence is connected.")).toBeInTheDocument(); + expect(screen.getByText("Evidence needed")).toBeInTheDocument(); + expect(screen.queryByText(/RankWeave|rankweave_not_available/)).not.toBeInTheDocument(); expect(screen.queryByText("Pricing renegotiation: revised quote sent")).not.toBeInTheDocument(); }); @@ -3196,11 +3198,11 @@ describe("App, authenticated", () => { name: /open ranking: public post/i, }); expect(rankingButton).toHaveTextContent("Public post"); - expect(rankingButton).toHaveTextContent("Rankings · rankweave"); + expect(screen.getByText("Evidence combined")).toBeInTheDocument(); expect(rankingButton).toHaveTextContent("rank 1"); expect( screen.getByText( - "RankWeave fused newest-first and title-overlap ranks. This is not a calibrated score.", + "Compare recency and content-relevance evidence, then open the source post before acting. This rank is not a performance score.", ), ).toBeInTheDocument(); expect( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b0dc2ff79..179c92952 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3351,7 +3351,7 @@ function RankingsPanel({ setError(null); fetchRankings(accessToken) .then(setRanking) - .catch((err) => setError(String(err))); + .catch(() => setError(t("Rankings could not be loaded. Retry in a moment."))); }, [accessToken]); return ( @@ -3361,24 +3361,24 @@ function RankingsPanel({ {ranking && ( {ranking.status === "accepted" - ? "rankweave" - : `rankweave · ${ranking.status_reason ?? "unavailable"}`} + ? t("Evidence combined") + : t("Evidence needed")} )}
    {error &&

    {error}

    } {ranking === null && !error &&

    {t("Loading rankings...")}

    } {ranking && ranking.status === "unavailable" && ( -

    {t("Rankings · RankWeave not available")}

    +

    {t("Rankings are not ready. Refresh after the source evidence is connected.")}

    )} {ranking && ranking.status === "accepted" && ranking.rankings.length === 0 && ( -

    {t("No fused rankings from RankWeave.")}

    +

    {t("No comparable posts are visible. Check the period and access scope.")}

    )} {ranking && ranking.rankings.length > 0 && ( <>

    {t( - "RankWeave fused newest-first and title-overlap ranks. This is not a calibrated score.", + "Compare recency and content-relevance evidence, then open the source post before acting. This rank is not a performance score.", )}

      diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index b647a585b..f9bcabf2f 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -73,7 +73,12 @@ describe("i18n", () => { "This is an ontology neighborhood, not Event Lineage.", "Rankings", "Title overlap", - "RankWeave fused newest-first and title-overlap ranks. This is not a calibrated score.", + "Evidence combined", + "Evidence needed", + "Rankings could not be loaded. Retry in a moment.", + "Rankings are not ready. Refresh after the source evidence is connected.", + "No comparable posts are visible. Check the period and access scope.", + "Compare recency and content-relevance evidence, then open the source post before acting. This rank is not a performance score.", "Workspace navigation", "Observed calendar events", "No observed calendar events are available.", diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 2d19b1cb8..9ed35f342 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -37,16 +37,18 @@ const TRANSLATIONS: Partial>> = { "Log out": "로그아웃", Calendar: "캘린더", Rankings: "순위", - "Rankings · RankWeave not available": "순위 · RankWeave를 사용할 수 없음", - "Rankings · rankweave": "순위 · rankweave", + "Evidence combined": "근거 결합 완료", + "Evidence needed": "근거 확인 필요", "Loading rankings...": "순위를 불러오는 중...", - "No fused rankings from RankWeave.": "RankWeave가 융합한 순위가 없습니다.", + "Rankings could not be loaded. Retry in a moment.": "순위를 불러오지 못했습니다. 잠시 후 다시 시도하세요.", + "Rankings are not ready. Refresh after the source evidence is connected.": "순위를 확인할 수 없습니다. 원문 근거를 연결한 뒤 새로고침하세요.", + "No comparable posts are visible. Check the period and access scope.": "비교할 수 있는 글이 없습니다. 기간과 접근 범위를 확인하세요.", "Fused rankings": "융합 순위", "Open ranking: {title}": "순위 열기: {title}", "rank {rank}": "순위 {rank}", "Title overlap": "제목 겹침", - "RankWeave fused newest-first and title-overlap ranks. This is not a calibrated score.": - "RankWeave가 최신순과 제목 겹침 순위를 융합했습니다. 보정된 점수가 아닙니다.", + "Compare recency and content-relevance evidence, then open the source post before acting. This rank is not a performance score.": + "최신성과 내용 연관성 근거를 비교하고 실행 전에 원문을 여세요. 이 순위는 성과 점수가 아닙니다.", "Ranking evidence for {title}": "{title}의 순위 근거", "{label} rank {rank}, contribution {contribution}": "{label} 순위 {rank}, 기여 {contribution}", @@ -542,16 +544,18 @@ const TRANSLATIONS: Partial>> = { "Log out": "退出登录", Calendar: "日历", Rankings: "排名", - "Rankings · RankWeave not available": "排名 · RankWeave 不可用", - "Rankings · rankweave": "排名 · rankweave", + "Evidence combined": "证据已合并", + "Evidence needed": "需要核对证据", "Loading rankings...": "正在加载排名...", - "No fused rankings from RankWeave.": "RankWeave 未返回融合排名。", + "Rankings could not be loaded. Retry in a moment.": "无法加载排名,请稍后重试。", + "Rankings are not ready. Refresh after the source evidence is connected.": "排名尚未就绪。连接原始证据后请刷新。", + "No comparable posts are visible. Check the period and access scope.": "没有可比较的文章。请检查时间范围和访问权限。", "Fused rankings": "融合排名", "Open ranking: {title}": "打开排名:{title}", "rank {rank}": "排名 {rank}", "Title overlap": "标题重叠", - "RankWeave fused newest-first and title-overlap ranks. This is not a calibrated score.": - "RankWeave 融合了最新优先和标题重叠排名。这不是校准分数。", + "Compare recency and content-relevance evidence, then open the source post before acting. This rank is not a performance score.": + "请比较时效性和内容相关证据,并在采取行动前打开原文。该排名不是绩效分数。", "Ranking evidence for {title}": "{title} 的排名证据", "{label} rank {rank}, contribution {contribution}": "{label} 排名 {rank},贡献 {contribution}", @@ -1063,16 +1067,18 @@ const TRANSLATIONS: Partial>> = { "Log out": "ログアウト", Calendar: "カレンダー", Rankings: "ランキング", - "Rankings · RankWeave not available": "ランキング · RankWeave を利用できません", - "Rankings · rankweave": "ランキング · rankweave", + "Evidence combined": "根拠の結合完了", + "Evidence needed": "根拠の確認が必要", "Loading rankings...": "ランキングを読み込み中...", - "No fused rankings from RankWeave.": "RankWeave の融合ランキングはありません。", + "Rankings could not be loaded. Retry in a moment.": "ランキングを読み込めませんでした。しばらくしてから再試行してください。", + "Rankings are not ready. Refresh after the source evidence is connected.": "ランキングはまだ確認できません。原文の根拠を接続してから更新してください。", + "No comparable posts are visible. Check the period and access scope.": "比較できる投稿がありません。期間とアクセス範囲を確認してください。", "Fused rankings": "融合ランキング", "Open ranking: {title}": "ランキングを開く: {title}", "rank {rank}": "順位 {rank}", "Title overlap": "タイトル一致", - "RankWeave fused newest-first and title-overlap ranks. This is not a calibrated score.": - "RankWeave が新しい順とタイトル一致の順位を融合しました。校正されたスコアではありません。", + "Compare recency and content-relevance evidence, then open the source post before acting. This rank is not a performance score.": + "新しさと内容関連性の根拠を比較し、対応前に原文を開いてください。この順位は業績スコアではありません。", "Ranking evidence for {title}": "{title} の順位根拠", "{label} rank {rank}, contribution {contribution}": "{label} 順位 {rank}、寄与 {contribution}", @@ -1563,16 +1569,18 @@ const TRANSLATIONS: Partial>> = { "Log out": "Đăng xuất", Calendar: "Lịch", Rankings: "Xếp hạng", - "Rankings · RankWeave not available": "Xếp hạng · RankWeave không khả dụng", - "Rankings · rankweave": "Xếp hạng · rankweave", + "Evidence combined": "Đã kết hợp bằng chứng", + "Evidence needed": "Cần kiểm tra bằng chứng", "Loading rankings...": "Đang tải xếp hạng...", - "No fused rankings from RankWeave.": "Không có xếp hạng hợp nhất từ RankWeave.", + "Rankings could not be loaded. Retry in a moment.": "Không thể tải xếp hạng. Hãy thử lại sau giây lát.", + "Rankings are not ready. Refresh after the source evidence is connected.": "Xếp hạng chưa sẵn sàng. Hãy làm mới sau khi kết nối bằng chứng gốc.", + "No comparable posts are visible. Check the period and access scope.": "Không có bài viết nào có thể so sánh. Hãy kiểm tra khoảng thời gian và phạm vi truy cập.", "Fused rankings": "Xếp hạng hợp nhất", "Open ranking: {title}": "Mở xếp hạng: {title}", "rank {rank}": "hạng {rank}", "Title overlap": "Trùng tiêu đề", - "RankWeave fused newest-first and title-overlap ranks. This is not a calibrated score.": - "RankWeave đã hợp nhất hạng mới nhất trước và trùng tiêu đề. Đây không phải điểm đã hiệu chỉnh.", + "Compare recency and content-relevance evidence, then open the source post before acting. This rank is not a performance score.": + "Hãy so sánh bằng chứng về độ mới và mức liên quan nội dung, rồi mở bài gốc trước khi hành động. Xếp hạng này không phải điểm hiệu suất.", "Ranking evidence for {title}": "Bằng chứng xếp hạng cho {title}", "{label} rank {rank}, contribution {contribution}": "{label} hạng {rank}, đóng góp {contribution}", From 169f599fd8840bb8de8e38238a43c3bc32b9614f Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 09:54:10 +0900 Subject: [PATCH 067/393] test(auth): refresh cached token at issuer expiry --- backend/tests/test_api.py | 42 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 21f3f327d..08f909f28 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -15,6 +15,7 @@ import asyncio import math import os +import time import uuid from contextlib import closing from pathlib import Path @@ -37,6 +38,7 @@ _KEYCLOAK_BASE_URL = os.environ.get("LINEAGEWEAVE_TEST_KEYCLOAK_BASE_URL", "http://localhost:18080") _VALKEY_URL = os.environ.get("LINEAGEWEAVE_TEST_VALKEY_URL", "redis://localhost:16379/0") _REALM = "lineageweave-demo" +_demo_analyst_token_cache: tuple[str, int] | None = None _MIGRATION_PATH = Path(__file__).resolve().parents[2] / "migrations" / "0001_initial_schema.sql" _REGISTRY_MIGRATION = Path(__file__).resolve().parents[2] / "migrations" / "0018_analysis_run_registry.sql" _RETENTION_MIGRATION = Path(__file__).resolve().parents[2] / "migrations" / "0020_analysis_run_retention_purge.sql" @@ -273,10 +275,46 @@ def _fetch_demo_analyst_token() -> str: return token_response["access_token"] +def _current_demo_analyst_token() -> str: + """Reuse the live token until its issuer-recorded expiration instant.""" + global _demo_analyst_token_cache + + if _demo_analyst_token_cache is not None: + token, expires_at = _demo_analyst_token_cache + if time.time() < expires_at: + return token + + token = _fetch_demo_analyst_token() + expires_at = int(jwt.decode(token, options={"verify_signature": False})["exp"]) + _demo_analyst_token_cache = (token, expires_at) + return token + + @pytest.fixture def demo_analyst_token() -> str: - """Return a fresh token so long-running suites cannot outlive its TTL.""" - return _fetch_demo_analyst_token() + """Return a cached live token, refreshing it at the issuer's exact expiry.""" + return _current_demo_analyst_token() + + +def test_demo_analyst_token_cache_refreshes_only_at_issuer_expiry(monkeypatch: pytest.MonkeyPatch) -> None: + """The integration suite reuses a token before ``exp`` and refreshes at ``exp``.""" + global _demo_analyst_token_cache + + issued = iter( + ( + jwt.encode({"exp": 101}, "test-key-for-cache-expiry-check-1", algorithm="HS256"), + jwt.encode({"exp": 202}, "test-key-for-cache-expiry-check-2", algorithm="HS256"), + ) + ) + monkeypatch.setattr(__name__ + "._fetch_demo_analyst_token", lambda: next(issued)) + monkeypatch.setattr(__name__ + ".time.time", lambda: 100) + _demo_analyst_token_cache = None + first = _current_demo_analyst_token() + assert _current_demo_analyst_token() == first + + monkeypatch.setattr(__name__ + ".time.time", lambda: 101) + assert _current_demo_analyst_token() != first + _demo_analyst_token_cache = None @pytest.fixture From d314855c7e6a7499db5cae1eced303ec187e3fdb Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 10:03:08 +0900 Subject: [PATCH 068/393] fix(ui): keep analysis internals out of action copy --- frontend/src/App.test.tsx | 46 +++++++++++++++++++-------------------- frontend/src/App.tsx | 40 +++++++++++++++++++--------------- 2 files changed, 45 insertions(+), 41 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 414cadd5e..1004c28a1 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -649,7 +649,7 @@ describe("App, authenticated", () => { JSON.stringify({ detail: payload.run_kind_code === "analysis_run_tepp" - ? "Connect a TEPP transport from a Failed TEPP row; this endpoint does not invent a measurement." + ? "Ask an administrator to restore temporal measurement, then re-run this source set." : "Rebuild the period report from the Reports panel.", }), { status: 422, headers: { "Content-Type": "application/json" } }, @@ -3248,10 +3248,10 @@ describe("App, authenticated", () => { expect(await screen.findByRole("heading", { name: "Analysis runs" })).toBeInTheDocument(); const list = screen.getByRole("list", { name: "Analysis runs" }); expect(list).toHaveTextContent("Lineage reconstruction · Succeeded · Demo Corp"); - expect(list).toHaveTextContent("TEPP measurement · Failed · Demo Corp"); + expect(list).toHaveTextContent("Temporal measurement · Failed · Demo Corp"); expect(list).toHaveTextContent("Period report · Succeeded · Demo Corp"); expect(list).toHaveTextContent( - "Open this run to see why it failed, then connect the measurement service and re-run.", + "Open this run to review the failure, ask an administrator to restore analysis, then re-run.", ); expect(list).toHaveTextContent("3 documents"); expect(list).not.toHaveTextContent("postgresql://"); @@ -3341,15 +3341,15 @@ describe("App, authenticated", () => { await userEvent.click( screen.getByRole("button", { - name: "Open analysis run: TEPP measurement · Failed · Demo Corp", + name: "Open analysis run: Temporal measurement · Failed · Demo Corp", }), ); expect( - await screen.findByRole("heading", { name: "TEPP measurement · Failed · Demo Corp" }), + await screen.findByRole("heading", { name: "Temporal measurement · Failed · Demo Corp" }), ).toBeInTheDocument(); const teppHistory = screen.getByRole("list", { name: "Analysis run status history" }); expect(teppHistory).toHaveTextContent("Failed 2026-01-12 12:37 · tepp_not_available"); - expect(screen.getByText(/cutoff corpus TEPP would measure/i)).toBeInTheDocument(); + expect(screen.getByText(/source set temporal measurement would measure/i)).toBeInTheDocument(); expect(teppHistory).not.toHaveTextContent("Succeeded"); }); @@ -3401,7 +3401,7 @@ describe("App, authenticated", () => { expect(screen.queryByRole("heading", { name: "Body this run knew" })).not.toBeInTheDocument(); }); - it("tells a running lineage run to refresh the durable outbox", async () => { + it("tells a running lineage run to refresh its progress", async () => { stubBackend({ runningLineageRun: true }); render(); @@ -3409,12 +3409,12 @@ describe("App, authenticated", () => { name: "Open analysis run: Lineage reconstruction · Running · Demo Corp", }); expect(lineageButton).toHaveTextContent( - "Refresh this run. Start already queued the work on the durable outbox.", + "Refresh this run to see the latest progress.", ); await userEvent.click(lineageButton); expect(screen.getByRole("button", { name: "Start reconstruction" })).toBeInTheDocument(); expect( - screen.getAllByText("Refresh this run. Start already queued the work on the durable outbox."), + screen.getAllByText("Refresh this run to see the latest progress."), ).not.toHaveLength(0); }); @@ -3427,14 +3427,14 @@ describe("App, authenticated", () => { name: "Open analysis run: Lineage reconstruction · Failed · Demo Corp", }); const teppButton = screen.getByRole("button", { - name: "Open analysis run: TEPP measurement · Failed · Demo Corp", + name: "Open analysis run: Temporal measurement · Failed · Demo Corp", }); expect(lineageButton).toHaveTextContent( "Open this run to see why it failed, then retry reconstruction from a current snapshot.", ); expect(lineageButton).not.toHaveTextContent("measurement service"); expect(teppButton).toHaveTextContent( - "Open this run to see why it failed, then connect the measurement service and re-run.", + "Open this run to review the failure, ask an administrator to restore analysis, then re-run.", ); expect(teppButton).not.toHaveTextContent("reconstruction"); }); @@ -3734,17 +3734,17 @@ describe("App, authenticated", () => { await userEvent.click( await screen.findByRole("button", { - name: "Open analysis run: TEPP measurement · Pending · Demo Corp", + name: "Open analysis run: Temporal measurement · Pending · Demo Corp", }), ); expect( - await screen.findByText("These posts are the cutoff corpus TEPP will measure once this run finishes."), + await screen.findByText("These posts are the cutoff corpus temporal measurement will measure once this run finishes."), ).toBeInTheDocument(); expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/this TEPP run measured/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/this temporal measurement run measured/i)).not.toBeInTheDocument(); expect(screen.queryByText(/Reconstruction has not started yet/)).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Start reconstruction" })).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Start TEPP measurement" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Start temporal measurement" })).toBeInTheDocument(); }); it("starts a pending TEPP run through tepp_client and does not invent a theta", async () => { @@ -3753,12 +3753,12 @@ describe("App, authenticated", () => { await userEvent.click( await screen.findByRole("button", { - name: "Open analysis run: TEPP measurement · Pending · Demo Corp", + name: "Open analysis run: Temporal measurement · Pending · Demo Corp", }), ); - await userEvent.click(screen.getByRole("button", { name: "Start TEPP measurement" })); + await userEvent.click(screen.getByRole("button", { name: "Start temporal measurement" })); expect( - await screen.findByRole("heading", { name: "TEPP measurement · Failed · Demo Corp" }), + await screen.findByRole("heading", { name: "Temporal measurement · Failed · Demo Corp" }), ).toBeInTheDocument(); expect(screen.getByText(/tepp_not_available/)).toBeInTheDocument(); expect(screen.queryByText(/theta/i)).not.toBeInTheDocument(); @@ -3775,16 +3775,16 @@ describe("App, authenticated", () => { await userEvent.click( await screen.findByRole("button", { - name: "Open analysis run: TEPP measurement · Failed · Demo Corp", + name: "Open analysis run: Temporal measurement · Failed · Demo Corp", }), ); expect( await screen.findByText( - "Connect a TEPP transport from this Failed row. Request a lineage reconstruction does not invent a measurement.", + "Ask an administrator to restore temporal measurement, then re-run this source set.", ), ).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Request a new TEPP measurement" })).not.toBeInTheDocument(); - expect(screen.queryByRole("heading", { name: "TEPP measurement · Pending · Demo Corp" })).not.toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Temporal measurement · Pending · Demo Corp" })).not.toBeInTheDocument(); expect( fetchMock.mock.calls.some( (call) => String(call[0]).endsWith("/api/analysis-runs") && call[1]?.method === "POST", @@ -3798,11 +3798,11 @@ describe("App, authenticated", () => { await userEvent.click( await screen.findByRole("button", { - name: "Open analysis run: TEPP measurement · Succeeded · Demo Corp", + name: "Open analysis run: Temporal measurement · Succeeded · Demo Corp", }), ); expect( - await screen.findByText("These posts are the cutoff corpus this TEPP run measured."), + await screen.findByText("These posts are the cutoff corpus this temporal measurement run measured."), ).toBeInTheDocument(); expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument(); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 179c92952..d91eb5b28 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2654,7 +2654,13 @@ function PostDetailPopup({ } function analysisRunCaption(run: AnalysisRun): string { - return [run.run_kind_label, run.status_label, run.scope_entity_name ?? run.scope_kind_label] + const kindLabel = + run.run_kind_code === "analysis_run_tepp" + ? "Temporal measurement" + : run.run_kind_code === "analysis_run_topic_lineage" + ? "Topic journey analysis" + : run.run_kind_label; + return [kindLabel, run.status_label, run.scope_entity_name ?? run.scope_kind_label] .filter(Boolean) .join(" · "); } @@ -2674,9 +2680,9 @@ function analysisRunNextAction(run: AnalysisRun): string | null { case "analysis_run_lineage": return "Open this run, then start reconstruction. Reconstruction has not started yet."; case "analysis_run_tepp": - return "Open this run to confirm which posts TEPP will measure. Measurement has not started yet — this is not a calibrated result."; + return "Open this run to confirm the source posts, then start temporal measurement."; case "analysis_run_topic_lineage": - return "Open this run to confirm which posts TEPP will thread into topic lineage. Topic-lineage analysis has not started yet — this is not a calibrated topic result."; + return "Open this run to confirm the source posts, then start topic journey analysis."; case "analysis_run_report": return "Open this run to confirm which posts the period report will use. The report has not been built yet."; default: { @@ -2687,9 +2693,9 @@ function analysisRunNextAction(run: AnalysisRun): string | null { case "analysis_status_failed": switch (run.run_kind_code) { case "analysis_run_tepp": - return "Open this run to see why it failed, then connect the measurement service and re-run."; + return "Open this run to review the failure, ask an administrator to restore analysis, then re-run."; case "analysis_run_topic_lineage": - return "Open this run to see why it failed, then connect the TEPP transport and re-run."; + return "Open this run to review the failure, ask an administrator to restore analysis, then re-run."; case "analysis_run_lineage": return "Open this run to see why it failed, then retry reconstruction from a current snapshot."; case "analysis_run_report": @@ -2700,7 +2706,7 @@ function analysisRunNextAction(run: AnalysisRun): string | null { } } case "analysis_status_running": - return "Refresh this run. Start already queued the work on the durable outbox."; + return "Refresh this run to see the latest progress."; case "analysis_status_succeeded": case "analysis_status_cancelled": case null: @@ -2719,7 +2725,7 @@ function analysisRunEmptyPostsHint(run: AnalysisRun): string { switch (run.run_kind_code) { case "analysis_run_tepp": return ( - "No posts were available at this cutoff for TEPP to measure. " + + "No posts were available at this cutoff for temporal measurement. " + "Open a later run, or ask an administrator to capture a newer snapshot." ); case "analysis_run_topic_lineage": @@ -2753,15 +2759,15 @@ function analysisRunEmptyPostsHint(run: AnalysisRun): string { function analysisRunCorpusHint(run: AnalysisRun): string | null { const isTopicLineage = run.run_kind_code === "analysis_run_topic_lineage"; if (run.run_kind_code !== "analysis_run_tepp" && !isTopicLineage) return null; - const service = isTopicLineage ? "topic-lineage" : "TEPP"; - const result = isTopicLineage ? "a topic-identity result" : "a calibrated result"; + const service = isTopicLineage ? "topic journey analysis" : "temporal measurement"; + const result = isTopicLineage ? "a topic journey result" : "a calibrated result"; const verb = isTopicLineage ? "thread" : "measure"; const verbPast = isTopicLineage ? "threaded" : "measured"; switch (run.status_code) { case "analysis_status_failed": return ( - `These posts are the cutoff corpus ${service} would ${verb}. Connect a TEPP ` + - `transport, then re-run, to replace Failed with ${result}.` + `These posts are the source set ${service} would ${verb}. ` + + `Ask an administrator to restore analysis, then re-run to produce ${result}.` ); case "analysis_status_succeeded": return `These posts are the cutoff corpus this ${service} run ${verbPast}.`; @@ -2900,7 +2906,7 @@ function analysisRunCanStart(run: AnalysisRun): boolean { function analysisRunStartLabel(run: AnalysisRun): string { if (run.run_kind_code === "analysis_run_tepp") { - return "Start TEPP measurement"; + return "Start temporal measurement"; } if (run.run_kind_code === "analysis_run_topic_lineage") { return "Start topic lineage"; @@ -3192,7 +3198,7 @@ function AnalysisRunsPanel({ > {starting ? selected.run_kind_code === "analysis_run_tepp" - ? "Submitting the TEPP request..." + ? "Starting temporal measurement..." : selected.run_kind_code === "analysis_run_topic_lineage" ? "Submitting the topic-lineage request..." : "Reconstructing the cutoff bag..." @@ -3202,10 +3208,8 @@ function AnalysisRunsPanel({ {analysisRunCanRequestTeppRetry(selected) && (

      {selected.run_kind_code === "analysis_run_topic_lineage" - ? "Connect a TEPP transport from this Failed row. Request a " + - "lineage reconstruction does not invent a topic model." - : "Connect a TEPP transport from this Failed row. Request a lineage " + - "reconstruction does not invent a measurement."} + ? "Ask an administrator to restore topic journey analysis, then re-run this source set." + : "Ask an administrator to restore temporal measurement, then re-run this source set."}

      )} {analysisRunReportPeriod(selected) && onSelectReportPeriod && ( @@ -3390,7 +3394,7 @@ function RankingsPanel({ onClick={() => onSelectPost(hit.post_id)} > {hit.post_title} - {t("Rankings · rankweave")} + {t("Evidence ranking")} {tf("rank {rank}", { rank: String(hit.fused_rank) })} {(hit.channel_evidence ?? []).length > 0 ? ( From 41c6caefc96f822321998683143053dbc7726b31 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 10:05:08 +0900 Subject: [PATCH 069/393] docs(gaps): refresh dashboard exact-head evidence --- docs/product-technical-gap-baseline.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 00008e677..d30b0a8f2 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,8 +1,8 @@ # Product & Technical Gap Baseline -> Dashboard delivery snapshot: 2026-08-26 07:26 KST. Protected `main` was +> Dashboard delivery snapshot: 2026-08-26 10:06 KST. Protected `main` was > `494b54e2245040bcf02b45376f221c37cd437e76`. Dashboard PR #640 exact -> pre-synchronization head was `dd134e77`; this branch is not +> observed head was `d314855c`; this branch is not > protected-main release evidence. ## Operations Dashboard PRD/TRD traceability @@ -135,13 +135,14 @@ context only. | PR | Exact observed head | Merge/check state at this snapshot | | ---: | --- | --- | +| #681 | `3e0fa644` | stacked fast-mlsirm pair-posterior contract pin; exact-head checks queued and independent review required | | #667 | `425de329` | current governance/gap evidence refresh; exact-head checks and independent review required | | #663 | `e65fd29c` | consolidates project ontology traversal and #632 content; exact-head checks and independent review required | | #658 | `f497a6e8` | evidence-honest Global Ask cutoff with revision-interval live-after semantics | | #657 | `a59a2023` | fail-closed TEPP asynchronous lifecycle persistence; executable producer evidence remains required | | #644 | `ed8d97f3` | native-surface code splitting with modal-focus regression coverage | | #643 | `7fb4d18c` | accessible status-notice surfaces | -| #640 | `dd134e77` | operations-dashboard contract alignment | +| #640 | `d314855c` | operations-dashboard contract alignment; exact-head checks queued and independent review required | | #639 | `8da485d3` | exact-head checks and independent review required | | #632 | `cad4debf` | semantic provenance repair structurally included by #663 | | #631 | `e6b4f0c4` | documentation-only queue snapshot requires current-main refresh or closure | From d6c94512589a51fdc99d1a7fa42870448f3b3d8f Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 10:08:29 +0900 Subject: [PATCH 070/393] fix(api): return customer-action analysis errors --- backend/app/analysis_run_ingestion.py | 6 ++---- tests/test_analysis_run_create.py | 6 +++--- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index 7335f6fb9..289e3df7f 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -640,14 +640,12 @@ def _require_lineage_create_kind(run_kind_code: str) -> None: if run_kind_code == _TEPP_RUN_KIND: raise AnalysisRunCreateError( 422, - "Connect a TEPP transport from a Failed TEPP row; this endpoint " - "does not invent a measurement.", + "Open the failed temporal measurement, ask an administrator to restore analysis, then re-run it.", ) if run_kind_code == _TOPIC_LINEAGE_RUN_KIND: raise AnalysisRunCreateError( 422, - "Connect a TEPP transport from a Failed topic-lineage row; this " - "endpoint does not invent a topic model.", + "Open the failed topic journey analysis, ask an administrator to restore analysis, then re-run it.", ) if run_kind_code == _REPORT_RUN_KIND: raise AnalysisRunCreateError( diff --git a/tests/test_analysis_run_create.py b/tests/test_analysis_run_create.py index ef9dc9f71..6ede3f863 100644 --- a/tests/test_analysis_run_create.py +++ b/tests/test_analysis_run_create.py @@ -144,11 +144,11 @@ def test_create_rejects_tepp_and_report_kinds_without_a_fake_score() -> None: with pytest.raises(AnalysisRunCreateError) as tepp: _require_lineage_create_kind("analysis_run_tepp") assert tepp.value.status_code == 422 - assert "invent a measurement" in tepp.value.detail + assert "restore analysis" in tepp.value.detail with pytest.raises(AnalysisRunCreateError) as topic_lineage: _require_lineage_create_kind("analysis_run_topic_lineage") assert topic_lineage.value.status_code == 422 - assert "invent a topic model" in topic_lineage.value.detail + assert "restore analysis" in topic_lineage.value.detail with pytest.raises(AnalysisRunCreateError) as report: _require_lineage_create_kind("analysis_run_report") assert report.value.status_code == 422 @@ -180,7 +180,7 @@ async def _run() -> None: idempotency_key="client-key-1", ) assert err.value.status_code == 422 - assert "invent a measurement" in err.value.detail + assert "restore analysis" in err.value.detail asyncio.run(_run()) From c77acc776fb90708e16c3a399541d52348044fce Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 10:14:17 +0900 Subject: [PATCH 071/393] fix(ui): keep ranking evidence customer-facing --- frontend/src/App.tsx | 2 +- frontend/src/components/OperationsDashboard.tsx | 4 ++-- lineageweave/leftover_pairs.py | 6 +++++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d91eb5b28..8658d49b4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3385,7 +3385,7 @@ function RankingsPanel({ "Compare recency and content-relevance evidence, then open the source post before acting. This rank is not a performance score.", )}

      -
        +
          {ranking.rankings.map((hit) => (
        • ))} diff --git a/lineageweave/leftover_pairs.py b/lineageweave/leftover_pairs.py index 87a563bc1..572749ffe 100644 --- a/lineageweave/leftover_pairs.py +++ b/lineageweave/leftover_pairs.py @@ -85,7 +85,11 @@ def leftover_map_from_residual( if index < len(result.singular_values) else 0.0 ), - leftover_share=float(result.axis_shares[index]), + leftover_share=( + float(result.axis_shares[index]) + if index < len(result.axis_shares) + else 0.0 + ), ) for index in range(_LEFTOVER_MAP_AXES) ) From 85cb3a26aaaed85d886a0b3e2436ca424c575966 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 10:10:59 +0900 Subject: [PATCH 072/393] feat(ask): link citations to evidence timeline --- CHANGELOG.md | 5 + backend/app/global_ask_queue.py | 4 + backend/app/post_chat_ingestion.py | 9 + docs/adr/0225-ask-answer-evidence-timeline.md | 61 ++++++ docs/adr/README.md | 1 + docs/product-requirements.md | 13 ++ docs/product-technical-gap-baseline.md | 1 + docs/storybook-inventory.md | 1 + frontend/src/App.css | 156 +++++++++++++++ frontend/src/App.test.tsx | 27 +++ frontend/src/App.tsx | 58 +----- frontend/src/api.ts | 8 + .../components/AskAnswerTimeline.stories.tsx | 70 +++++++ .../src/components/AskAnswerTimeline.test.tsx | 102 ++++++++++ frontend/src/components/AskAnswerTimeline.tsx | 188 ++++++++++++++++++ frontend/src/i18n.ts | 52 +++++ lineageweave/post_chat.py | 26 +++ tests/test_global_ask_queue.py | 53 +++++ tests/test_global_ask_sources.py | 4 + tests/test_post_chat.py | 30 +++ 20 files changed, 821 insertions(+), 48 deletions(-) create mode 100644 docs/adr/0225-ask-answer-evidence-timeline.md create mode 100644 frontend/src/components/AskAnswerTimeline.stories.tsx create mode 100644 frontend/src/components/AskAnswerTimeline.test.tsx create mode 100644 frontend/src/components/AskAnswerTimeline.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c33678fe..6f57bce06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,11 @@ All notable changes to this project are documented here. Format follows ### Added +- Global Ask answers now link numbered citations to authorized event cards in + both directions. Each card names event time or the record-time fallback and + opens the focused evidence layer or full source post without inventing a + Project Journey. + - ADR 0210's Dashboard consumer now persists a normalized, exact-provenance projection for TEPP temporal topics and fast-mlsirm case-deletion model influence. The API authorizes the fitted analysis scope before returning diff --git a/backend/app/global_ask_queue.py b/backend/app/global_ask_queue.py index c7e570d81..7174189c1 100644 --- a/backend/app/global_ask_queue.py +++ b/backend/app/global_ask_queue.py @@ -35,6 +35,7 @@ from lineageweave.post_chat import ( PostChatClient, cited_post_evidence, + cited_post_events, cited_post_summaries, ) from lineageweave.temporal_expressions import resolve_korean_relative_time @@ -260,6 +261,7 @@ def can_see(row: asyncpg.Record) -> bool: "answer_text": "", "cited_post_ids": [], "cited_posts": [], + "cited_events": [], "source_post_ids": [], "cited_post_evidence": [], "lineage_graph": {"nodes": [], "edges": [], "truncated": False}, @@ -304,11 +306,13 @@ def can_see(row: asyncpg.Record) -> bool: lineage_graph = await lineage_graphs_for_posts(conn, can_see, cited_ids) images = await cited_post_images(conn, cited_ids) cited_posts = cited_post_summaries(sources, cited_ids) + cited_events = cited_post_events(sources, cited_ids) cited_evidence = cited_post_evidence(sources, cited_ids) return { "answer_text": answer.answer_text, "cited_post_ids": cited_ids, "cited_posts": cited_posts, + "cited_events": cited_events, "cited_post_evidence": cited_evidence, "cited_post_images": images, "source_post_ids": [source.post_id for source in sources], diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index 4ca9e2f2d..2777e8c66 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -579,6 +579,9 @@ async def gather_global_chat_sources( if post_id in lineage_neighbor_id_set and anchor_is_visible else () ) + event_occurred_at = row.get("event_occurred_at") + created_at = row.get("created_at") + observed_at = event_occurred_at or created_at sources.append( ChatSourceDocument( post_id, @@ -589,6 +592,12 @@ async def gather_global_chat_sources( + semantic_facts.get(post_id, ()) + lineage_fact + time_axis_evidence_fact(row, time_filter_active=time_filter_active), + observed_at=observed_at.isoformat() if observed_at else None, + time_axis_code="event_occurred_at" + if event_occurred_at is not None + else "created_at" + if created_at is not None + else None, ) ) return sources diff --git a/docs/adr/0225-ask-answer-evidence-timeline.md b/docs/adr/0225-ask-answer-evidence-timeline.md new file mode 100644 index 000000000..fce742bcf --- /dev/null +++ b/docs/adr/0225-ask-answer-evidence-timeline.md @@ -0,0 +1,61 @@ +# ADR 0225: Ask answers link citations to an evidence timeline + +- Status: Accepted +- Date: 2026-08-26 +- Related: [0039](0039-global-ask-agent-source-boundary.md), [0090](0090-global-ask-lineage-timeline-expansion.md), [0153](0153-ask-evidence-layer-popup.md), [0202](0202-ask-event-time-filter.md) + +## Context + +Global Ask returns an answer and authorized cited posts, but the answer and +source controls are visually separate. A reader cannot select citation `[2]` +and land on the corresponding event, or select an event and return to the +answer citation. The current response also omits the cited source's observed +instant and named clock, so the frontend cannot construct an honest event-time +list without guessing from the lineage graph. + +## Decision + +1. A Global Ask result returns `cited_events` in citation order. Each entry is + derived from the same authorized `ChatSourceDocument` that was admitted to + the answer and contains only its post id, title, persisted observed instant, + and clock code. `event_occurred_at` is preferred; `created_at` is the named + fallback. A missing instant remains absent. +2. The answer renders citations `[1]..[n]` from `cited_posts`/`cited_events`. + Selecting a citation focuses and highlights its event card. Selecting that + card focuses and highlights the matching citation. Both directions preserve + the citation number even when cards are chronologically ordered. +3. Every event card opens the existing evidence layer and the authorized full + post. The cards show the named source clock and stored evidence; they do not + expose provider, package, schema, hash, environment, or model-run detail. +4. This surface is an **answer evidence timeline**, not a Project Journey. + Chronological ordering alone does not create a predecessor, branch, project + start, or causal relation. The separate Project Journey contract continues + to require a persisted TEPP TDT/CHRONOS result under ADR 0206. +5. A commercial perspective or recommended response may appear only inside the + contextual-orchestrator answer when the cited event progression supports it. + The frontend never manufactures a recommendation from dates, titles, or + citation order. Customer copy tells the reader which evidence or source to + inspect next and does not explain internal implementation boundaries. +6. The interaction uses native buttons, visible focus, `aria-pressed`, a live + selection status, no color-only state, and no animated scrolling. It remains + a single column on narrow viewports and a conversation/timeline split when + space permits. + +## Consequences + +- The reader can move between an answer claim and its source event without + losing context. +- Event time and record time remain distinguishable without inventing dates. +- Existing evidence-popup and post-detail authorization paths remain the only + source-opening paths. + +## Verification + +- Backend tests prove citation-order preservation, clock selection, absent-time + behavior, and unknown-citation removal. +- Component and Storybook interaction tests prove both focus directions, + source opening, keyboard semantics, empty time, narrow layout, and + customer-facing copy. +- Authenticated Compose screenshots cover desktop and narrow viewports with + synthetic data. + diff --git a/docs/adr/README.md b/docs/adr/README.md index 492097d78..fc75367d0 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -23,6 +23,7 @@ decision from them. | [`temporal-topic-context-influence-research.md`](../temporal-topic-context-influence-research.md) | [0210](0210-temporal-topic-context-influence-dashboard.md) | | [`python-mathematical-compute-boundary-audit.md`](../doctoring/python-mathematical-compute-boundary-audit.md) | [0208](0208-externalize-local-mathematical-compute.md) | | Canonical Docker Compose project (`name: lineageweave`) | [0224](0224-canonical-compose-project-name.md) | +| Ask answer citation and evidence-timeline interaction | [0225](0225-ask-answer-evidence-timeline.md) | [0011](0011-prov-o-standard-relations.md) and [0065](0065-prov-o-provenance-boundary.md) cite the dated W3C PROV-O and PROV-DM Recommendations (https://www.w3.org/TR/2013/REC-prov-o-20130430/ and https://www.w3.org/TR/2013/REC-prov-dm-20130430/). diff --git a/docs/product-requirements.md b/docs/product-requirements.md index 36e5fcb44..dbce16860 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -96,6 +96,19 @@ stale evidence from a previously opened post. An open lifecycle has a cited start, a missing end, and nullable elapsed time; a resolved lifecycle links both endpoint sources and names the source clock used for each instant. +### PRD-FR-5A — Ask answer evidence navigation + +- Link each numbered Ask citation to one authorized event card and preserve the + same number when cards are ordered by observed time. +- Move focus citation-to-card and card-to-citation, then open the existing + evidence layer or full source post. +- Name `event_occurred_at` or the `created_at` fallback; never turn chronology + into a project start, predecessor, branch, or recommended response. + +Acceptance: keyboard selection works in both directions, every card opens its +authorized source, missing time stays explicit, and any commercial next action +comes from the cited orchestrator answer rather than frontend inference. + ### PRD-FR-6 — Measurement boundary - Consume TEPP accepted/completed wire contracts and fast-mlsirm outputs; do diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index d30b0a8f2..ace1e0ffa 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -13,6 +13,7 @@ | Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus separate rebid-response and handover milestone pairs | Stacked candidate reports open/resolved/evidence-missing rebid and handover lifecycles without a delay threshold or invented elapsed endpoint; authenticated synthetic runtime passed, while authorized-corpus re-analysis remains pending | | External information count/rate and sales/project relation | ADR 0206; semantic `external_information` classification inside Dashboard GNB | Candidate GNB destination filters the Dashboard to external evidence; no separate Board by product decision; authenticated synthetic runtime passed with the honest zero-result state | | Project-specific journey | Explicit source/semantic project membership plus provenance-bearing TEPP TDT/CHRONOS predecessor, branch, and transition results | Candidate API preserves every explicit project membership, but its local event-time sort is only an observed-event list. It is no longer labeled as a Project Journey. Full journey delivery remains open until the accepted TEPP producer artifact is persisted and rendered; no fixed sales/order start or nearest-date edge is accepted. | +| Ask answer citation-to-event navigation | ADR 0225; authorized cited source, observed source clock, focused evidence layer, and full-post navigation | Stacked candidate renders numbered answer citations and chronologically ordered evidence cards with bidirectional focus. Event and record clocks stay distinct; this list does not claim a Project Journey. Storybook and component interaction evidence are included; authenticated runtime screenshots remain required at the exact candidate head. | | Repeat issue to design improvement | `repeat_issue`, `issue_pattern`, and `improvement_action` cited facts | Candidate semantic contract; design-system connector acceptance pending | | Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback; authenticated runtime acceptance pending | | Similar VOC, customer cohort, prior action | Persisted repeat-issue candidate semantics plus orchestrator pair adjudication and extractive evidence | Candidate live post endpoint and post-detail UI implemented; authenticated runtime acceptance pending | diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index b4991791d..36f6fbb44 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -6,6 +6,7 @@ operator-facing control you can click before changing product CSS. | Story | Operator next action | Token / module | |---|---|---| | `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, repeat issue, or topic-context influence. `TopicInfluenceAccepted` preserves exact ties, multiple membership, time states, uncertainty, and source actions; `EvidenceReady` shows the producer-contract unavailable state. `NarrowViewport`, `ExternalInformationEmpty`, `RequiredFactMissing`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover mobile, scoped-empty, explicit evidence-absence, analysis-pending, retryable failure, and transport-error states. | `--color-dashboard-*`, `OperationsDashboard`, `TopicContextInfluence` | +| `Ask Agent/AnswerEvidenceTimeline` | Select an answer citation to focus its event card, select the card to return to the answer, then open its evidence or source post. `MissingObservedTime` keeps an absent event clock explicit and `NarrowViewport` verifies the single-column interaction. | `--color-accent-*`, `--radius-panel`, `--size-control-min`, `AskAnswerTimeline` | | `Post/SimilarVocPanel` | Compare ontology/semantic similar VOC and prior action evidence, then open the source; unavailable states show no fabricated TEPP theta or weight. | `SimilarVocPanel.css`, `SimilarVocPanel` | | `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` | | `Evidence/OrganizationAliasChip` | Click a cataloged org; the parenthetical is the unique corroborated SKOS companion. | `--color-chip-border`, `--radius-chip`, `OrganizationAliasChip` | diff --git a/frontend/src/App.css b/frontend/src/App.css index 0bfe4f13c..c9c56120d 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1050,6 +1050,162 @@ color: var(--text); } +.ask-answer-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.9fr); + gap: 1.5rem; + align-items: start; +} + +.ask-conversation, +.ask-evidence-timeline { + min-width: 0; +} + +.ask-message { + max-width: 90%; + padding: var(--space-panel-block); + border: 1px solid var(--border); + border-radius: var(--radius-panel); + background: var(--surface-muted); +} + +.ask-message + .ask-message { + margin-top: var(--space-panel-block); +} + +.ask-message-question { + margin-left: auto; + border-color: var(--color-accent-border); + background: var(--color-accent-background); +} + +.ask-message-speaker { + display: block; + margin-bottom: var(--space-control-gap); + color: var(--text-muted); + font-size: 0.8rem; + font-weight: 600; +} + +.ask-message p, +.ask-evidence-timeline > p { + margin: 0; +} + +.ask-inline-citations { + display: flex; + flex-wrap: wrap; + gap: var(--space-control-gap); + margin-top: var(--space-panel-block); +} + +.ask-inline-citation { + min-width: var(--size-control-min); + min-height: var(--size-control-min); + border: 0; + border-bottom: 2px solid currentColor; + background: transparent; + color: var(--color-accent); + cursor: pointer; + font-weight: 700; +} + +.ask-inline-citation[aria-pressed="true"] { + outline: 3px solid var(--color-focus-border); + outline-offset: 2px; +} + +.ask-next-action { + margin-top: var(--space-panel-block); + padding: var(--space-panel-block); + border-left: 4px solid var(--color-accent-gold); + background: var(--color-code-background); +} + +.ask-evidence-timeline > ol { + position: relative; + display: grid; + gap: var(--space-panel-block); + margin: var(--space-panel-block) 0 0; + padding: 0; + list-style: none; +} + +.ask-evidence-timeline > ol::before { + position: absolute; + top: 0; + bottom: 0; + left: 0.75rem; + width: 2px; + background: var(--color-accent-border); + content: ""; +} + +.ask-evidence-timeline article { + position: relative; + margin-left: 2rem; + padding: var(--space-panel-block); + border: 1px solid var(--border); + border-radius: var(--radius-panel); + background: var(--surface); +} + +.ask-event-selected article { + border-color: var(--color-focus-border); + outline: 2px solid var(--color-focus-border); + outline-offset: 2px; +} + +.ask-event-select { + display: flex; + width: 100%; + min-height: var(--size-control-min); + align-items: flex-start; + gap: var(--space-panel-block); + border: 0; + padding: 0; + background: transparent; + color: var(--text); + cursor: pointer; + text-align: left; +} + +.ask-event-select strong, +.ask-event-select small { + display: block; +} + +.ask-event-select small { + margin-top: var(--space-control-gap); + color: var(--text-muted); +} + +.ask-event-marker { + position: relative; + z-index: 1; + flex: 0 0 auto; + color: var(--color-accent); + font-weight: 700; +} + +.ask-event-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-control-gap); + margin-top: var(--space-panel-block); +} + +@media (max-width: 60rem) { + .ask-answer-layout { + grid-template-columns: minmax(0, 1fr); + } + + .ask-message { + max-width: 100%; + } +} + .evidence-panel { position: fixed; top: 0; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 1004c28a1..a33ead143 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1737,6 +1737,14 @@ describe("App, authenticated", () => { answer_text: "The cited project is supported by the stored semantic evidence.", cited_post_ids: ["post-2"], cited_posts: [{ post_id: "post-2", post_title: "Linked post" }], + cited_events: [ + { + post_id: "post-2", + post_title: "Linked post", + observed_at: "2026-08-01T00:00:00Z", + time_axis_code: "event_occurred_at", + }, + ], cited_post_evidence: [ { post_id: "post-2", @@ -2051,6 +2059,25 @@ describe("App, authenticated", () => { expect(screen.getByRole("button", { name: "View evidence" })).toBeInTheDocument(); }); + it("links an Ask answer citation to its event card and back", async () => { + stubBackend(); + render(); + expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Ask Agent" })); + await userEvent.type(screen.getByRole("textbox", { name: "Ask a question" }), "Which project?"); + await userEvent.click(screen.getByRole("button", { name: "Ask" })); + + const citation = await screen.findByRole("button", { name: "Show event 1: Linked post" }); + const eventCard = screen.getByRole("button", { + name: "Return to answer citation 1: Linked post", + }); + await userEvent.click(citation); + expect(eventCard).toHaveFocus(); + expect(screen.getByText(/Event occurred/)).toBeInTheDocument(); + await userEvent.click(eventCard); + expect(citation).toHaveFocus(); + }); + it("labels the Customer Master entity level and Keymen side, never the raw lookup code", async () => { // Live UI finding (2026-08-19): read_customer_master() skipped the // common_lookup_value join both endpoints elsewhere already use, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8658d49b4..16b814666 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -94,9 +94,9 @@ import { CutoffKnownBody } from "./components/CutoffKnownBody"; import { LineageEntityPicker } from "./components/LineageEntityPicker"; import { OntologyExplorer } from "./components/OntologyExplorer"; import { AskEvidenceLayerPopup } from "./components/AskEvidenceLayerPopup"; +import { AskAnswerTimeline } from "./components/AskAnswerTimeline"; import { PopupCloseButton } from "./components/PopupCloseButton"; import { SimilarVocPanel } from "./components/SimilarVocPanel"; -import { chatEvidenceKindLabel } from "./evidenceKindLabels"; import { WorkspaceNav, type WorkspaceDestination } from "./components/WorkspaceNav"; import { OperationsDashboard } from "./components/OperationsDashboard"; import { initialWorkspaceDestination } from "./gnbChrome"; @@ -4826,6 +4826,7 @@ function AskAgentPanel({ }) { const [question, setQuestion] = useState(""); const [answer, setAnswer] = useState(null); + const [answeredQuestion, setAnsweredQuestion] = useState(""); const [error, setError] = useState(null); const [asking, setAsking] = useState(false); const [evidenceLayerPostId, setEvidenceLayerPostId] = useState(null); @@ -4837,6 +4838,7 @@ function AskAgentPanel({ setError(null); try { setAnswer(await askAgent(accessToken, normalized)); + setAnsweredQuestion(normalized); } catch (err) { setAnswer(null); setError(orchestratorUnavailableMessage(err, t("Ask Agent"))); @@ -4866,8 +4868,13 @@ function AskAgentPanel({ {answer && (

          {t("Answer")}

          - {answer.answer_text ?

          {answer.answer_text}

          : null} - {answer.next_action ?

          {t(answer.next_action)}

          : null} + {answer.delivery ? ( ) : null} - {answer.cited_posts && answer.cited_posts.length > 0 && ( - <> -

          {t("Cited posts")}

          -
            - {answer.cited_posts.map((post) => ( -
          • - - - {answer.cited_post_evidence?.find((item) => item.post_id === post.post_id)?.facts.length ? ( -
              - {answer.cited_post_evidence - .find((item) => item.post_id === post.post_id) - ?.facts.map((fact, index) => ( -
            • - {chatEvidenceKindLabel(fact.kind)} - {fact.text} -
            • - ))} -
            - ) : null} - {answer.cited_post_images - ?.filter((image) => image.post_id === post.post_id) - .map((image) => ( -

            - {t("Image evidence")}: {image.caption?.trim() ? image.caption : t("Untitled image")} - {image.extracted_text ? ` — ${image.extracted_text}` : ""} - {image.tags.length ? ` — ${t("Image tags")}: ${image.tags.join(", ")}` : ""} -

            - ))} -
          • - ))} -
          - - )} {answer.lineage_graph && answer.lineage_graph.nodes.length > 0 ? ( ) : null} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 858b871b3..e819a063a 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -443,6 +443,13 @@ export interface CitedPostEvidence { facts: CitedPostEvidenceFact[]; } +export interface CitedPostEvent { + post_id: string; + post_title: string; + observed_at: string | null; + time_axis_code: "event_occurred_at" | "created_at" | null; +} + export interface ChatAnswer { post_id: string; answer_text: string; @@ -477,6 +484,7 @@ export interface AskAgentResponse { answer_text: string; cited_post_ids: string[]; cited_posts?: CitedPostRef[]; + cited_events?: CitedPostEvent[]; cited_post_evidence?: CitedPostEvidence[]; cited_post_images?: CitedPostImage[]; source_post_ids: string[]; diff --git a/frontend/src/components/AskAnswerTimeline.stories.tsx b/frontend/src/components/AskAnswerTimeline.stories.tsx new file mode 100644 index 000000000..6078fc2fd --- /dev/null +++ b/frontend/src/components/AskAnswerTimeline.stories.tsx @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { AskAnswerTimeline } from "./AskAnswerTimeline"; +import "../App.css"; + +const meta = { + title: "Ask Agent/AnswerEvidenceTimeline", + component: AskAnswerTimeline, + parameters: { layout: "fullscreen" }, + decorators: [(Story) =>
          ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const args: Story["args"] = { + question: "What changed before the revised proposal?", + answer: { + answer_text: "The account discussion preceded the customer's revised request.", + cited_post_ids: ["post-request", "post-discussion"], + cited_posts: [ + { post_id: "post-request", post_title: "Customer revised request" }, + { post_id: "post-discussion", post_title: "Account discussion" }, + ], + cited_events: [ + { post_id: "post-request", post_title: "Customer revised request", observed_at: "2026-08-20T09:00:00Z", time_axis_code: "event_occurred_at" }, + { post_id: "post-discussion", post_title: "Account discussion", observed_at: "2026-08-10T09:00:00Z", time_axis_code: "created_at" }, + ], + cited_post_evidence: [ + { post_id: "post-request", facts: [{ kind: "semantic_project", text: "project: Synthetic renewal" }] }, + { post_id: "post-discussion", facts: [{ kind: "semantic_role", text: "actor: Synthetic account owner" }] }, + ], + source_post_ids: ["post-request", "post-discussion"], + }, + onOpenEvidence: () => undefined, + onOpenPost: () => undefined, +}; + +export const BidirectionalFocus: Story = { + args, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const citation = canvas.getByRole("button", { name: "Show event 1: Customer revised request" }); + const card = canvas.getByRole("button", { + name: "Return to answer citation 1: Customer revised request", + }); + await userEvent.click(citation); + await expect(card).toHaveFocus(); + await userEvent.click(card); + await expect(citation).toHaveFocus(); + }, +}; + +export const MissingObservedTime: Story = { + args: { + ...args, + answer: { + ...args.answer, + cited_posts: [args.answer.cited_posts![0]], + cited_events: [{ ...args.answer.cited_events![0], observed_at: null, time_axis_code: null }], + }, + }, + play: async ({ canvasElement }) => { + await expect(within(canvasElement).getByText("Observed time unavailable")).toBeVisible(); + }, +}; + +export const NarrowViewport: Story = { + ...BidirectionalFocus, + parameters: { viewport: { defaultViewport: "mobile1" } }, +}; diff --git a/frontend/src/components/AskAnswerTimeline.test.tsx b/frontend/src/components/AskAnswerTimeline.test.tsx new file mode 100644 index 000000000..9f0855f2e --- /dev/null +++ b/frontend/src/components/AskAnswerTimeline.test.tsx @@ -0,0 +1,102 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { AskAgentResponse } from "../api"; +import { AskAnswerTimeline } from "./AskAnswerTimeline"; + +const answer: AskAgentResponse = { + answer_text: "The revised request followed the initial commercial discussion.", + cited_post_ids: ["post-later", "post-earlier"], + cited_posts: [ + { post_id: "post-later", post_title: "Revised request" }, + { post_id: "post-earlier", post_title: "Initial discussion" }, + ], + cited_events: [ + { + post_id: "post-later", + post_title: "Revised request", + observed_at: "2026-08-20T09:00:00Z", + time_axis_code: "event_occurred_at", + }, + { + post_id: "post-earlier", + post_title: "Initial discussion", + observed_at: "2026-08-10T09:00:00Z", + time_axis_code: "created_at", + }, + ], + cited_post_evidence: [ + { + post_id: "post-later", + facts: [{ kind: "semantic_project", text: "project: Synthetic renewal" }], + }, + ], + source_post_ids: ["post-later", "post-earlier"], +}; + +describe("AskAnswerTimeline", () => { + it("links citation and chronological event focus in both directions", async () => { + const user = userEvent.setup(); + render( + undefined} + onOpenPost={() => undefined} + />, + ); + + const timeline = screen.getByRole("region", { name: "Answer evidence timeline" }); + const cards = within(timeline).getAllByRole("article"); + expect(cards[0]).toHaveAccessibleName("Evidence 2: Initial discussion"); + expect(cards[1]).toHaveAccessibleName("Evidence 1: Revised request"); + + const citation = screen.getByRole("button", { name: "Show event 1: Revised request" }); + const card = screen.getByRole("button", { + name: "Return to answer citation 1: Revised request", + }); + await user.click(citation); + expect(card).toHaveFocus(); + expect(card).toHaveAttribute("aria-pressed", "true"); + await user.click(card); + expect(citation).toHaveFocus(); + expect(citation).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("status")).toHaveTextContent("Selected evidence: Revised request"); + }); + + it("opens the evidence layer and full source from the same authorized card", async () => { + const user = userEvent.setup(); + const onOpenEvidence = vi.fn(); + const onOpenPost = vi.fn(); + render( + , + ); + + await user.click(screen.getAllByRole("button", { name: "View evidence" })[0]); + expect(onOpenEvidence).toHaveBeenCalledWith("post-earlier"); + await user.click(screen.getByRole("button", { name: "Open post: Revised request" })); + expect(onOpenPost).toHaveBeenCalledWith("post-later"); + }); + + it("names absent time instead of borrowing a lineage timestamp", () => { + render( + undefined} + onOpenPost={() => undefined} + />, + ); + + expect(screen.getByText("Observed time unavailable")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/AskAnswerTimeline.tsx b/frontend/src/components/AskAnswerTimeline.tsx new file mode 100644 index 000000000..58bc26dfd --- /dev/null +++ b/frontend/src/components/AskAnswerTimeline.tsx @@ -0,0 +1,188 @@ +import { useRef, useState } from "react"; +import type { AskAgentResponse, CitedPostEvent } from "../api"; +import { chatEvidenceKindLabel } from "../evidenceKindLabels"; +import { getLocale, t, tf } from "../i18n"; + +type Props = { + question: string; + answer: AskAgentResponse; + onOpenEvidence: (postId: string) => void; + onOpenPost: (postId: string) => void; +}; + +type Citation = { + citationNumber: number; + postId: string; + postTitle: string; + event: CitedPostEvent | undefined; +}; + +function observedTimeLabel(event: CitedPostEvent | undefined): string { + if (!event?.observed_at) return t("Observed time unavailable"); + const date = new Date(event.observed_at); + if (Number.isNaN(date.valueOf())) return t("Observed time unavailable"); + const formatted = new Intl.DateTimeFormat(getLocale(), { + dateStyle: "medium", + timeStyle: "short", + }).format(date); + const axis = event.time_axis_code === "event_occurred_at" + ? t("Event occurred") + : event.time_axis_code === "created_at" + ? t("Record created") + : t("Observed time"); + return `${formatted} · ${axis}`; +} + +function observedEpoch(event: CitedPostEvent | undefined): number | null { + if (!event?.observed_at) return null; + const epoch = Date.parse(event.observed_at); + return Number.isNaN(epoch) ? null : epoch; +} + +/** Links one grounded Ask answer to its authorized source-event cards. */ +export function AskAnswerTimeline({ question, answer, onOpenEvidence, onOpenPost }: Props) { + const [selectedPostId, setSelectedPostId] = useState(null); + const citationRefs = useRef(new Map()); + const cardRefs = useRef(new Map()); + const eventsByPost = new Map(answer.cited_events?.map((event) => [event.post_id, event])); + const citations: Citation[] = (answer.cited_posts ?? []).map((post, index) => ({ + citationNumber: index + 1, + postId: post.post_id, + postTitle: post.post_title, + event: eventsByPost.get(post.post_id), + })); + const chronological = [...citations].sort((left, right) => { + const leftEpoch = observedEpoch(left.event); + const rightEpoch = observedEpoch(right.event); + if (leftEpoch === null) return rightEpoch === null ? left.citationNumber - right.citationNumber : 1; + if (rightEpoch === null) return -1; + return leftEpoch - rightEpoch || left.citationNumber - right.citationNumber; + }); + + function selectCitation(citation: Citation, target: "card" | "citation") { + setSelectedPostId(citation.postId); + const destination = target === "card" + ? cardRefs.current.get(citation.postId) + : citationRefs.current.get(citation.postId); + destination?.scrollIntoView?.({ block: "nearest", inline: "nearest" }); + destination?.focus({ preventScroll: true }); + } + + return ( +
          +
          +
          + {t("You")} +

          {question}

          +
          +
          + {t("Ask Agent")} + {answer.answer_text ?

          {answer.answer_text}

          : null} + {citations.length ? ( + + ) : null} +
          + {answer.next_action ?

          {t(answer.next_action)}

          : null} +
          + + {chronological.length ? ( +
          +

          {t("Answer evidence timeline")}

          +

          {t("Select a citation to review the event and open its source.")}

          +
            + {chronological.map((citation) => { + const facts = answer.cited_post_evidence?.find( + (item) => item.post_id === citation.postId, + )?.facts ?? []; + const images = answer.cited_post_images?.filter( + (image) => image.post_id === citation.postId, + ) ?? []; + const selected = selectedPostId === citation.postId; + return ( +
          1. +
            + + {facts.length ? ( +
              + {facts.map((fact, index) => ( +
            • + {chatEvidenceKindLabel(fact.kind)} + : {fact.text} +
            • + ))} +
            + ) : null} + {images.map((image) => ( +

            + {t("Image evidence")}: {image.caption?.trim() ? image.caption : t("Untitled image")} + {image.extracted_text ? ` — ${image.extracted_text}` : ""} + {image.tags.length ? ` — ${t("Image tags")}: ${image.tags.join(", ")}` : ""} +

            + ))} +
            + + +
            +
            +
          2. + ); + })} +
          + {selectedPostId ? ( +

          + {tf("Selected evidence: {title}", { + title: citations.find((citation) => citation.postId === selectedPostId)?.postTitle ?? "", + })} +

          + ) : null} +
          + ) : null} +
          + ); +} diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 9ed35f342..638a23422 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -181,6 +181,19 @@ const TRANSLATIONS: Partial>> = { "Asking...": "질의 중...", Answer: "답변", "Cited posts": "인용된 글", + Conversation: "대화", + You: "나", + "Answer citations": "답변 근거", + "Answer evidence timeline": "답변 근거 타임라인", + "Select a citation to review the event and open its source.": "답변의 번호를 선택해 해당 사건과 원문을 확인하세요.", + "Show event {number}: {title}": "사건 {number} 보기: {title}", + "Return to answer citation {number}: {title}": "답변 근거 {number}로 돌아가기: {title}", + "Evidence {number}: {title}": "근거 {number}: {title}", + "Selected evidence: {title}": "선택한 근거: {title}", + "Observed time unavailable": "확인할 수 있는 사건 시각이 없습니다", + "Observed time": "확인 시각", + "Event occurred": "사건 발생일", + "Record created": "기록 생성일 기준", "Report · alert · MCP": "리포트 · 알림 · MCP", "{count} evidence documents are linked to this report.": "근거 문서 {count}건이 리포트에 연결됐습니다.", "You can subscribe to evidence-change alerts.": "근거 변경 알림을 구독할 수 있습니다.", @@ -688,6 +701,19 @@ const TRANSLATIONS: Partial>> = { "Asking...": "正在提问...", Answer: "回答", "Cited posts": "引用文章", + Conversation: "对话", + You: "您", + "Answer citations": "答案证据", + "Answer evidence timeline": "答案证据时间线", + "Select a citation to review the event and open its source.": "选择答案编号以查看相应事件和原文。", + "Show event {number}: {title}": "查看事件 {number}:{title}", + "Return to answer citation {number}: {title}": "返回答案证据 {number}:{title}", + "Evidence {number}: {title}": "证据 {number}:{title}", + "Selected evidence: {title}": "已选择证据:{title}", + "Observed time unavailable": "没有可确认的事件时间", + "Observed time": "确认时间", + "Event occurred": "事件发生时间", + "Record created": "记录创建时间", "Search related posts": "搜索相关文章", "Search related posts for: {name}": "搜索与{name}相关的文章", "Evidence facts": "证据事实", @@ -1211,6 +1237,19 @@ const TRANSLATIONS: Partial>> = { "Asking...": "質問中...", Answer: "回答", "Cited posts": "引用された投稿", + Conversation: "会話", + You: "あなた", + "Answer citations": "回答の根拠", + "Answer evidence timeline": "回答根拠タイムライン", + "Select a citation to review the event and open its source.": "回答の番号を選び、該当する出来事と原文を確認してください。", + "Show event {number}: {title}": "出来事 {number} を表示: {title}", + "Return to answer citation {number}: {title}": "回答根拠 {number} に戻る: {title}", + "Evidence {number}: {title}": "根拠 {number}: {title}", + "Selected evidence: {title}": "選択した根拠: {title}", + "Observed time unavailable": "確認できる出来事の時刻がありません", + "Observed time": "確認時刻", + "Event occurred": "出来事の発生日時", + "Record created": "記録作成日時", "Search related posts": "関連投稿を検索", "Search related posts for: {name}": "{name}の関連投稿を検索", "Evidence facts": "証拠の事実", @@ -1713,6 +1752,19 @@ const TRANSLATIONS: Partial>> = { "Asking...": "Đang hỏi...", Answer: "Câu trả lời", "Cited posts": "Bài viết được trích dẫn", + Conversation: "Cuộc trò chuyện", + You: "Bạn", + "Answer citations": "Bằng chứng cho câu trả lời", + "Answer evidence timeline": "Dòng thời gian bằng chứng trả lời", + "Select a citation to review the event and open its source.": "Chọn số trích dẫn để xem sự kiện và mở nguồn gốc.", + "Show event {number}: {title}": "Xem sự kiện {number}: {title}", + "Return to answer citation {number}: {title}": "Quay lại bằng chứng trả lời {number}: {title}", + "Evidence {number}: {title}": "Bằng chứng {number}: {title}", + "Selected evidence: {title}": "Bằng chứng đã chọn: {title}", + "Observed time unavailable": "Không có thời điểm sự kiện để xác nhận", + "Observed time": "Thời điểm xác nhận", + "Event occurred": "Thời điểm sự kiện xảy ra", + "Record created": "Thời điểm tạo bản ghi", "Search related posts": "Tìm bài viết liên quan", "Search related posts for: {name}": "Tìm bài viết liên quan đến {name}", "Evidence facts": "Sự kiện bằng chứng", diff --git a/lineageweave/post_chat.py b/lineageweave/post_chat.py index 429a66336..d5df68064 100644 --- a/lineageweave/post_chat.py +++ b/lineageweave/post_chat.py @@ -64,6 +64,8 @@ class ChatSourceDocument: post_body: str graph_facts: tuple[str, ...] = field(default_factory=tuple) evidence_facts: tuple[str, ...] = field(default_factory=tuple) + observed_at: str | None = None + time_axis_code: str | None = None @dataclass(frozen=True) @@ -93,6 +95,24 @@ def cited_post_summaries( ] +def cited_post_events( + sources: list[ChatSourceDocument] | tuple[ChatSourceDocument, ...], + cited_post_ids: tuple[str, ...] | list[str], +) -> list[dict[str, str | None]]: + """Return cited event clocks in citation order without inventing time.""" + by_id = {source.post_id: source for source in sources} + return [ + { + "post_id": source.post_id, + "post_title": source.post_title, + "observed_at": source.observed_at, + "time_axis_code": source.time_axis_code, + } + for post_id in cited_post_ids + if (source := by_id.get(post_id)) is not None + ] + + def _buyer_evidence_kind(fact: str) -> str: if fact.startswith("time axis:"): return "time_axis" @@ -172,6 +192,9 @@ def answer(self, question: str, sources: list[ChatSourceDocument]) -> ChatAnswer Do not output a reasoning trace. Return the JSON object immediately. For every part of your answer, track which source number(s) it came from. +If the question asks for a commercial response, include a concise commercial +perspective only when the cited event progression supports it; otherwise say +what evidence is still needed. Never infer it from chronology alone. Reply with ONLY a JSON object (no markdown fences, no prose) with exactly these fields: @@ -190,6 +213,9 @@ def answer(self, question: str, sources: list[ChatSourceDocument]) -> ChatAnswer _CHAT_REQUEST_PROMPT_TEMPLATE = """\ Answer the question using ONLY the numbered source documents below. Do not use outside knowledge or guess. Be concise and preserve the evidence facts. +If the question asks for a commercial response, include a concise commercial +perspective only when the cited event progression supports it; otherwise say +what evidence is still needed. Never infer it from chronology alone. Write the answer first, then a new line exactly beginning CITED SOURCES: followed by the 1-based source numbers separated by commas. Cite every source the answer used; write NONE when the sources do not support an answer. diff --git a/tests/test_global_ask_queue.py b/tests/test_global_ask_queue.py index 76b07170c..696b763cd 100644 --- a/tests/test_global_ask_queue.py +++ b/tests/test_global_ask_queue.py @@ -7,6 +7,7 @@ from backend.app import global_ask_queue from backend.app.global_ask_queue import load_job_visibility +from lineageweave.post_chat import ChatAnswer, ChatSourceDocument class _AvailableClient: @@ -173,3 +174,55 @@ async def fetchval(self, query: str, *args): assert processes == {"queued-process"} assert process_scope_limited is True assert has_post_read is True + + +def test_completed_answer_carries_the_cited_source_clock(monkeypatch) -> None: + """The UI timeline receives the admitted source clock, not a graph guess.""" + connection = _Connection(None) + pool = _Pool(connection) + sources = [ + ChatSourceDocument( + "post-1", + "Synthetic event", + "body", + observed_at="2026-08-21T03:00:00+00:00", + time_axis_code="event_occurred_at", + ) + ] + + async def _fake_gather(*_args, **_kwargs): + return sources + + async def _fake_graph(*_args, **_kwargs): + return {"nodes": [], "edges": [], "truncated": False} + + async def _fake_images(*_args, **_kwargs): + return [] + + class _AnswerClient: + def answer(self, _question, _sources): + return ChatAnswer("Grounded answer", ("post-1",)) + + monkeypatch.setattr(global_ask_queue, "gather_global_chat_sources", _fake_gather) + monkeypatch.setattr(global_ask_queue, "lineage_graphs_for_posts", _fake_graph) + monkeypatch.setattr(global_ask_queue, "cited_post_images", _fake_images) + + payload = asyncio.run( + global_ask_queue.compute_global_ask_answer( + pool, + question_text="What happened?", + corporate_entity_ids=set(), + process_unit_ids=set(), + process_scope_limited=False, + chat_client=_AnswerClient(), + ) + ) + + assert payload["cited_events"] == [ + { + "post_id": "post-1", + "post_title": "Synthetic event", + "observed_at": "2026-08-21T03:00:00+00:00", + "time_axis_code": "event_occurred_at", + } + ] diff --git a/tests/test_global_ask_sources.py b/tests/test_global_ask_sources.py index f7fc31ae7..f7aee9456 100644 --- a/tests/test_global_ask_sources.py +++ b/tests/test_global_ask_sources.py @@ -570,6 +570,8 @@ async def fetch(self, query: str, *args): assert [source.post_id for source in sources] == ["yesterday-event"] assert TIME_AXIS_EVENT in sources[0].evidence_facts assert TIME_AXIS_CREATED not in sources[0].evidence_facts + assert sources[0].observed_at == "2026-08-21T03:00:00+00:00" + assert sources[0].time_axis_code == "event_occurred_at" def test_global_sources_name_created_at_fallback_when_event_clock_is_missing( @@ -608,3 +610,5 @@ async def fetch(self, query: str, *args): assert [source.post_id for source in sources] == ["ingest-yesterday"] assert TIME_AXIS_CREATED in sources[0].evidence_facts + assert sources[0].observed_at == "2026-08-21T03:00:00+00:00" + assert sources[0].time_axis_code == "created_at" diff --git a/tests/test_post_chat.py b/tests/test_post_chat.py index 9c6d2faf4..3922abd10 100644 --- a/tests/test_post_chat.py +++ b/tests/test_post_chat.py @@ -34,6 +34,7 @@ NullPostChatClient, _render_sources_block, cited_post_evidence, + cited_post_events, cited_post_summaries, normalize_chat_question, parse_chat_response, @@ -166,6 +167,34 @@ def test_cited_post_summaries_keep_citation_order_and_drop_unknown_ids() -> None ] +def test_cited_post_events_keep_named_clocks_and_do_not_invent_time() -> None: + sources = ( + ChatSourceDocument( + "post-event", + "Observed event", + "body", + observed_at="2026-08-21T03:00:00+00:00", + time_axis_code="event_occurred_at", + ), + ChatSourceDocument("post-unknown", "No observed instant", "body"), + ) + + assert cited_post_events(sources, ("post-unknown", "missing", "post-event")) == [ + { + "post_id": "post-unknown", + "post_title": "No observed instant", + "observed_at": None, + "time_axis_code": None, + }, + { + "post_id": "post-event", + "post_title": "Observed event", + "observed_at": "2026-08-21T03:00:00+00:00", + "time_axis_code": "event_occurred_at", + }, + ] + + def test_cited_post_evidence_hides_prompt_metadata_but_keeps_semantic_facts() -> None: source = ChatSourceDocument( "post-evidence", @@ -356,3 +385,4 @@ def fake_post_json(url, payload, *, headers, timeout): assert observed["payload"]["reasoning_effort"] == "auto" assert observed["payload"]["mode"] == "auto" assert "CITED SOURCES" in observed["payload"]["messages"][0]["content"] + assert "Never infer it from chronology alone" in observed["payload"]["messages"][0]["content"] From bec013d8b520bda9671eef44900446fcdef1e929 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 18:28:20 -0700 Subject: [PATCH 073/393] fix: consolidate canonical Compose product stack (#678) * fix(compose): consolidate canonical product stack * fix(smoke): avoid unrelated Rust build dependency * docs(ops): record exact isolated stack cleanup --------- Co-authored-by: Codex --- ARCHITECTURE.md | 4 ++ CHANGELOG.md | 4 ++ Makefile | 2 +- README.md | 10 +++++ docker-compose.yml | 1 + docs/adr/0224-canonical-compose-project.md | 44 +++++++++++++++++++ docs/adr/README.md | 1 + .../compose-project-consolidation.md | 33 ++++++++++++++ docs/product-technical-gap-baseline.md | 12 +++++ tests/test_frontend_container_contract.py | 24 ++++++++++ tests/test_makefile_contract.py | 3 +- 11 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 docs/adr/0224-canonical-compose-project.md create mode 100644 docs/operability/compose-project-consolidation.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6788714fa..290e7697e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -161,6 +161,10 @@ real-provider LLM tests). provider (`docker/keycloak/realm-export.json` seeds a `lineageweave-demo` realm with synthetic demo accounts carrying `corp_code` / `pu_code` as custom token claims -- see [README](README.md#local-product-stack-docker-compose)). +ADR 0224 fixes the default project name to `lineageweave` and keeps the +migration, SearXNG, contextual-orchestrator, backend, and frontend in that same +project. Test stacks use an explicit disposable `-p` name; they never replace a +canonical service with a container built from another worktree. `scripts/smoke_test_oidc.py` proves the round-trip is real: it logs in as the synthetic demo user, fetches Keycloak's live JWKS, and cryptographically verifies the returned JWT's RS256 signature rather than just checking for an diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f57bce06..561ea7b9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project are documented here. Format follows ### Changed +- Docker Compose now has one canonical `lineageweave` project containing the + complete synthetic product stack. The backend receives the existing TEPP API + credential contract, and the OIDC smoke target installs its declared backend + dependencies before verifying the live Keycloak signature. - Rankings now call RankWeave's parameter-free classic RRF path when no calibrated weights exist and project RankWeave-owned channel contributions; the prior local contribution arithmetic and invalid all-ones call to the diff --git a/Makefile b/Makefile index 62e1b3198..c892f6f91 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ ps: # Keycloak's live JWKS, and asserts the corp_code/pu_code claims. See # scripts/smoke_test_oidc.py. smoke: - uv run --locked python scripts/smoke_test_oidc.py + uv run --locked --extra dev python scripts/smoke_test_oidc.py # Seeds synthetic corp/account/post rows keyed to the actual Keycloak demo # users' real subject ids, plus Valkey ticket_created events so Activity diff --git a/README.md b/README.md index 22633f79d..0a12d98ff 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,16 @@ compatibility aliases only. `ORCHESTRATOR_BASE_URL` and `ORCHESTRATOR_API_KEY` are separate, internal LineageWeave-to-orchestrator settings. +The Compose file declares `lineageweave` as its canonical default project, so +the same eight-service synthetic stack is addressed from the repository and +temporary worktrees. Isolated tests may override it explicitly with `-p`; use +`docker compose down` without `-v` when retiring such a project so its named +volumes remain recoverable (ADR 0224). +The bundled Keycloak realm is the standalone/local/dev/test fallback only. +Setting `KEYVERSE_ISSUER` selects central Keyverse for both backend and +frontend and activates the fail-closed claim binding in ADR 0156; the two +issuers are never combined as authorization authorities. + Postgres and Keycloak are built (`docker/postgres-init/`, `docker/keycloak/`) rather than bind-mounted, so the keycloak database's init script and the realm seed ship inside the images themselves -- portable to any Docker host diff --git a/docker-compose.yml b/docker-compose.yml index 924bd3092..6729174f6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -177,6 +177,7 @@ services: ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY:-${CONTEXTUAL_ORCHESTRATOR_TOKEN:-lineageweave-orchestrator-dev-only}} SEARXNG_BASE_URL: http://searxng:8080 TEPP_TRANSPORT_URL: ${TEPP_TRANSPORT_URL:-} + TEPP_API_KEY: ${TEPP_API_KEY:-} CALDAV_BASE_URL: ${CALDAV_BASE_URL:-} NARUON_CALENDAR_BASE_URL: ${NARUON_CALENDAR_BASE_URL:-} NARUON_CALENDAR_SERVICE_TOKEN: ${NARUON_CALENDAR_SERVICE_TOKEN:-} diff --git a/docs/adr/0224-canonical-compose-project.md b/docs/adr/0224-canonical-compose-project.md new file mode 100644 index 000000000..2c722c324 --- /dev/null +++ b/docs/adr/0224-canonical-compose-project.md @@ -0,0 +1,44 @@ +# ADR 0224: Canonical local Compose project + +- Status: Accepted +- Date: 2026-08-26 + +## Context + +Running the same Compose file from temporary worktrees created multiple `lw*` +and branch-named projects. Operators could no longer tell which stack owned the +current synthetic database, migrations, frontend, backend, identity provider, +search, queue, and contextual-orchestrator boundary. One observed project also +carried `TEPP_API_KEY` into the backend while the Dashboard candidate omitted +that already-supported runtime setting. + +## Decision + +`docker-compose.yml` declares the default project name `lineageweave` and keeps +all eight product services in that project: PostgreSQL, the one-shot migration, +Valkey, SearXNG, Keycloak, contextual-orchestrator, backend, and frontend. +An isolated test may still override the name explicitly with Compose `-p`; it +must use a disposable name and must not mutate the canonical project. + +The backend receives only its TEPP transport URL and TEPP API credential. The +provider gateway credentials remain confined to contextual-orchestrator through +the existing `${HOME}/.env` boundary. Compose cleanup uses `docker compose down` +for an exactly identified project and never deletes named volumes by default. + +Identity selection remains ADR 0028/0156's exclusive choice. With a non-empty +`KEYVERSE_ISSUER`, backend and frontend use central Keyverse and malformed or +unbound Keyverse scope claims fail closed; the local Keycloak service is not a +second trusted issuer. With no Keyverse issuer, standalone/local/dev/test uses +only the synthetic `lineageweave-demo` Keycloak realm. + +## Consequences + +- `make up`, `make ps`, `make logs`, and `make down` address the same project + from the repository or a worktree unless an isolated test explicitly uses + `-p`. +- A complete synthetic acceptance run can exercise OIDC, migrations, search, + Valkey, contextual-orchestrator, backend, frontend, Dashboard, and Ask without + mixing services from different working directories. +- Historical `lw*` projects may be removed only after comparing their Compose + source and validating the canonical stack; their named volumes remain + recoverable. diff --git a/docs/adr/README.md b/docs/adr/README.md index fc75367d0..9f3391bc6 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -19,6 +19,7 @@ decision from them. | [`storybook-inventory.md`](../storybook-inventory.md) | [0118](0118-uiux-standard-guide-v3-design-overhaul.md), [0184](0184-ontology-provenance-explorer.md) | | [`POSTGRESQL_CONCURRENCY_REFERENCES.md`](../doctoring/POSTGRESQL_CONCURRENCY_REFERENCES.md) | [0204](0204-analysis-run-short-transaction-delivery.md) | | [`operability/http-concurrency-evidence.md`](../operability/http-concurrency-evidence.md) | [0204](0204-analysis-run-short-transaction-delivery.md), [0212](0212-single-query-authorized-post-filter-options.md) | +| [`operability/compose-project-consolidation.md`](../operability/compose-project-consolidation.md) | [0224](0224-canonical-compose-project.md) | | Evidence operations Dashboard (`/`) | [0206](0206-evidence-operations-dashboard.md) | | [`temporal-topic-context-influence-research.md`](../temporal-topic-context-influence-research.md) | [0210](0210-temporal-topic-context-influence-dashboard.md) | | [`python-mathematical-compute-boundary-audit.md`](../doctoring/python-mathematical-compute-boundary-audit.md) | [0208](0208-externalize-local-mathematical-compute.md) | diff --git a/docs/operability/compose-project-consolidation.md b/docs/operability/compose-project-consolidation.md new file mode 100644 index 000000000..e9d47328a --- /dev/null +++ b/docs/operability/compose-project-consolidation.md @@ -0,0 +1,33 @@ +# Local Compose project consolidation evidence + +On 2026-08-26 KST, container labels were read before any cleanup. Three +non-canonical Compose projects were identified by exact project name and +configuration path: `lw-cancelled-visualk5c5lp`, `lw-k6-agent`, and `lwrepro`. +Their service definitions were compared with the Dashboard candidate. The only +still-supported environment contract absent from that candidate was +`TEPP_API_KEY`; it is now part of the canonical backend service. + +Before cleanup, `lineageweave-dashboard-metrics` ran PostgreSQL plus its +successful one-shot migration, Valkey, SearXNG, Keycloak, +contextual-orchestrator, backend, and frontend. Live OIDC/JWKS verification +passed. An authenticated 2-VU, 20-second k6 run completed 162 requests with +zero failures across posts, Event Lineage, Dashboard, and Ask polling; all +seven observed Ask jobs in the synthetic database were `succeeded`. +This local run left `KEYVERSE_ISSUER` unset and therefore proved the synthetic +Keycloak fallback only. A Keyverse-configured deployment is a separate, +fail-closed issuer and claim-binding acceptance boundary under ADR 0028/0156. + +Each identified Compose project was retired with its exact `-p` project name +and `docker compose down`, without `-v`. Six named volumes remain: one +PostgreSQL and one Valkey volume for each retired project. The independently +created `lw-orch-hostport` container has no Compose project/configuration +labels, so it was not guessed into a project or deleted. A later exact-label +audit found one running `lw-k6-agent` migration container that Compose could +not discover because it lacked configuration labels; after its project and +service labels were revalidated, that isolated test container was removed +directly. Stale created-only projects `lineageweave-kg-fix-20260822` and +`lineageweave-261-exact` were also removed with their exact project names. +Named volumes were not deleted. + +This is local, synthetic runtime evidence. It is neither production capacity +evidence nor protected-main delivery evidence. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index ace1e0ffa..7629bc1ad 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -7,6 +7,18 @@ ## Operations Dashboard PRD/TRD traceability +ADR 0224 reconciles the observed `lw*` test projects with the complete +`lineageweave` Compose boundary. The Dashboard candidate stack exercised all +eight declared services with 27 synthetic posts: live OIDC/JWKS verification +passed, the latest topic-coordinate migration tables were present, Ask reached +`succeeded`, and a 2-VU 20-second authenticated k6 observation completed 162 +HTTP requests with zero failures. This is local candidate evidence, not a +protected-main release claim. Historical test projects are retired only by +their exact Compose project label and without named-volume deletion. PR #678 +implementation head `da98de07` fixes the default project name; its follow-up +exact-label audit also removed the remaining identifiable isolated test +containers while preserving named volumes. + | Requirement | Evidence contract | Delivery state | |---|---|---| | Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification and `claim_received` → `cause_confirmed` milestones with cited spans and observed source clocks | Stacked candidate reports open/resolved/evidence-missing counts and exact elapsed time only for paired observed endpoints; every required answer and endpoint is cited or explicitly missing; authenticated synthetic runtime passed, while authorized-corpus re-analysis remains pending | diff --git a/tests/test_frontend_container_contract.py b/tests/test_frontend_container_contract.py index 546198405..fe35f80c7 100644 --- a/tests/test_frontend_container_contract.py +++ b/tests/test_frontend_container_contract.py @@ -26,3 +26,27 @@ def test_compose_and_frontend_image_share_vite_build_arguments() -> None: assert f"{variable}:" in compose assert f"ARG {variable}" in dockerfile assert f"{variable}=${{{variable}}}" in dockerfile + + +def test_compose_has_one_default_project_and_complete_service_boundary() -> None: + """Default Compose runs share one name and expose every owned integration hop.""" + compose = (_ROOT / "docker-compose.yml").read_text(encoding="utf-8") + + assert compose.startswith("name: lineageweave\n") + for service in ( + "postgres", + "database_migration", + "valkey", + "searxng", + "keycloak", + "orchestrator", + "backend", + "frontend", + ): + assert f" {service}:\n" in compose + assert "TEPP_API_KEY: ${TEPP_API_KEY:-}" in compose + assert "KEYVERSE_ISSUER: ${KEYVERSE_ISSUER:-}" in compose + assert ( + "VITE_KEYVERSE_ISSUER: " + "${KEYVERSE_ISSUER:-http://localhost:${KEYCLOAK_PORT:-18080}/realms/lineageweave-demo}" + ) in compose diff --git a/tests/test_makefile_contract.py b/tests/test_makefile_contract.py index 5dff9a3b5..9b97f9637 100644 --- a/tests/test_makefile_contract.py +++ b/tests/test_makefile_contract.py @@ -10,7 +10,8 @@ def test_makefile_runtime_targets_use_locked_uv_environment() -> None: encoding="utf-8" ) - assert "uv run --locked python scripts/smoke_test_oidc.py" in makefile + assert "uv run --locked --extra dev python scripts/smoke_test_oidc.py" in makefile + assert "--extra backend python scripts/smoke_test_oidc.py" not in makefile assert "uv run --locked python scripts/seed_demo_data.py" in makefile assert "\n\tpython3 scripts/smoke_test_oidc.py" not in makefile assert "\n\tpython3 scripts/seed_demo_data.py" not in makefile From dae28fb2b70df1fb98ff9d20ba51a919a69abf86 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 18:28:24 -0700 Subject: [PATCH 074/393] build: pin fail-closed lineage weight owner contract (#681) * build: pin lineage weight owner contract * build: refresh lineage owner exact head --------- Co-authored-by: Codex --- .../0208-externalize-local-mathematical-compute.md | 8 ++++++++ docs/product-technical-gap-baseline.md | 2 +- pyproject.toml | 11 +++++------ uv.lock | 4 ++-- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/docs/adr/0208-externalize-local-mathematical-compute.md b/docs/adr/0208-externalize-local-mathematical-compute.md index 1c5d9adda..d0a2c6e5a 100644 --- a/docs/adr/0208-externalize-local-mathematical-compute.md +++ b/docs/adr/0208-externalize-local-mathematical-compute.md @@ -71,6 +71,14 @@ different responsibility. ## Implemented migration slices +- The backend dependency is immutably pinned to fast-mlsirm commit + `b3d85c35856fa8f7376821084f93292d0dea0407`, which publishes the strict + `tepp.lineage_pair_criterion_posterior.v2` minimum producer schema and keeps + channel-weight estimation unavailable. This is a contract adapter only: the + legacy Python estimator remains frozen migration debt and MUST NOT be used to + activate calibrated weights. No consumer-facing projection exposes schema, + transport, hash, TEPP, or fast-mlsirm internals. + - The residual interaction map consumes fast-mlsirm's protected-main `residual_interaction_map` and `polytomous_expected_response` contracts. Gabriel SVD, axis inertia, distance, reconstruction, unexplained residual, diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 7629bc1ad..3cba8a3e3 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -452,7 +452,7 @@ this file per §3.5 of the prior snapshot). | SKOS organization aliases | Catalog binding and chip caption live on #480 / #482 | One catalog row per corroborated org; companion caption is hint-only until bound | | Event Lineage evidence | Channel evidence and Allen relations live on #387 / #484 | Persist channel scores, explain them in the popup, never invent a fused score | | Scientific measurement | Durable accepted TEPP receipts and LineageWeave #614's exact accepted snapshot/cutoff/run/pair-count consumer are protected; TEPP #237 remains open, so no registered producer artifact exists yet. #387 removes inferred/default persistence weights, but several older reconstruction tests still pass hand-authored numeric dictionaries that are not estimator evidence | Land TEPP #237 through its protected gate, then replace remaining reconstruction-test constants with provenance-bearing fast-mlsirm estimates over synthetic fixtures. Retain true-parameter RMSE recovery as the acceptance bar | -| Python mathematical-compute boundary | ADR 0208's first deletion slice consumes fast-mlsirm protected-main Rust `residual_interaction_map` and `polytomous_expected_response`; local Gabriel SVD, distance, reconstruction, shares, expected-category and duplicate likelihood formulas are deleted. This stack also removes local RRF contribution arithmetic and corrects the invalid all-ones call to RankWeave's convex API through RankWeave #47 (`f92c3a2c`), but RankWeave #47 is still Python and therefore is not the final Rust CPU/GPU execution contract. The doctoring inventory still names period calibration, channel weighting, cosine, graph ranking, and fusion debt | Land the owner and stacked consumer through protected gates; then move RankWeave's calculation core to Rust CPU/GPU and migrate each remaining construct to its owning repository contract before requiring an empty transition inventory | +| Python mathematical-compute boundary | ADR 0208's first deletion slice consumes fast-mlsirm protected-main Rust `residual_interaction_map` and `polytomous_expected_response`; local Gabriel SVD, distance, reconstruction, shares, expected-category and duplicate likelihood formulas are deleted. This stack also removes local RRF contribution arithmetic and corrects the invalid all-ones call to RankWeave's convex API through RankWeave #47 (`f92c3a2c`), but RankWeave #47 is still Python and therefore is not the final Rust CPU/GPU execution contract. The backend now immutably pins fast-mlsirm owner PR #1423 head `b3d85c35856fa8f7376821084f93292d0dea0407`, which validates the accepted-anchor prerequisite and publishes the exact pair-level posterior producer schema while returning unavailable instead of inventing weights. The doctoring inventory still names period calibration, channel weighting, cosine, graph ranking, and fusion debt | Obtain independent pair-level criterion posterior evidence with TDT/CHRONOS event-time provenance and CPU/GPU parity receipts; only then land the Rust estimator and delete the frozen Python channel-weight path. Continue the separate RankWeave and period-report owner migrations before requiring an empty transition inventory | | Asynchronous authorization | Protected `main` rebuilds Global Ask worker scope after the bearer token leaves the request; #468 now persists exact Keyverse organization/process-unit scope in 3NF child tables and intersects it with current affiliations | Land #468 through the protected gate; prove a second affiliation and a revoked process unit cannot widen delayed-job evidence | | Planned-facility intent | Planned-facility relationship intent remains only on closed, unmerged #490; earlier stack-only merges were not protected delivery | Recreate the evidence-backed slice on a current base and land through protected `main` before a release claim | | Accessibility and responsive UX | #602 delivered base post-detail modal semantics; #605 adds selected-post refocus, collapsed/hidden/inert/CSS-invisible focus exclusion across both modal types, readable evidence separators, focused tests, and desktop/mobile Storybook screenshots | Land #605 through the protected gate, then complete screen-reader and authenticated Playwright acceptance on the exact release head | diff --git a/pyproject.toml b/pyproject.toml index 110858e16..2a55f69b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,12 +51,11 @@ backend = [ # Speaks RESP; works against Valkey (a Redis-protocol-compatible fork) # as well as real Redis. Used for the post-activity event stream. "redis>=5.0.0", - # LLM-as-a-Judge -> IRT -> Fixed-Item Parameter Calibration for the - # weekly/monthly PU/team/project reports (ADR 0003). No PyPI release - # yet; pinned to a specific commit, same pattern as rankweave. Ships a - # PyO3/maturin Rust core with no fallback wheel, so building this from - # source needs a Rust toolchain -- see backend/Dockerfile. - "fast-mlsirm @ git+https://github.com/ContextualWisdomLab/fast-mlsirm.git@256470c7d1df4910a018841499a74d88b751a774", + # Owner arithmetic plus the fail-closed TEPP lineage criterion consumer + # contract (ADR 0208). The exact commit is immutable; it still exposes no + # channel-weight result until independent pair-level posterior evidence is + # available. PyO3/maturin source builds need the pinned Rust toolchain. + "fast-mlsirm @ git+https://github.com/ContextualWisdomLab/fast-mlsirm.git@b3d85c35856fa8f7376821084f93292d0dea0407", ] [tool.setuptools.packages.find] diff --git a/uv.lock b/uv.lock index 2225d1d16..be5f249f0 100644 --- a/uv.lock +++ b/uv.lock @@ -471,7 +471,7 @@ wheels = [ [[package]] name = "fast-mlsirm" version = "0.9.0" -source = { git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=256470c7d1df4910a018841499a74d88b751a774#256470c7d1df4910a018841499a74d88b751a774" } +source = { git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=b3d85c35856fa8f7376821084f93292d0dea0407#b3d85c35856fa8f7376821084f93292d0dea0407" } dependencies = [ { name = "numpy" }, ] @@ -655,7 +655,7 @@ requires-dist = [ { name = "certifi", specifier = ">=2024.0.0" }, { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.6" }, { name = "cryptography", specifier = ">=42.0" }, - { name = "fast-mlsirm", marker = "extra == 'backend'", git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=256470c7d1df4910a018841499a74d88b751a774" }, + { name = "fast-mlsirm", marker = "extra == 'backend'", git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=b3d85c35856fa8f7376821084f93292d0dea0407" }, { name = "fastapi", marker = "extra == 'backend'", specifier = ">=0.141.1" }, { name = "httpx2", marker = "extra == 'dev'", specifier = ">=2.12.0" }, { name = "opentelemetry-api", specifier = ">=1.30.0" }, From cb78cd7808fc38d2d709ff27e7dd4b3ee201f037 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 10:37:40 +0900 Subject: [PATCH 075/393] docs(adr): define portable Rust accelerator backends --- ...ative-mlx-mathematical-compute-boundary.md | 175 ++++++++++++++++++ docs/adr/README.md | 1 + docs/product-technical-gap-baseline.md | 1 + 3 files changed, 177 insertions(+) create mode 100644 docs/adr/0226-macos-native-mlx-mathematical-compute-boundary.md diff --git a/docs/adr/0226-macos-native-mlx-mathematical-compute-boundary.md b/docs/adr/0226-macos-native-mlx-mathematical-compute-boundary.md new file mode 100644 index 000000000..e696aab83 --- /dev/null +++ b/docs/adr/0226-macos-native-mlx-mathematical-compute-boundary.md @@ -0,0 +1,175 @@ +# ADR 0226: macOS-native MLX boundary for Rust-owned computation + +- Status: Accepted +- Date: 2026-08-26 +- Amends: ADR 0208 and ADR 0210 +- Clarifies: ADR 0076 + +## Context + +LineageWeave runs its product services in Linux containers through Docker or +Colima on Apple Silicon. The MLX Metal backend is not a Linux-container +capability. MLX enables Metal on Darwin and requires Apple Silicon, macOS 14, +Xcode 15, and the macOS 14 SDK; its Linux distributions provide CPU or NVIDIA +CUDA backends instead. A Colima Linux VM therefore cannot truthfully issue an +MLX Metal execution receipt merely because its macOS host has an Apple GPU. + +ADR 0208 already assigns mathematical and psychometric arithmetic to the Rust +cores of TEPP, fast-mlsirm, and RankWeave. Moving a formula into Python to gain +access to MLX would violate that ownership boundary. ADR 0076's prohibition on +LineageWeave-specific MLX model-provider routes remains unchanged: this ADR is +about owner-repository numerical kernels, not LLM or VISION routing. + +## Decision + +1. On Apple Silicon, an owner repository may execute an accepted numerical + kernel through MLX Metal only in a **macOS-native process**. The owner Rust + core remains the algorithm and contract authority and links the MLX C/C++ + surface or an equally typed native FFI boundary. Python may launch or marshal + a generated binding, but it may not implement, transform, normalize, score, + or repair the mathematics. +2. Linux Compose containers never claim MLX Metal execution. They call the + macOS-native owner service through an explicitly configured authenticated + HTTPS boundary reachable from the container host gateway. No endpoint, + credential, or certificate is baked into an image or committed. Mutual TLS + is required; the native service binds only to the local host interface and + authorizes the exact owner contract and tenant scope. +3. The transport uses a versioned request/result envelope with input and output + SHA-256 digests, owner code revision, estimand and schema versions, device + identity, backend (`mlx_metal`, `mlx_cpu`, `mlx_cuda`, or `rust_cpu`), + precision, worker configuration, start/end instants, convergence and + identification diagnostics, and a signed execution receipt. A requested + Metal run without an `mlx_metal` receipt fails closed. +4. Linux CI and non-Apple deployments may execute an owner-approved MLX CPU, + MLX CUDA, deterministic multithreaded Rust CPU, or owner-native Rust OpenCL + path. MLX does not publish an OpenCL backend, so an OpenCL receipt MUST be + `rust_opencl`, never `mlx_opencl`. The caller requests one exact capability; + runtime discovery cannot silently choose another backend. CPU portability + is not evidence that Metal, CUDA, or OpenCL was exercised. +5. Every newly accelerated estimand requires deterministic synthetic recovery, + Rust-reference versus MLX numerical parity with the estimand's + identification constraints, non-finite and shape rejection, device-receipt + verification, disconnect/timeout/idempotency tests, and an actual + Apple-Silicon Metal integration run. A tolerance must come from the owner's + numerical error analysis and precision contract; no local constant is + invented by LineageWeave. +6. LineageWeave remains a consumer. It may persist and authorize an accepted + receipt but never selects an MLX device, retries on a different mathematical + backend, or recomputes a rejected result. Customer UI presents the measured + result, uncertainty, evidence, and next action; it does not expose MLX, + Colima, FFI, transport, schema, or package details. +7. Deployment is fail-closed and reversible. If the native service is absent, + untrusted, incompatible, or produces a parity-invalid result, the affected + channel is unavailable and dropped under the existing renormalization + contract. Rollback disables the native endpoint and returns to an already + accepted owner CPU contract; it never substitutes Python arithmetic. + +## Docker Compose backend contract + +Owner repositories publish four additive, versioned Compose overlays. The +base product Compose file contains no accelerator device and remains the CPU- +portable control plane. A deployment selects exactly one overlay and records +its rendered Compose digest in the execution receipt. + +| Requested backend | Where computation runs | Compose/device contract | Required proof before accepting work | +|---|---|---|---| +| `rust_cpu` or `mlx_cpu` | Linux owner-service container | `compose.compute-cpu.yml`; no host device mapping | container CPU architecture, owner self-test, worker-count determinism, memory limit and actual backend receipt | +| `mlx_cuda` | Linux owner-service container on an NVIDIA host | `compose.compute-cuda.yml`; Docker device reservation with `driver: nvidia`, either an explicit `device_ids` list or measured `count` (never both), and mandatory `capabilities: [gpu]` | NVIDIA driver/toolkit and MLX CUDA compatibility, selected device identity, a real CUDA kernel self-test, CPU/CUDA parity | +| `rust_opencl` | Linux owner-service container | `compose.compute-opencl.yml`; a vendor CDI device is preferred. If CDI is unavailable, map only preflight-discovered render/compute nodes and mount the matching vendor ICD read-only; never map all of `/dev` or grant privileged mode | OpenCL platform/device identity, ICD and kernel availability, a real OpenCL kernel self-test, CPU/OpenCL parity | +| `mlx_metal` | macOS-native Rust owner service outside Colima | no GPU device in Compose. `compose.compute-metal-host.yml` supplies only the opaque mTLS endpoint and certificate-file mounts from runtime secrets | native arm64/macOS/SDK compatibility, Metal device identity, signed native-service health, a real MLX Metal kernel self-test, CPU/Metal parity | + +The deployment procedure is normative: + +1. Run the owner-supplied preflight in **plan mode**. It reads the container + CPU/memory limits and enumerates only APIs available on that platform + (MLX device query, NVIDIA management API, OpenCL ICD, or macOS Metal). It + emits a machine-readable plan containing the requested backend, exact + device identity, driver/runtime versions, resource limits, overlay digest, + and failed prerequisites. It does not mutate Docker or select a fallback. +2. Reject the plan unless the requested backend and every prerequisite are + satisfied. Device selection comes from an explicit administrator choice or + the only compatible discovered device; multiple compatible devices require + an explicit choice rather than catalog-order selection. +3. Validate the rendered configuration with + `docker compose -f docker-compose.yml -f compose.compute-.yml config + --quiet`. The macOS native service must already be healthy before the Metal + host overlay is admitted. +4. Start with the same files and canonical project name: + `docker compose -f docker-compose.yml -f + compose.compute-.yml -p lineageweave up -d`. Secrets and mTLS + material enter through runtime-only files or the platform secret store, + never an image, Compose literal, log, or receipt. +5. Run the owner's device self-test and numerical parity acceptance. Only then + mark the backend ready. Health means that the selected device executed the + kernel; a process-level HTTP 200 is insufficient. +6. On a device, driver, receipt, parity, or connectivity failure, stop + accepting new mathematical jobs and surface the channel as unavailable. + Do not restart under CPU automatically. An authorized operator may render + and admit the CPU overlay as a separate deployment decision. +7. Teardown uses the exact file set and project name with `down` and never + removes volumes unless separately authorized. Test-only projects use an + isolated project name and are removed after their evidence is retained. + +Raw device mappings are a portability exception, not the default. The +generated OpenCL overlay must contain the exact preflight-discovered device +paths; a static wildcard, privileged container, host PID namespace, or broad +device cgroup permission is prohibited. CUDA follows Docker's device +reservation contract. CDI is used when the Docker daemon and vendor expose a +compatible device specification because it carries device nodes, libraries, +environment, and hooks as one auditable declaration. + +## Runtime topology + +```mermaid +flowchart LR + UI[LineageWeave UI] --> API[Linux Compose API] + API -->|mTLS, versioned envelope| HOST[macOS-native Rust owner service] + HOST -->|typed native boundary| MLX[MLX Metal] + HOST -->|signed result and receipt| API + API --> DB[(Provenance store)] +``` + +## Consequences + +- Apple GPU acceleration remains available without falsely treating a Linux + VM as a Metal host. +- The native service becomes a separately supervised local component with + certificate rotation, health, timeout, admission, audit, and resource-limit + responsibilities. +- Compose stays portable. A machine without the native capability still runs + the product and honestly reports the affected measurement as unavailable or + uses a separately accepted owner CPU/CUDA result. +- TEPP, fast-mlsirm, and RankWeave must each adopt this boundary in their own + normative ADR before publishing an `mlx_metal` receipt. + +## Alternatives considered + +1. **Run MLX Metal inside Colima.** Rejected because the guest is Linux and MLX + disables its Metal backend there. +2. **Move the kernel into host Python.** Rejected because it transfers + mathematical ownership out of Rust and duplicates formulas. +3. **Mount an unauthenticated local socket.** Rejected because VM socket + forwarding is runtime-specific and an unauthenticated compute boundary can + cross tenant and provenance scopes. +4. **Label any Apple-hosted run as Metal.** Rejected because host hardware does + not prove which backend executed the operation. +5. **Call a Rust OpenCL kernel MLX.** Rejected because MLX has no OpenCL + backend; backend identity is measurement provenance, not branding. + +## References (APA 7th) + +Hannun, A., Digani, J., Katharopoulos, A., & Collobert, R. (2023). *MLX: An +array framework for Apple silicon* [Computer software]. Apple Machine Learning +Research. https://github.com/ml-explore/mlx + +MLX Contributors. (2026). *Build and install: MLX 0.32.1 documentation*. +https://ml-explore.github.io/mlx/build/html/install.html + +MLX Contributors. (2026). *Unified memory: MLX 0.32.1 documentation*. +https://ml-explore.github.io/mlx/build/html/usage/unified_memory.html + +Docker, Inc. (2026). *Run Docker Compose services with GPU access*. +https://docs.docker.com/compose/how-tos/gpu-support/ + +Docker, Inc. (2026). *Container Device Interface (CDI)*. +https://docs.docker.com/build/building/cdi/ diff --git a/docs/adr/README.md b/docs/adr/README.md index 9f3391bc6..c70c2fb7d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -25,6 +25,7 @@ decision from them. | [`python-mathematical-compute-boundary-audit.md`](../doctoring/python-mathematical-compute-boundary-audit.md) | [0208](0208-externalize-local-mathematical-compute.md) | | Canonical Docker Compose project (`name: lineageweave`) | [0224](0224-canonical-compose-project-name.md) | | Ask answer citation and evidence-timeline interaction | [0225](0225-ask-answer-evidence-timeline.md) | +| macOS-native Rust/MLX mathematical compute boundary | [0226](0226-macos-native-mlx-mathematical-compute-boundary.md), [0208](0208-externalize-local-mathematical-compute.md) | [0011](0011-prov-o-standard-relations.md) and [0065](0065-prov-o-provenance-boundary.md) cite the dated W3C PROV-O and PROV-DM Recommendations (https://www.w3.org/TR/2013/REC-prov-o-20130430/ and https://www.w3.org/TR/2013/REC-prov-dm-20130430/). diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 3cba8a3e3..24ff4c85b 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -25,6 +25,7 @@ containers while preserving named volumes. | Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus separate rebid-response and handover milestone pairs | Stacked candidate reports open/resolved/evidence-missing rebid and handover lifecycles without a delay threshold or invented elapsed endpoint; authenticated synthetic runtime passed, while authorized-corpus re-analysis remains pending | | External information count/rate and sales/project relation | ADR 0206; semantic `external_information` classification inside Dashboard GNB | Candidate GNB destination filters the Dashboard to external evidence; no separate Board by product decision; authenticated synthetic runtime passed with the honest zero-result state | | Project-specific journey | Explicit source/semantic project membership plus provenance-bearing TEPP TDT/CHRONOS predecessor, branch, and transition results | Candidate API preserves every explicit project membership, but its local event-time sort is only an observed-event list. It is no longer labeled as a Project Journey. Full journey delivery remains open until the accepted TEPP producer artifact is persisted and rendered; no fixed sales/order start or nearest-date edge is accepted. | +| Apple-Silicon mathematical acceleration | ADR 0226 macOS-native Rust owner service with authenticated MLX Metal execution receipts | Normative boundary accepted; TEPP, fast-mlsirm, and RankWeave owner implementations and actual Metal parity receipts remain required before activation | | Ask answer citation-to-event navigation | ADR 0225; authorized cited source, observed source clock, focused evidence layer, and full-post navigation | Stacked candidate renders numbered answer citations and chronologically ordered evidence cards with bidirectional focus. Event and record clocks stay distinct; this list does not claim a Project Journey. Storybook and component interaction evidence are included; authenticated runtime screenshots remain required at the exact candidate head. | | Repeat issue to design improvement | `repeat_issue`, `issue_pattern`, and `improvement_action` cited facts | Candidate semantic contract; design-system connector acceptance pending | | Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback; authenticated runtime acceptance pending | From 6272197a20c9938fa35b802f0538fbab1361b38c Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 10:41:28 +0900 Subject: [PATCH 076/393] docs(adr): consolidate Compose project decision --- .../0224-canonical-compose-project-name.md | 39 ------------------- docs/adr/README.md | 2 +- 2 files changed, 1 insertion(+), 40 deletions(-) delete mode 100644 docs/adr/0224-canonical-compose-project-name.md diff --git a/docs/adr/0224-canonical-compose-project-name.md b/docs/adr/0224-canonical-compose-project-name.md deleted file mode 100644 index c906e2124..000000000 --- a/docs/adr/0224-canonical-compose-project-name.md +++ /dev/null @@ -1,39 +0,0 @@ -# ADR 0224: Pin the canonical Compose project name - -## Status - -Accepted — 2026-08-26 - -## Context - -Docker Compose otherwise derives its project name from the checkout directory. -Stacked PR worktrees therefore produced several production-looking -`lineageweave-*` projects with duplicated networks and stateful services. That -made the product preview ambiguous and could point operators at the wrong -PostgreSQL or identity service. - -## Decision - -The repository Compose file declares `name: lineageweave`. Normal `docker -compose` commands therefore converge on one canonical standalone stack, -regardless of the checkout directory name. - -An isolated test or review environment may override the name explicitly with -`docker compose -p `. Such an override is test infrastructure, -not another canonical deployment. After its declared objective succeeds, its -evidence and any required behavior are retained, then its containers and -network are removed with `docker compose -p down`. Named -volumes are never deleted as part of project-name consolidation without a -separate, explicit authorization. - -The canonical standalone stack retains its synthetic local Keycloak fallback. -Organization-integrated deployments configure the central Keyverse issuer as -required by ADR 0028 and ADR 0156; the Compose project name does not change the -identity-provider trust boundary. - -## Consequences - -- `docker compose up` consistently creates the `lineageweave` project. -- Preview and operational instructions have one unambiguous project name. -- Parallel review stacks must opt into a distinct name and ports deliberately. -- Existing noncanonical volumes remain recoverable until separately retired. diff --git a/docs/adr/README.md b/docs/adr/README.md index c70c2fb7d..b12fc90b4 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -23,7 +23,7 @@ decision from them. | Evidence operations Dashboard (`/`) | [0206](0206-evidence-operations-dashboard.md) | | [`temporal-topic-context-influence-research.md`](../temporal-topic-context-influence-research.md) | [0210](0210-temporal-topic-context-influence-dashboard.md) | | [`python-mathematical-compute-boundary-audit.md`](../doctoring/python-mathematical-compute-boundary-audit.md) | [0208](0208-externalize-local-mathematical-compute.md) | -| Canonical Docker Compose project (`name: lineageweave`) | [0224](0224-canonical-compose-project-name.md) | +| Canonical Docker Compose project (`name: lineageweave`) | [0224](0224-canonical-compose-project.md) | | Ask answer citation and evidence-timeline interaction | [0225](0225-ask-answer-evidence-timeline.md) | | macOS-native Rust/MLX mathematical compute boundary | [0226](0226-macos-native-mlx-mathematical-compute-boundary.md), [0208](0208-externalize-local-mathematical-compute.md) | From a357fbfcabb5719395a3bb40a407299d9d68b3f8 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 10:46:57 +0900 Subject: [PATCH 077/393] fix(seed): respect source evidence eligibility --- scripts/seed_demo_data.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index 092d28fa5..4b1c46d92 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -31,6 +31,7 @@ import psycopg2 +from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL from lineageweave.http_client import get_json, get_json_list, post_form from lineageweave.post_summary import ACTOR_TYPE_PERSON, POST_SUMMARY_CONTRACT_VERSION from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable @@ -2211,7 +2212,9 @@ def _warm_seeded_post_content( try: with connection.cursor() as cur: cur.execute( - "select post_id from source_post where post_title like 'Demo %post'" + "select post_id from source_post " + "where post_title like 'Demo %post' and " + + SOURCE_POST_ELIGIBILITY_SQL.format(alias="source_post") ) post_ids = [str(row[0]) for row in cur.fetchall()] finally: From 72703c31fec69cf45e51451036d38c33566d989a Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 10:50:05 +0900 Subject: [PATCH 078/393] fix(orchestrator): pin embedding capability selection --- docker/contextual-orchestrator/Dockerfile | 2 +- docs/adr/0083-orchestrator-runtime-commit-pin.md | 3 ++- tests/test_documentation_hygiene.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile index 0af60f58c..c14885083 100644 --- a/docker/contextual-orchestrator/Dockerfile +++ b/docker/contextual-orchestrator/Dockerfile @@ -5,7 +5,7 @@ WORKDIR /app # Reuse the upstream implementation without copying it into LineageWeave. # Pin the runtime to a reviewed immutable upstream commit; model selection, # structured synthesis, and reasoning policy stay in contextual-orchestrator. -ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/1a40e0f7ad10d1a24137d69d20e44fc9a5dcdd89.tar.gz /tmp/contextual-orchestrator.tar.gz +ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/315c9f05834df6df3a946de160db96f22c355e8b.tar.gz /tmp/contextual-orchestrator.tar.gz RUN mkdir /tmp/contextual-orchestrator \ && tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \ && cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \ diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md index 1ad14cade..5bfbef271 100644 --- a/docs/adr/0083-orchestrator-runtime-commit-pin.md +++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md @@ -15,7 +15,8 @@ multi-agent. ## Decision `docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to -commit `1a40e0f7ad10d1a24137d69d20e44fc9a5dcdd89`. The pin remains explicit +commit `315c9f05834df6df3a946de160db96f22c355e8b`, the merge commit for +contextual-orchestrator PR 789's embedding-capability selection contract. The pin remains explicit and immutable until the reviewed upstream change is superseded; it is not a moving `main` reference and it is not a LineageWeave monkey patch. diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py index b287d3470..bb5bedfbc 100644 --- a/tests/test_documentation_hygiene.py +++ b/tests/test_documentation_hygiene.py @@ -118,7 +118,7 @@ def test_role_catalog_identity_migration_is_wired() -> None: def test_orchestrator_runtime_pin_matches_adr() -> None: """The image pin and ADR must describe the same immutable upstream commit.""" - expected_embedding_contract_commit = "1a40e0f7ad10d1a24137d69d20e44fc9a5dcdd89" + expected_embedding_contract_commit = "315c9f05834df6df3a946de160db96f22c355e8b" dockerfile = ( _ROOT / "docker" / "contextual-orchestrator" / "Dockerfile" ).read_text(encoding="utf-8") From 4a4acc38382c3ffb22dbf5092542ee454d4e30f5 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 10:53:10 +0900 Subject: [PATCH 079/393] fix: close customer-copy and migration review findings --- .../test_operations_dashboard_postgres.py | 1 + docs/adr/0024-rankweave-fusion-fail-closed.md | 5 +++-- frontend/src/App.css | 2 +- .../src/components/AskAnswerTimeline.test.tsx | 13 +++++++++++++ frontend/src/components/AskAnswerTimeline.tsx | 13 +++++++++---- .../components/OperationsDashboard.test.tsx | 8 ++++++++ .../src/components/OperationsDashboard.tsx | 7 ++++++- lineageweave/leftover_pairs.py | 4 ++-- ...0214_topic_context_influence_projection.sql | 18 ++++++++++++++++++ pyproject.toml | 2 +- tests/test_schema.py | 2 +- 11 files changed, 63 insertions(+), 12 deletions(-) diff --git a/backend/tests/test_operations_dashboard_postgres.py b/backend/tests/test_operations_dashboard_postgres.py index fad533606..2c987979b 100644 --- a/backend/tests/test_operations_dashboard_postgres.py +++ b/backend/tests/test_operations_dashboard_postgres.py @@ -30,6 +30,7 @@ async def test_operations_dashboard_sql_binds_against_postgres() -> None: "operations_case_milestone", "operations_case_missing_milestone", "topic_context_membership", + "topic_activity_interval", "topic_post_context_influence", ) for table_name in required_tables: diff --git a/docs/adr/0024-rankweave-fusion-fail-closed.md b/docs/adr/0024-rankweave-fusion-fail-closed.md index 15c875eee..184bfe31b 100644 --- a/docs/adr/0024-rankweave-fusion-fail-closed.md +++ b/docs/adr/0024-rankweave-fusion-fail-closed.md @@ -22,8 +22,9 @@ tables, and does not bind the demo IdP to production Keyverse. 1. Consume RankWeave only through `RankWeaveClient`. The default transport raises `RankWeaveNotAvailable`. `build_rankweave_client (disabled=False)` uses `LibraryRankWeaveTransport`, which imports - `weighted_reciprocal_rank_fuse` inside the call so a missing - package fail-closes. + both `reciprocal_rank_fuse` (the default parameter-free path) and + `weighted_reciprocal_rank_fuse` (the explicit weighted path) inside + the call so a missing package fail-closes. 2. `GET /api/rankings` (`post_read`) loads ABAC-visible posts as two rank-only channels: temporal (newest first) and lexical (token overlap with the synthetic demo query `pricing quote delivery`). diff --git a/frontend/src/App.css b/frontend/src/App.css index c9c56120d..f9bd2ee0b 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1104,7 +1104,7 @@ min-width: var(--size-control-min); min-height: var(--size-control-min); border: 0; - border-bottom: 2px solid currentColor; + border-bottom: 2px solid currentcolor; background: transparent; color: var(--color-accent); cursor: pointer; diff --git a/frontend/src/components/AskAnswerTimeline.test.tsx b/frontend/src/components/AskAnswerTimeline.test.tsx index 9f0855f2e..1465ad9b7 100644 --- a/frontend/src/components/AskAnswerTimeline.test.tsx +++ b/frontend/src/components/AskAnswerTimeline.test.tsx @@ -91,6 +91,7 @@ describe("AskAnswerTimeline", () => { ...answer, cited_events: [{ ...answer.cited_events![0], observed_at: null, time_axis_code: null }], cited_posts: [answer.cited_posts![0]], + cited_post_ids: [answer.cited_posts![0].post_id], }} onOpenEvidence={() => undefined} onOpenPost={() => undefined} @@ -99,4 +100,16 @@ describe("AskAnswerTimeline", () => { expect(screen.getByText("Observed time unavailable")).toBeInTheDocument(); }); + + it("keeps an id-only citation visible when details are unavailable", () => { + render( + undefined} + onOpenPost={() => undefined} + />, + ); + expect(screen.getByRole("article", { name: "Evidence 1: Record details" })).toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/AskAnswerTimeline.tsx b/frontend/src/components/AskAnswerTimeline.tsx index 58bc26dfd..6da8678d7 100644 --- a/frontend/src/components/AskAnswerTimeline.tsx +++ b/frontend/src/components/AskAnswerTimeline.tsx @@ -45,11 +45,16 @@ export function AskAnswerTimeline({ question, answer, onOpenEvidence, onOpenPost const citationRefs = useRef(new Map()); const cardRefs = useRef(new Map()); const eventsByPost = new Map(answer.cited_events?.map((event) => [event.post_id, event])); - const citations: Citation[] = (answer.cited_posts ?? []).map((post, index) => ({ + const postDetails = new Map((answer.cited_posts ?? []).map((post) => [post.post_id, post])); + const citationIds = [...new Set([ + ...(answer.cited_post_ids ?? []), + ...(answer.cited_posts ?? []).map((post) => post.post_id), + ])]; + const citations: Citation[] = citationIds.map((postId, index) => ({ citationNumber: index + 1, - postId: post.post_id, - postTitle: post.post_title, - event: eventsByPost.get(post.post_id), + postId, + postTitle: postDetails.get(postId)?.post_title ?? t("Record details"), + event: eventsByPost.get(postId), })); const chronological = [...citations].sort((left, right) => { const leftEpoch = observedEpoch(left.event); diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx index 08dc61138..6f6118e89 100644 --- a/frontend/src/components/OperationsDashboard.test.tsx +++ b/frontend/src/components/OperationsDashboard.test.tsx @@ -141,6 +141,14 @@ describe("OperationsDashboardView", () => { expect(screen.queryByText(/추정 점수/)).not.toBeInTheDocument(); }); + it("shows a separate next action when topic influence does not apply", () => { + const notApplicable = { ...data, topic_context: { ...data.topic_context, status_code: "not_applicable" as const, reason_code: "no_eligible_posts" } }; + render( undefined} />); + expect(screen.getByText("이 기간에는 글 영향도를 계산할 대상이 없습니다.")).toBeInTheDocument(); + expect(screen.getByText("다른 기간을 선택해 분석 가능한 글이 있는지 확인하세요.")).toBeInTheDocument(); + expect(screen.queryByText("글 영향도를 아직 확인할 수 없습니다.")).not.toBeInTheDocument(); + }); + it("opens accepted exact influence evidence and retains equal values", async () => { const onOpenPost = vi.fn(); const influence = { diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx index b9882716e..6e10329ca 100644 --- a/frontend/src/components/OperationsDashboard.tsx +++ b/frontend/src/components/OperationsDashboard.tsx @@ -196,7 +196,12 @@ export function TopicContextInfluence({ data, onOpenPost }: { data: OperationsDa

          글 영향도

          시간 흐름별 Topic model influence

          사업 가치가 아닌, 해당 글을 제외했을 때 Topic·조직 수준 모형이 변하는 정도입니다.

          - {topicContext.status_code === "unavailable" ? ( + {topicContext.status_code === "not_applicable" ? ( +
          + 이 기간에는 글 영향도를 계산할 대상이 없습니다. +

          다른 기간을 선택해 분석 가능한 글이 있는지 확인하세요.

          +
          + ) : topicContext.status_code === "unavailable" ? (
          글 영향도를 아직 확인할 수 없습니다.

          분석 대상 글의 사건 시점과 조직 소속을 확인한 뒤 다시 분석하세요.

          diff --git a/lineageweave/leftover_pairs.py b/lineageweave/leftover_pairs.py index 572749ffe..1254b1139 100644 --- a/lineageweave/leftover_pairs.py +++ b/lineageweave/leftover_pairs.py @@ -135,8 +135,8 @@ def leftover_map_coverage_from_residual( _validate_shapes(post_ids, item_codes, matrix, expected) result = residual_interaction_map(matrix, expected, axis_count=_LEFTOVER_MAP_AXES) - map_posts = int(len(result.person_indices)) - map_items = int(len(result.item_indices)) + map_posts = len(result.person_indices) + map_items = len(result.item_indices) return LeftoverMapCoverage( map_post_count=map_posts, scored_post_count=int(result.scored_person_count), diff --git a/migrations/0214_topic_context_influence_projection.sql b/migrations/0214_topic_context_influence_projection.sql index ffa88b202..56452e4ba 100644 --- a/migrations/0214_topic_context_influence_projection.sql +++ b/migrations/0214_topic_context_influence_projection.sql @@ -158,14 +158,32 @@ create table if not exists topic_post_context_influence ( alter table topic_model_run add column if not exists coordinate_kind_code text check (coordinate_kind_code in ('logistic_normal_coordinate', 'plausible_value')); +do $$ +begin + if exists (select 1 from topic_model_run where coordinate_kind_code is null) then + raise exception '0214 cannot enforce coordinate_kind_code: existing runs need producer reanalysis'; + end if; +end $$; alter table topic_model_run alter column coordinate_kind_code set not null; alter table topic_lineage_relation add column if not exists provenance_assertion_id uuid references provenance_assertion (assertion_id); +do $$ +begin + if exists (select 1 from topic_lineage_relation where provenance_assertion_id is null) then + raise exception '0214 cannot enforce lineage provenance: existing relations need producer reanalysis'; + end if; +end $$; alter table topic_lineage_relation alter column provenance_assertion_id set not null; alter table topic_context_membership add column if not exists provenance_assertion_id uuid references provenance_assertion (assertion_id); +do $$ +begin + if exists (select 1 from topic_context_membership where provenance_assertion_id is null) then + raise exception '0214 cannot enforce membership provenance: existing memberships need producer reanalysis'; + end if; +end $$; alter table topic_context_membership alter column provenance_assertion_id set not null; create index if not exists topic_activity_interval_time_idx diff --git a/pyproject.toml b/pyproject.toml index 2a55f69b9..56078316e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ ] [build-system] -requires = ["setuptools>=75"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project.optional-dependencies] diff --git a/tests/test_schema.py b/tests/test_schema.py index 3529fd7e1..62040411b 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -354,7 +354,7 @@ def test_operations_case_milestones_reject_cross_kind_types(schema_db) -> None: (str(post_id), str(post_id), "a" * 64), (str(post_id),), ) - for index, (statement, values) in enumerate(zip(invalid_statements, parameters)): + for index, (statement, values) in enumerate(zip(invalid_statements, parameters, strict=True)): savepoint = f"invalid_milestone_kind_{index}" cur.execute(f"savepoint {savepoint}") with pytest.raises(psycopg2.errors.CheckViolation): From f88e7c648b93ad819d4501a1616161f2d3f16271 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 10:54:41 +0900 Subject: [PATCH 080/393] Revert "fix(orchestrator): pin embedding capability selection" This reverts commit 72703c31fec69cf45e51451036d38c33566d989a. --- docker/contextual-orchestrator/Dockerfile | 2 +- docs/adr/0083-orchestrator-runtime-commit-pin.md | 3 +-- tests/test_documentation_hygiene.py | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile index c14885083..0af60f58c 100644 --- a/docker/contextual-orchestrator/Dockerfile +++ b/docker/contextual-orchestrator/Dockerfile @@ -5,7 +5,7 @@ WORKDIR /app # Reuse the upstream implementation without copying it into LineageWeave. # Pin the runtime to a reviewed immutable upstream commit; model selection, # structured synthesis, and reasoning policy stay in contextual-orchestrator. -ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/315c9f05834df6df3a946de160db96f22c355e8b.tar.gz /tmp/contextual-orchestrator.tar.gz +ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/1a40e0f7ad10d1a24137d69d20e44fc9a5dcdd89.tar.gz /tmp/contextual-orchestrator.tar.gz RUN mkdir /tmp/contextual-orchestrator \ && tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \ && cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \ diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md index 5bfbef271..1ad14cade 100644 --- a/docs/adr/0083-orchestrator-runtime-commit-pin.md +++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md @@ -15,8 +15,7 @@ multi-agent. ## Decision `docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to -commit `315c9f05834df6df3a946de160db96f22c355e8b`, the merge commit for -contextual-orchestrator PR 789's embedding-capability selection contract. The pin remains explicit +commit `1a40e0f7ad10d1a24137d69d20e44fc9a5dcdd89`. The pin remains explicit and immutable until the reviewed upstream change is superseded; it is not a moving `main` reference and it is not a LineageWeave monkey patch. diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py index bb5bedfbc..b287d3470 100644 --- a/tests/test_documentation_hygiene.py +++ b/tests/test_documentation_hygiene.py @@ -118,7 +118,7 @@ def test_role_catalog_identity_migration_is_wired() -> None: def test_orchestrator_runtime_pin_matches_adr() -> None: """The image pin and ADR must describe the same immutable upstream commit.""" - expected_embedding_contract_commit = "315c9f05834df6df3a946de160db96f22c355e8b" + expected_embedding_contract_commit = "1a40e0f7ad10d1a24137d69d20e44fc9a5dcdd89" dockerfile = ( _ROOT / "docker" / "contextual-orchestrator" / "Dockerfile" ).read_text(encoding="utf-8") From f3b1ef13f97ac995b66357cf3466f2d5245bfaef Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 11:03:57 +0900 Subject: [PATCH 081/393] feat: enqueue bounded semantic backfill jobs --- ARCHITECTURE.md | 8 + backend/app/main.py | 31 +++- backend/app/post_content_queue.py | 115 ++++++++++++++ ...98-valkey-backed-post-content-ingestion.md | 16 +- docs/product-requirements.md | 3 + docs/product-technical-gap-baseline.md | 2 +- scripts/queue_post_content_backfill.py | 125 ++------------- tests/test_post_content_backfill_endpoint.py | 130 ++++++++++++++++ tests/test_post_content_queue.py | 147 ++++++++++++++++++ ...test_queue_post_content_backfill_script.py | 96 ++++++++++++ tests/test_static_sql_review_contracts.py | 3 +- 11 files changed, 560 insertions(+), 116 deletions(-) create mode 100644 tests/test_post_content_backfill_endpoint.py create mode 100644 tests/test_queue_post_content_backfill_script.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 290e7697e..7d04f6daa 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -438,6 +438,14 @@ name, confirmed the events on the activity endpoint, and independently confirmed the stream's existence and length with `valkey-cli` directly against the `valkey` container. +Post-content ingestion uses the same transport with a stronger durability +boundary (ADR 0098): PostgreSQL owns each job and Valkey only wakes the worker. +`POST /api/post-content/backfill` is a `post_admin`-gated producer for one +1--200-row eligible page. It commits jobs before publishing, returns HTTP 202 +without running semantic providers, and reports wake-ups that the worker's +bounded recovery sweep must republish. `FOR UPDATE SKIP LOCKED` partitions +concurrent operator calls without a second scheduler or an in-memory task. + ## Phase 5c: customer commitment derivation and the calendar The brief asked for two separate-sounding things: issues auto-registered diff --git a/backend/app/main.py b/backend/app/main.py index 9a8ad5045..5c90eda4a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -32,7 +32,7 @@ import redis.asyncio as redis from fastapi import Depends, FastAPI, HTTPException, Query, status from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel +from pydantic import BaseModel, Field from backend.app.activity_stream import ( create_valkey_client, @@ -131,6 +131,7 @@ persist_post_chat, ) from backend.app.post_content_queue import ( + enqueue_post_content_backfill, ensure_post_content_job, post_content_api_status, post_content_is_complete, @@ -802,12 +803,40 @@ class LocalePreferenceRequest(BaseModel): preferred_locale: Literal["en", "ko", "zh", "ja", "vi"] +class PostContentBackfillRequest(BaseModel): + """Bounded operator request for durable semantic-content ingestion.""" + + limit: int = Field(default=100, ge=1, le=200) + + class CustomerHintResolveRequest(BaseModel): """Body of a POST /api/customer-master/resolve-hint request.""" hint_code: str +@app.post("/api/post-content/backfill", status_code=status.HTTP_202_ACCEPTED) +async def queue_post_content_backfill( + request: PostContentBackfillRequest, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), +) -> dict[str, int | bool]: + """Queue one bounded corpus page and return before semantic work runs.""" + _require_post_admin(account) + settings = load_settings() + require_orchestrator_evidence = bool( + settings.orchestrator_base_url and settings.orchestrator_api_key + ) + return await enqueue_post_content_backfill( + pool, + valkey, + limit=request.limit, + require_embedding=require_orchestrator_evidence, + require_structure=require_orchestrator_evidence, + ) + + @app.patch("/api/me/preferences") async def update_me_preferences( preference: LocalePreferenceRequest, diff --git a/backend/app/post_content_queue.py b/backend/app/post_content_queue.py index 43e44e015..dfc9181d9 100644 --- a/backend/app/post_content_queue.py +++ b/backend/app/post_content_queue.py @@ -10,6 +10,7 @@ import asyncpg import redis.asyncio as redis +from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL from lineageweave.observability import traced POST_CONTENT_STREAM_KEY = "post-content-ingestion" @@ -307,6 +308,120 @@ async def ensure_post_content_job( ) +async def enqueue_post_content_backfill( + pool: asyncpg.Pool, + client: redis.Redis | None, + *, + limit: int, + require_embedding: bool, + require_structure: bool, +) -> dict[str, int | bool]: + """Durably enqueue one bounded page of eligible incomplete source posts. + + PostgreSQL is committed before Valkey is touched. A missing wake-up is + therefore recoverable by :func:`republish_queued_post_content_jobs` rather + than turning an operator request into lost work. Active and terminal jobs + are excluded so repeated requests neither duplicate work nor reset the + explicit retry boundary. + """ + if not 1 <= limit <= 200: + raise ValueError("limit must be between 1 and 200") + query = f""" + select post.post_id, post.post_body + from source_post post + left join post_content_ingestion_job job on job.post_id = post.post_id + where {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} + and (job.post_id is null or job.status_code = $1) + and ( + not exists ( + select 1 from post_content_unit unit + where unit.post_id = post.post_id + ) + or ($2::boolean and exists ( + select 1 + from post_content_unit unit + left join post_content_embedding embedding + on embedding.post_content_unit_id = unit.post_content_unit_id + where unit.post_id = post.post_id + and embedding.post_content_embedding_id is null + )) + or ($2::boolean and exists ( + select 1 + from post_content_unit unit + join post_content_image image + on image.post_content_unit_id = unit.post_content_unit_id + join post_content_image_region region + on region.post_content_image_id = image.post_content_image_id + left join post_content_image_region_embedding embedding + on embedding.post_content_image_region_id = region.post_content_image_region_id + where unit.post_id = post.post_id + and region.description_status_code = 'described' + and embedding.post_content_image_region_embedding_id is null + )) + or ($3::boolean and exists ( + select 1 + from post_content_unit unit + left join post_content_unit_structure structure + on structure.post_content_unit_id = unit.post_content_unit_id + where unit.post_id = post.post_id + and unit.unit_kind_code <> 'image' + and ( + structure.post_content_unit_structure_id is null + or structure.decision_source_code = 'unresolved' + ) + )) + ) + order by post.created_at, post.post_id + limit $4 + for update of post skip locked + """ + requests: list[PostContentJobRequest] = [] + has_more = False + async with pool.acquire() as conn: + async with conn.transaction(): + # Safe SQL: the eligibility predicate is an immutable schema fragment; values are bound. + rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + query, + SUCCEEDED, + require_embedding, + require_structure, + limit + 1, + ) + has_more = len(rows) > limit + for row in rows[:limit]: + post_id = str(row["post_id"]) + complete = await post_content_is_complete( + conn, + post_id, + require_embedding=require_embedding, + require_structure=require_structure, + ) + request = await ensure_post_content_job( + conn, + post_id, + str(row["post_body"] or ""), + content_complete=complete, + ) + if request.should_publish: + requests.append(request) + + published = 0 + for request in requests: + if await publish_post_content_event( + client, + post_id=request.post_id, + source_body_digest=request.source_body_sha256, + ): + published += 1 + return { + "selected_posts": min(len(rows), limit), + "queued_posts": len(requests), + "published_events": published, + "recovery_pending": len(requests) - published, + "has_more": has_more, + } + + async def requeue_failed_post_content_job( conn: asyncpg.Connection, post_id: str, diff --git a/docs/adr/0098-valkey-backed-post-content-ingestion.md b/docs/adr/0098-valkey-backed-post-content-ingestion.md index 4077e398d..b106cb287 100644 --- a/docs/adr/0098-valkey-backed-post-content-ingestion.md +++ b/docs/adr/0098-valkey-backed-post-content-ingestion.md @@ -92,10 +92,18 @@ event. ## Corpus backfill (2026-08-20) -Operational backfill MUST use `scripts/queue_post_content_backfill.py`. It -selects only non-draft, non-deleted rows with real source context, records the -same completeness-aware job state in PostgreSQL, and publishes wake-ups through -Valkey. Direct provider calls are not a substitute for the worker queue. +Operational backfill MUST use `scripts/queue_post_content_backfill.py` or +`POST /api/post-content/backfill`; both call the same producer. The HTTP +entry point requires `post_admin`, accepts only a 1--200 row page, and returns +HTTP 202 after committing the ledger and attempting wake-ups; it never runs a +provider in the request. The CLI has the same bound and no whole-corpus mode. +The producer applies `SOURCE_POST_ELIGIBILITY_SQL`, locks source rows with +`SKIP LOCKED`, selects only new or incomplete-succeeded jobs, rechecks the +shared completeness predicate, and records the existing job state in +PostgreSQL. Repeated calls therefore do not reset active or terminal work. +If Valkey is unavailable, the response reports `recovery_pending` and the +committed queued rows are republished by the existing recovery sweep. Direct +provider calls are not a substitute for the worker queue. ### Operational timeout for structure adjudication diff --git a/docs/product-requirements.md b/docs/product-requirements.md index dbce16860..fcb9f741e 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -72,6 +72,9 @@ dangling endpoints fail closed; fixed input produces stable page boundaries. - Preserve source representation and derive ordered paragraph, list, table, formula, conversation-turn, and image-region semantic units. - Route embeddings, LLM, and VISION through contextual-orchestrator. +- Let an authorized administrator enqueue only a bounded page of eligible, + incomplete posts into the durable worker ledger; acknowledge before model + work and recover a missing broker wake-up from PostgreSQL. - Apply authorization/time/process scope before ranking and again before response delivery. - Keep internal post citations separate from external public citations. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 24ff4c85b..b83e0b9b7 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -439,7 +439,7 @@ this file per §3.5 of the prior snapshot). | Gap | Current evidence | Acceptance requirement | | --- | --- | --- | | Protected release | 11 open PRs at the 07:26 KST snapshot; the exact-head inventory in section 1 records their current evidence boundaries | Terminal exact-head checks, no unresolved threads, independent exact-head approvals, protected squash-merge SHA | -| Evidence-grounded operations workspace | Protected-main #614 delivers governed semantic Ask, live Similar VOC, disjoint pending/failed analysis metrics, full Storybook state inventory, and current desktop/mobile screenshot evidence. Authorized-corpus backfill acceptance remains unavailable | Perform authenticated authorized-corpus acceptance with aggregate evidence and retain fail-closed no-match behavior | +| Evidence-grounded operations workspace | Protected-main #614 delivers governed semantic Ask, live Similar VOC, disjoint pending/failed analysis metrics, full Storybook state inventory, and current desktop/mobile screenshot evidence. The current Dashboard stack adds a candidate `post_admin`-gated, 1--200-row durable semantic-backfill enqueue path that reuses PostgreSQL recovery and never runs providers in HTTP; authorized-corpus acceptance remains unavailable | Land the candidate, then perform authenticated authorized-corpus acceptance with aggregate queued/published/recovery and derived-evidence counts while retaining fail-closed no-match behavior | | Shared frontend gate | The ADR 0109 login repair is on protected `main`; eight older branches carried the defect and received the same verified repair this loop (#521–#560) | Keep every future branch cut from post-repair bases; re-verify with frontend lint/test/build before push | | Identifying baseline regression | `main` gap file listed real post identifiers; separately, closed #506 and pre-existing public history contain a private runtime source-table identifier, while current `main` and #507 trees are clean | Land this non-identifying rewrite, then coordinate ADR 0001 history remediation with security/privacy owners; do not reproduce the value, force-push, or delete evidence ad hoc | | Authorized-corpus runtime | Repository tests use synthetic fixtures; private records remain outside git | Authenticated runtime validation returning only aggregate, non-identifying evidence | diff --git a/scripts/queue_post_content_backfill.py b/scripts/queue_post_content_backfill.py index bac966ddc..c94d3ca97 100644 --- a/scripts/queue_post_content_backfill.py +++ b/scripts/queue_post_content_backfill.py @@ -17,9 +17,7 @@ sys.path.insert(0, str(REPOSITORY_ROOT)) from backend.app.post_content_queue import ( # noqa: E402 - ensure_post_content_job, - post_content_is_complete, - publish_post_content_event, + enqueue_post_content_backfill, ) from backend.app.config import load_settings # noqa: E402 @@ -37,8 +35,7 @@ def _parser() -> argparse.ArgumentParser: "--valkey-url", default=os.environ.get("VALKEY_URL", "redis://localhost:16379/0"), ) - parser.add_argument("--limit", type=int, default=100) - parser.add_argument("--all", action="store_true", help="scan the complete real corpus") + parser.add_argument("--limit", type=int, choices=range(1, 201), default=100) return parser @@ -46,118 +43,28 @@ async def queue_post_content_backfill( target_dsn: str, valkey_url: str, *, - limit: int | None, -) -> dict[str, int]: - if limit is not None and limit < 1: - raise ValueError("limit must be positive") + limit: int, +) -> dict[str, int | bool]: + """Queue one bounded page through the shared durable producer.""" + if not 1 <= limit <= 200: + raise ValueError("limit must be between 1 and 200") settings = load_settings() require_orchestrator_evidence = bool( settings.orchestrator_base_url and settings.orchestrator_api_key ) - connection = await asyncpg.connect(target_dsn) + pool = await asyncpg.create_pool(target_dsn, min_size=1, max_size=1) client = redis.from_url(valkey_url, decode_responses=True) - result = {"scanned_posts": 0, "already_complete": 0, "queued_posts": 0, "published_events": 0} try: - rows = await connection.fetch( - """ - select post_id, post_body - from source_post post - where nullif(btrim(source_draft_code), '') is null - and nullif(btrim(source_deleted_flag), '') is null - and ( - nullif(btrim(source_author_code), '') is not null - or nullif(btrim(source_author_name), '') is not null - or nullif(btrim(source_company_code), '') is not null - or nullif(btrim(source_company_name), '') is not null - or nullif(btrim(source_process_unit_code), '') is not null - or nullif(btrim(source_process_unit_name), '') is not null - or nullif(btrim(source_sales_pool_code), '') is not null - or nullif(btrim(source_sales_pool_name), '') is not null - or nullif(btrim(source_customer_code), '') is not null - or nullif(btrim(source_customer_name), '') is not null - or nullif(btrim(source_project_code), '') is not null - or nullif(btrim(source_project_name), '') is not null - ) - and ( - not exists ( - select 1 - from post_content_unit unit - where unit.post_id = post.post_id - ) - or ($1::boolean and exists ( - select 1 - from post_content_unit unit - left join post_content_embedding embedding - on embedding.post_content_unit_id = unit.post_content_unit_id - where unit.post_id = post.post_id - and embedding.post_content_embedding_id is null - )) - or ($1::boolean and exists ( - select 1 - from post_content_unit unit - join post_content_image image - on image.post_content_unit_id = unit.post_content_unit_id - join post_content_image_region region - on region.post_content_image_id = image.post_content_image_id - left join post_content_image_region_embedding embedding - on embedding.post_content_image_region_id = region.post_content_image_region_id - where unit.post_id = post.post_id - and region.description_status_code = 'described' - and embedding.post_content_image_region_embedding_id is null - )) - or ($2::boolean and exists ( - select 1 - from post_content_unit unit - left join post_content_unit_structure structure - on structure.post_content_unit_id = unit.post_content_unit_id - where unit.post_id = post.post_id - and unit.unit_kind_code <> 'image' - and ( - structure.post_content_unit_structure_id is null - or structure.decision_source_code = 'unresolved' - ) - )) - ) - order by post.created_at, post.post_id - limit $3::bigint - """, - require_orchestrator_evidence, - require_orchestrator_evidence, - limit if limit is not None else 9223372036854775807, + return await enqueue_post_content_backfill( + pool, + client, + limit=limit, + require_embedding=require_orchestrator_evidence, + require_structure=require_orchestrator_evidence, ) - for row in rows: - result["scanned_posts"] += 1 - post_id = str(row["post_id"]) - async with connection.transaction(): - complete = await post_content_is_complete( - connection, - post_id, - require_embedding=require_orchestrator_evidence, - require_structure=require_orchestrator_evidence, - ) - request = await ensure_post_content_job( - connection, - post_id, - str(row["post_body"] or ""), - content_complete=complete, - ) - if complete and not request.should_publish: - result["already_complete"] += 1 - continue - if request.should_publish: - entry_id = await publish_post_content_event( - client, - post_id=post_id, - source_body_digest=request.source_body_sha256, - ) - if entry_id is None: - raise RuntimeError(f"Valkey did not publish post-content job {post_id}") - result["published_events"] += 1 - result["queued_posts"] += 1 - return result finally: - await connection.close() + await pool.close() await client.aclose() @@ -167,7 +74,7 @@ def main() -> None: queue_post_content_backfill( args.target_dsn, args.valkey_url, - limit=None if args.all else args.limit, + limit=args.limit, ) ) print(result) diff --git a/tests/test_post_content_backfill_endpoint.py b/tests/test_post_content_backfill_endpoint.py new file mode 100644 index 000000000..0be04bd8d --- /dev/null +++ b/tests/test_post_content_backfill_endpoint.py @@ -0,0 +1,130 @@ +"""Authorization and request bounds for the semantic backfill operator API.""" + +from __future__ import annotations + +import asyncio + +import pytest +from pydantic import ValidationError + +from backend.app import main +from backend.app.auth import CurrentAccount + + +def _account(*permissions: str) -> CurrentAccount: + """Build one synthetic account without an OIDC or database dependency.""" + return CurrentAccount( + user_account_id="00000000-0000-0000-0000-000000000001", + external_subject_id="synthetic-subject", + display_name="Synthetic operator", + preferred_locale="en", + corporate_entity_ids=frozenset(), + process_unit_ids=frozenset(), + permission_codes=frozenset(permissions), + ) + + +def test_backfill_endpoint_requires_post_admin() -> None: + """A reader cannot enqueue corpus-wide semantic processing.""" + with pytest.raises(main.HTTPException) as raised: + asyncio.run( + main.queue_post_content_backfill( + main.PostContentBackfillRequest(), + account=_account("post_read"), + pool=object(), + valkey=object(), + ) + ) + assert raised.value.status_code == 403 + + +def test_backfill_request_limit_is_bounded() -> None: + """Pydantic rejects zero and corpus-sized operator requests.""" + for limit in (0, 201): + with pytest.raises(ValidationError): + main.PostContentBackfillRequest(limit=limit) + + +def test_backfill_endpoint_only_enqueues_durable_work( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The accepted response delegates once and never invokes a provider.""" + observed: dict[str, object] = {} + + async def enqueue(pool: object, valkey: object, **kwargs: object) -> dict[str, int | bool]: + observed.update(pool=pool, valkey=valkey, **kwargs) + return { + "selected_posts": 1, + "queued_posts": 1, + "published_events": 1, + "recovery_pending": 0, + "has_more": False, + } + + monkeypatch.setattr(main, "enqueue_post_content_backfill", enqueue) + monkeypatch.setattr( + main, + "load_settings", + lambda: type( + "Settings", + (), + {"orchestrator_base_url": "https://orchestrator.invalid", "orchestrator_api_key": "configured"}, + )(), + ) + pool = object() + valkey = object() + result = asyncio.run( + main.queue_post_content_backfill( + main.PostContentBackfillRequest(limit=17), + account=_account("post_admin"), + pool=pool, + valkey=valkey, + ) + ) + assert result["queued_posts"] == 1 + assert observed == { + "pool": pool, + "valkey": valkey, + "limit": 17, + "require_embedding": True, + "require_structure": True, + } + + +def test_backfill_endpoint_does_not_require_missing_model_evidence( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An unwired orchestrator remains unavailable instead of being fabricated.""" + observed: dict[str, object] = {} + + async def enqueue(_pool: object, _valkey: object, **kwargs: object) -> dict[str, int | bool]: + observed.update(kwargs) + return { + "selected_posts": 0, + "queued_posts": 0, + "published_events": 0, + "recovery_pending": 0, + "has_more": False, + } + + monkeypatch.setattr(main, "enqueue_post_content_backfill", enqueue) + monkeypatch.setattr( + main, + "load_settings", + lambda: type( + "Settings", (), {"orchestrator_base_url": "", "orchestrator_api_key": ""} + )(), + ) + asyncio.run( + main.queue_post_content_backfill( + main.PostContentBackfillRequest(), + account=_account("post_admin"), + pool=object(), + valkey=object(), + ) + ) + assert observed == { + "limit": 100, + "require_embedding": False, + "require_structure": False, + } diff --git a/tests/test_post_content_queue.py b/tests/test_post_content_queue.py index b4ad853bb..0377067ce 100644 --- a/tests/test_post_content_queue.py +++ b/tests/test_post_content_queue.py @@ -17,6 +17,8 @@ QUEUED, RUNNING, SUCCEEDED, + PostContentJobRequest, + enqueue_post_content_backfill, record_post_content_backfill_success, requeue_failed_post_content_job, post_content_api_status, @@ -40,6 +42,151 @@ def test_stream_is_a_wakeup_and_never_contains_a_body() -> None: assert source_body_sha256("body") != source_body_sha256("changed") +def test_bounded_backfill_is_idempotent_and_broker_loss_stays_recoverable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Select only new/succeeded work and retain queued rows after wake-up loss.""" + + class Transaction: + async def __aenter__(self) -> None: + return None + + async def __aexit__(self, *_args: object) -> None: + return None + + class Connection: + def transaction(self) -> Transaction: + return Transaction() + + async def fetch(self, query: str, *args: object) -> list[dict[str, str]]: + assert "source_draft_code" in query + assert "source_deleted_flag" in query + assert "job.post_id is null or job.status_code = $1" in query + assert "for update of post skip locked" in query.lower() + assert args == (SUCCEEDED, True, True, 3) + return [ + {"post_id": "00000000-0000-0000-0000-000000000001", "post_body": "one"}, + {"post_id": "00000000-0000-0000-0000-000000000002", "post_body": "two"}, + {"post_id": "00000000-0000-0000-0000-000000000003", "post_body": "three"}, + ] + + class Acquire: + async def __aenter__(self) -> Connection: + return Connection() + + async def __aexit__(self, *_args: object) -> None: + return None + + class Pool: + def acquire(self) -> Acquire: + return Acquire() + + async def incomplete(*_args: object, **_kwargs: object) -> bool: + return False + + async def ensure( + _conn: object, post_id: str, body: str, *, content_complete: bool + ) -> PostContentJobRequest: + assert content_complete is False + return PostContentJobRequest(post_id, source_body_sha256(body), QUEUED, True) + + publish_calls = 0 + + async def publish(*_args: object, **_kwargs: object) -> str | None: + nonlocal publish_calls + publish_calls += 1 + return "1-0" if publish_calls == 1 else None + + from backend.app import post_content_queue + + monkeypatch.setattr(post_content_queue, "post_content_is_complete", incomplete) + monkeypatch.setattr(post_content_queue, "ensure_post_content_job", ensure) + monkeypatch.setattr(post_content_queue, "publish_post_content_event", publish) + + result = asyncio.run( + enqueue_post_content_backfill( + Pool(), object(), limit=2, require_embedding=True, require_structure=True + ) + ) + assert result == { + "selected_posts": 2, + "queued_posts": 2, + "published_events": 1, + "recovery_pending": 1, + "has_more": True, + } + + +def test_backfill_skips_a_candidate_that_became_complete( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The shared completeness recheck wins over a stale candidate query.""" + + class Transaction: + async def __aenter__(self) -> None: + return None + + async def __aexit__(self, *_args: object) -> None: + return None + + class Connection: + def transaction(self) -> Transaction: + return Transaction() + + async def fetch(self, _query: str, *_args: object) -> list[dict[str, str]]: + return [ + {"post_id": "00000000-0000-0000-0000-000000000001", "post_body": "done"} + ] + + class Acquire: + async def __aenter__(self) -> Connection: + return Connection() + + async def __aexit__(self, *_args: object) -> None: + return None + + class Pool: + def acquire(self) -> Acquire: + return Acquire() + + async def complete(*_args: object, **_kwargs: object) -> bool: + return True + + async def ensure( + _conn: object, post_id: str, body: str, *, content_complete: bool + ) -> PostContentJobRequest: + assert content_complete is True + return PostContentJobRequest(post_id, source_body_sha256(body), SUCCEEDED, False) + + from backend.app import post_content_queue + + monkeypatch.setattr(post_content_queue, "post_content_is_complete", complete) + monkeypatch.setattr(post_content_queue, "ensure_post_content_job", ensure) + result = asyncio.run( + enqueue_post_content_backfill( + Pool(), object(), limit=2, require_embedding=False, require_structure=False + ) + ) + assert result == { + "selected_posts": 1, + "queued_posts": 0, + "published_events": 0, + "recovery_pending": 0, + "has_more": False, + } + + +@pytest.mark.parametrize("limit", [0, 201]) +def test_backfill_rejects_unbounded_pages(limit: int) -> None: + """The shared producer rejects callers that bypass the HTTP model bound.""" + with pytest.raises(ValueError, match="between 1 and 200"): + asyncio.run( + enqueue_post_content_backfill( + object(), object(), limit=limit, require_embedding=False, require_structure=False + ) + ) + + def test_api_status_does_not_call_failed_content_ready() -> None: assert post_content_api_status(QUEUED, content_present=False) == "processing" assert post_content_api_status(QUEUED, content_present=True) == "processing" diff --git a/tests/test_queue_post_content_backfill_script.py b/tests/test_queue_post_content_backfill_script.py new file mode 100644 index 000000000..d7835991c --- /dev/null +++ b/tests/test_queue_post_content_backfill_script.py @@ -0,0 +1,96 @@ +"""The operator CLI reuses the bounded durable producer.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +import pytest + +from scripts import queue_post_content_backfill as script + + +def test_parser_and_main_keep_the_operator_page_bounded( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """The executable accepts one bounded page and prints aggregate evidence.""" + parser = script._parser() + assert parser.parse_args(["--limit", "200"]).limit == 200 + with pytest.raises(SystemExit): + parser.parse_args(["--limit", "201"]) + + async def queue(*_args: object, **kwargs: object) -> dict[str, int]: + assert kwargs == {"limit": 7} + return {"queued_posts": 2} + + monkeypatch.setattr( + script, + "_parser", + lambda: SimpleNamespace( + parse_args=lambda: SimpleNamespace( + target_dsn="postgresql://invalid", + valkey_url="redis://invalid", + limit=7, + ) + ), + ) + monkeypatch.setattr(script, "queue_post_content_backfill", queue) + script.main() + assert capsys.readouterr().out.strip() == "{'queued_posts': 2}" + + +def test_script_uses_one_connection_pool_and_closes_resources( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The CLI delegates once and closes both transport handles.""" + closed: list[str] = [] + + class Pool: + async def close(self) -> None: + closed.append("pool") + + class Client: + async def aclose(self) -> None: + closed.append("client") + + pool = Pool() + client = Client() + + async def create_pool(*_args: object, **_kwargs: object) -> Pool: + return pool + + async def enqueue(_pool: object, _client: object, **kwargs: object) -> dict[str, int | bool]: + assert (_pool, _client) == (pool, client) + assert kwargs == { + "limit": 12, + "require_embedding": True, + "require_structure": True, + } + return {"queued_posts": 1} + + monkeypatch.setattr(script.asyncpg, "create_pool", create_pool) + monkeypatch.setattr(script.redis, "from_url", lambda *_args, **_kwargs: client) + monkeypatch.setattr(script, "enqueue_post_content_backfill", enqueue) + monkeypatch.setattr( + script, + "load_settings", + lambda: type( + "Settings", (), {"orchestrator_base_url": "https://example.invalid", "orchestrator_api_key": "set"} + )(), + ) + result = asyncio.run( + script.queue_post_content_backfill("postgresql://invalid", "redis://invalid", limit=12) + ) + assert result == {"queued_posts": 1} + assert closed == ["pool", "client"] + + +@pytest.mark.parametrize("limit", [0, 201]) +def test_script_rejects_unbounded_limits_before_connecting(limit: int) -> None: + """Invalid pages fail before any database or broker connection.""" + with pytest.raises(ValueError, match="between 1 and 200"): + asyncio.run( + script.queue_post_content_backfill( + "postgresql://invalid", "redis://invalid", limit=limit + ) + ) diff --git a/tests/test_static_sql_review_contracts.py b/tests/test_static_sql_review_contracts.py index 31c7896bc..d09df90d8 100644 --- a/tests/test_static_sql_review_contracts.py +++ b/tests/test_static_sql_review_contracts.py @@ -19,6 +19,7 @@ "backend/app/entity_relationship_ingestion.py", "backend/app/knowledge_graph.py", "backend/app/main.py", + "backend/app/post_content_queue.py", "backend/app/report_ingestion.py", "lineageweave/synthetic_seed_cleanup.py", "scripts/backfill_post_content.py", @@ -28,7 +29,7 @@ ) ASYNC_STATEMENT_METHODS = {"execute", "fetch", "fetchrow", "fetchval"} SQL_REVIEW_RULE = "python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli" -EXPECTED_SQL_SUPPRESSION_COUNT = 36 +EXPECTED_SQL_SUPPRESSION_COUNT = 37 @pytest.mark.parametrize("relative_path", SQL_REVIEW_PATHS) From 49f0369e0e095c562cfd8d874f60589ce7c08355 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 19:10:33 -0700 Subject: [PATCH 082/393] ops(postgres): plan measured runtime tuning (#684) * ops(postgres): plan measured runtime tuning * fix(postgres): support measured native setting units --------- Co-authored-by: Codex --- CHANGELOG.md | 6 + docker-compose.postgres-tuned.yml | 14 + ...0227-observed-postgresql-runtime-tuning.md | 91 ++++ docs/adr/README.md | 1 + .../operability/postgresql-observed-tuning.md | 48 ++ docs/product-technical-gap-baseline.md | 1 + scripts/plan_postgres_tuning.py | 458 +++++++++++++++++ tests/test_postgres_tuning_plan.py | 475 ++++++++++++++++++ 8 files changed, 1094 insertions(+) create mode 100644 docker-compose.postgres-tuned.yml create mode 100644 docs/adr/0227-observed-postgresql-runtime-tuning.md create mode 100644 docs/operability/postgresql-observed-tuning.md create mode 100644 scripts/plan_postgres_tuning.py create mode 100644 tests/test_postgres_tuning_plan.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 561ea7b9c..9efed2cde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,12 @@ All notable changes to this project are documented here. Format follows ### Added +- PostgreSQL Compose now has a plan-first automatic tuning procedure that + samples aligned WAL/checkpoint counters and container resources, calculates + only segment- and checkpoint-bound settings, validates an auditable plan, + preserves durability, and applies or rolls back solely through an approved + controlled restart. + - Global Ask answers now link numbered citations to authorized event cards in both directions. Each card names event time or the record-time fallback and opens the focused evidence layer or full source post without inventing a diff --git a/docker-compose.postgres-tuned.yml b/docker-compose.postgres-tuned.yml new file mode 100644 index 000000000..ef3843e1d --- /dev/null +++ b/docker-compose.postgres-tuned.yml @@ -0,0 +1,14 @@ +services: + postgres: + command: + - postgres + - -c + - max_wal_size=${POSTGRES_TUNED_MAX_WAL_SIZE:?generate and validate a tuning plan first} + - -c + - wal_buffers=${POSTGRES_TUNED_WAL_BUFFERS:?generate and validate a tuning plan first} + - -c + - fsync=${POSTGRES_TUNED_FSYNC:?generate and validate a tuning plan first} + - -c + - full_page_writes=${POSTGRES_TUNED_FULL_PAGE_WRITES:?generate and validate a tuning plan first} + - -c + - synchronous_commit=${POSTGRES_TUNED_SYNCHRONOUS_COMMIT:?generate and validate a tuning plan first} diff --git a/docs/adr/0227-observed-postgresql-runtime-tuning.md b/docs/adr/0227-observed-postgresql-runtime-tuning.md new file mode 100644 index 000000000..20ecf7819 --- /dev/null +++ b/docs/adr/0227-observed-postgresql-runtime-tuning.md @@ -0,0 +1,91 @@ +# ADR 0227: Observed PostgreSQL runtime tuning + +- Status: Accepted +- Date: 2026-08-26 + +## Context + +The canonical PostgreSQL 16 runtime has accumulated substantially more +requested than timed checkpoints and millions of `wal_buffers_full` events. +The currently running full-text index scan is CPU-bound and produces negligible +new WAL, so it is not evidence for changing storage concurrency or maintenance +memory. Historical cumulative counters are also unsafe to combine when their +statistics-reset instants differ. + +Static host-size profiles and conventional memory percentages would introduce +unsupported assumptions. PostgreSQL already supplies an automatic +`wal_buffers` calculation, a WAL-segment boundary, a configured checkpoint +interval, and cumulative workload counters. Those are the authoritative inputs +for the smallest measured correction. + +## Decision + +`scripts/plan_postgres_tuning.py` is the sole LineageWeave procedure for this +runtime tuning boundary. It performs two measurements separated by an +operator-declared observation duration and records: + +- PostgreSQL version and statistics-reset instants; +- `pg_stat_wal` and checkpoint deltas; +- current durability and tuning settings; +- `wal_segment_size` and the existing `checkpoint_timeout`; +- container memory limit, data-filesystem free bytes, and current `pg_wal` + bytes. + +The planner rejects counter resets, negative deltas, unsupported PostgreSQL +versions, incomplete durability evidence, or insufficient disk space. It emits +an immutable JSON audit plan and a Compose environment file. It never applies a +setting while PostgreSQL is running. + +The planner separately calculates WAL rates for the explicit sample and for +PostgreSQL's own `stats_reset` to snapshot window. The calculated +`max_wal_size` is the larger of its current value and the higher observed rate +projected over one already-configured checkpoint interval, rounded upward to +PostgreSQL's own WAL-segment size. This preserves historical write pressure +when the immediate sample is a CPU-bound, zero-WAL scan and directly targets +the documented condition in which WAL growth starts a checkpoint before +`checkpoint_timeout`; it does not add a private safety multiplier. When neither +window supports a larger value, `max_wal_size` remains unchanged even if the +requested-checkpoint count is high, because that counter does not prove which +request source caused each checkpoint. + +If either aligned observation window records at least one `wal_buffers_full` event, +`wal_buffers` becomes one measured WAL segment. PostgreSQL 16 documents one WAL +segment as the normal upper bound of its automatic selection. With no observed +full event, the current value remains unchanged. + +The procedure does **not** infer `shared_buffers`, `maintenance_work_mem`, +`effective_io_concurrency`, `maintenance_io_concurrency`, or +`wal_compression`. Their documented trade-offs require workload-specific memory +or storage latency/IOPS evidence that the WAL/checkpoint observation does not +provide. A CPU-bound index scan is explicitly not storage-concurrency evidence. + +`fsync`, `full_page_writes`, and `synchronous_commit` must all remain enabled. +The generated environment file is consumed only by the explicit +`docker-compose.postgres-tuned.yml` overlay during a controlled PostgreSQL +restart. The base Compose file remains the rollback path: remove the overlay +and restart PostgreSQL. The JSON plan records both proposed and rollback +values. + +## Consequences + +- A tuning proposal is reproducible from captured measurements and contains no + hand-selected weights, ratios, or thresholds. +- A short or unrepresentative observation can retain current settings but + cannot silently tune them. +- Increased `max_wal_size` can lengthen crash recovery and consume more disk; + the plan exposes both effects and refuses a proposal whose exact additional + reservation exceeds observed free space. +- Applying or rolling back requires an intentional service restart and normal + post-restart health/config verification. + +## References + +PostgreSQL Global Development Group. (2026a). *PostgreSQL 16 documentation: +20.5. Write ahead log*. https://www.postgresql.org/docs/16/runtime-config-wal.html + +PostgreSQL Global Development Group. (2026b). *PostgreSQL 16 documentation: +30.5. WAL configuration*. https://www.postgresql.org/docs/16/wal-configuration.html + +PostgreSQL Global Development Group. (2026c). *PostgreSQL 16 documentation: +20.4. Resource consumption*. +https://www.postgresql.org/docs/16/runtime-config-resource.html diff --git a/docs/adr/README.md b/docs/adr/README.md index b12fc90b4..2245a7cd8 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -26,6 +26,7 @@ decision from them. | Canonical Docker Compose project (`name: lineageweave`) | [0224](0224-canonical-compose-project.md) | | Ask answer citation and evidence-timeline interaction | [0225](0225-ask-answer-evidence-timeline.md) | | macOS-native Rust/MLX mathematical compute boundary | [0226](0226-macos-native-mlx-mathematical-compute-boundary.md), [0208](0208-externalize-local-mathematical-compute.md) | +| Observed PostgreSQL WAL/checkpoint tuning plan | [0227](0227-observed-postgresql-runtime-tuning.md) | [0011](0011-prov-o-standard-relations.md) and [0065](0065-prov-o-provenance-boundary.md) cite the dated W3C PROV-O and PROV-DM Recommendations (https://www.w3.org/TR/2013/REC-prov-o-20130430/ and https://www.w3.org/TR/2013/REC-prov-dm-20130430/). diff --git a/docs/operability/postgresql-observed-tuning.md b/docs/operability/postgresql-observed-tuning.md new file mode 100644 index 000000000..5433548d4 --- /dev/null +++ b/docs/operability/postgresql-observed-tuning.md @@ -0,0 +1,48 @@ +# PostgreSQL observed tuning procedure + +This procedure produces a plan before it changes a service. Run it only after +the canonical migration and other controlled database work have completed. +The observation duration is required rather than defaulted: select a window +that contains the workload being tuned and record that choice with the plan. + +```bash +uv run python scripts/plan_postgres_tuning.py plan \ + --sample-seconds "$OBSERVATION_SECONDS" \ + --output /tmp/lineageweave-postgres-tuning-plan.json + +uv run python scripts/plan_postgres_tuning.py validate \ + --plan /tmp/lineageweave-postgres-tuning-plan.json \ + --env-output /tmp/lineageweave-postgres-tuning.env +``` + +Review the JSON evidence, proposed settings, exact disk reservation, retained +settings, and rollback values. Validation renders the Compose configuration but +does not touch a container. + +Apply only in an approved restart window. Copy the printed `plan_id` exactly; +the procedure rejects a changed plan or a different approval value. + +```bash +uv run python scripts/plan_postgres_tuning.py apply \ + --plan /tmp/lineageweave-postgres-tuning-plan.json \ + --env-output /tmp/lineageweave-postgres-tuning.env \ + --approve-plan-id "$APPROVED_PLAN_ID" +``` + +After PostgreSQL becomes healthy, compare `SHOW max_wal_size`, +`SHOW wal_buffers`, all three durability settings, `pg_stat_wal`, and +checkpoint counters with the plan. Do not attribute the CPU time of an active +GIN scan to WAL or storage concurrency when its sampled WAL delta is zero. + +Rollback uses the plan's captured pre-change values and the same controlled +restart gate: + +```bash +uv run python scripts/plan_postgres_tuning.py rollback \ + --plan /tmp/lineageweave-postgres-tuning-plan.json \ + --env-output /tmp/lineageweave-postgres-rollback.env \ + --approve-plan-id "$APPROVED_PLAN_ID" +``` + +The base `docker-compose.yml` contains no tuned command. Removing the tuning +overlay and recreating PostgreSQL is the secondary rollback path. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 24ff4c85b..c7ddd5308 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -32,6 +32,7 @@ containers while preserving named volumes. | Similar VOC, customer cohort, prior action | Persisted repeat-issue candidate semantics plus orchestrator pair adjudication and extractive evidence | Candidate live post endpoint and post-detail UI implemented; authenticated runtime acceptance pending | | TEPP independent Event Lineage anchor | Accepted, persisted TEPP criterion bound to exact snapshot/cutoff before fast-mlsirm activation | Consumer PR #606 is on protected main; TEPP producer PR #237 remains open, so no end-to-end accepted artifact is release evidence yet | | Temporal Lineage topics and multilevel important posts | ADR 0210; TEPP posterior topic/plausible-value contract followed by fast-mlsirm observed-information case-deletion influence | This stacked candidate adds normalized persistence, exact run/snapshot/cutoff binding, pre-aggregation scope authorization, API diagnostics, and populated/unavailable Storybook surfaces. TEPP PR #247 remains open at `063f10f3`; stacked #251–#254 provide fail-closed input validation, full joint precision, deterministic joint plausible-value draws, and the canonical research register, while complete provenance assembly remains gated. fast-mlsirm PR #1418 validates the Rust consumer envelope but intentionally returns `EstimatorUnavailable` until the scientific estimator lands. Runtime therefore remains honestly unavailable with no local Python or fallback score. | +| PostgreSQL WAL/checkpoint pressure | ADR 0227; aligned two-snapshot `pg_stat_wal`/checkpoint deltas, PostgreSQL WAL-segment and checkpoint constraints, cgroup memory, and data-volume space | Candidate procedure emits a content-authenticated plan and Compose environment, retains unmeasured memory/I/O/compression settings, preserves durability, validates the overlay without mutation, rejects stale preconditions, and requires an approved service recreation for apply or rollback. The observed CPU-bound GIN scan remains distinct from historical checkpoint pressure; canonical runtime application waits for the active migration to complete. | ### Technical contract and flow diff --git a/scripts/plan_postgres_tuning.py b/scripts/plan_postgres_tuning.py new file mode 100644 index 000000000..61808189f --- /dev/null +++ b/scripts/plan_postgres_tuning.py @@ -0,0 +1,458 @@ +#!/usr/bin/env python3 +"""Measure, plan, validate, and deliberately apply PostgreSQL Compose tuning.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import subprocess +import time +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, Callable, Mapping, Sequence + +MIB = 1024 * 1024 +KIB = 1024 +SUPPORTED_SERVER_MAJOR = 16 +DURABILITY_SETTINGS = ("fsync", "full_page_writes", "synchronous_commit") +TUNED_COMPOSE_FILE = "docker-compose.postgres-tuned.yml" + +SNAPSHOT_SQL = r""" +SELECT json_build_object( + 'captured_at', clock_timestamp(), + 'server_version_num', current_setting('server_version_num')::integer, + 'wal_stats_reset', w.stats_reset, + 'checkpoint_stats_reset', b.stats_reset, + 'wal_bytes', w.wal_bytes::text, + 'wal_buffers_full', w.wal_buffers_full, + 'checkpoints_timed', b.checkpoints_timed, + 'checkpoints_req', b.checkpoints_req, + 'wal_segment_size_bytes', pg_size_bytes(current_setting('wal_segment_size')), + 'settings', json_build_object( + 'checkpoint_timeout_seconds', + EXTRACT(EPOCH FROM current_setting('checkpoint_timeout')::interval), + 'max_wal_size_bytes', pg_size_bytes(current_setting('max_wal_size')), + 'min_wal_size_bytes', pg_size_bytes(current_setting('min_wal_size')), + 'wal_buffers_bytes', pg_size_bytes(current_setting('wal_buffers')), + 'shared_buffers_bytes', pg_size_bytes(current_setting('shared_buffers')), + 'maintenance_work_mem_bytes', pg_size_bytes(current_setting('maintenance_work_mem')), + 'effective_io_concurrency', current_setting('effective_io_concurrency')::integer, + 'maintenance_io_concurrency', current_setting('maintenance_io_concurrency')::integer, + 'wal_compression', current_setting('wal_compression'), + 'fsync', current_setting('fsync'), + 'full_page_writes', current_setting('full_page_writes'), + 'synchronous_commit', current_setting('synchronous_commit') + ) +) +FROM pg_stat_wal AS w CROSS JOIN pg_stat_bgwriter AS b; +""" + + +class TuningPlanError(ValueError): + """Reject incomplete or unsafe tuning evidence.""" + + +@dataclass(frozen=True) +class Observation: + """Two PostgreSQL counter snapshots and measured container resources.""" + + before: Mapping[str, Any] + after: Mapping[str, Any] + elapsed_seconds: float + container_memory_limit_bytes: int | None + data_filesystem_free_bytes: int + pg_wal_bytes: int + + +def _integer(value: Any, field: str) -> int: + """Return one non-negative integer field or reject the snapshot.""" + try: + result = int(value) + except (TypeError, ValueError) as exc: + raise TuningPlanError(f"{field} must be an integer") from exc + if result < 0: + raise TuningPlanError(f"{field} must not be negative") + return result + + +def _delta(before: Mapping[str, Any], after: Mapping[str, Any], field: str) -> int: + """Calculate a monotonic PostgreSQL statistics-counter delta.""" + result = _integer(after.get(field), field) - _integer(before.get(field), field) + if result < 0: + raise TuningPlanError(f"{field} decreased during the observation") + return result + + +def _require_aligned_resets(before: Mapping[str, Any], after: Mapping[str, Any]) -> None: + """Reject observations spanning a PostgreSQL statistics reset.""" + for field in ("wal_stats_reset", "checkpoint_stats_reset"): + if not before.get(field) or before.get(field) != after.get(field): + raise TuningPlanError(f"{field} changed or is unavailable") + + +def _seconds_since(snapshot: Mapping[str, Any], reset_field: str) -> float: + """Calculate one PostgreSQL-owned cumulative statistics window.""" + try: + captured = datetime.fromisoformat(str(snapshot["captured_at"]).replace("Z", "+00:00")) + reset = datetime.fromisoformat(str(snapshot[reset_field]).replace("Z", "+00:00")) + except (KeyError, ValueError) as exc: + raise TuningPlanError(f"{reset_field} observation window is invalid") from exc + seconds = (captured - reset).total_seconds() + if seconds <= 0: + raise TuningPlanError(f"{reset_field} observation window must be positive") + return seconds + + +def _settings(snapshot: Mapping[str, Any]) -> Mapping[str, Any]: + """Return the measured PostgreSQL settings object.""" + settings = snapshot.get("settings") + if not isinstance(settings, Mapping): + raise TuningPlanError("settings are unavailable") + return settings + + +def _require_durability(settings: Mapping[str, Any]) -> None: + """Fail closed unless PostgreSQL durability remains enabled.""" + accepted = {"on", "true", "remote_apply", "remote_write", "local"} + for field in DURABILITY_SETTINGS: + if str(settings.get(field, "")).lower() not in accepted: + raise TuningPlanError(f"durability setting {field} must remain enabled") + + +def _durability_value(settings: Mapping[str, Any], field: str) -> str: + """Return one validated PostgreSQL durability setting unchanged.""" + value = str(settings.get(field, "")).lower() + allowed = { + "fsync": {"on"}, + "full_page_writes": {"on"}, + "synchronous_commit": {"on", "remote_apply", "remote_write", "local"}, + } + if value not in allowed[field]: + raise TuningPlanError(f"unsupported durability value for {field}") + return value + + +def build_plan(observation: Observation) -> dict[str, Any]: + """Build an evidence-derived, restart-only PostgreSQL tuning plan.""" + if observation.elapsed_seconds <= 0: + raise TuningPlanError("elapsed_seconds must be positive") + if observation.data_filesystem_free_bytes < 0 or observation.pg_wal_bytes < 0: + raise TuningPlanError("filesystem measurements must not be negative") + _require_aligned_resets(observation.before, observation.after) + server_major = ( + _integer(observation.after.get("server_version_num"), "server_version_num") + // 10000 + ) + if server_major != SUPPORTED_SERVER_MAJOR: + raise TuningPlanError("the tuning contract supports PostgreSQL 16 only") + + before_settings = _settings(observation.before) + after_settings = _settings(observation.after) + if before_settings != after_settings: + raise TuningPlanError("PostgreSQL settings changed during the observation") + _require_durability(after_settings) + + segment_bytes = _integer( + observation.after.get("wal_segment_size_bytes"), "wal_segment_size_bytes" + ) + if segment_bytes == 0 or segment_bytes % MIB: + raise TuningPlanError("wal_segment_size must be a positive whole number of MiB") + timeout_seconds = float(after_settings.get("checkpoint_timeout_seconds", 0)) + if timeout_seconds <= 0: + raise TuningPlanError("checkpoint_timeout_seconds must be positive") + + wal_bytes = _delta(observation.before, observation.after, "wal_bytes") + wal_buffers_full = _delta(observation.before, observation.after, "wal_buffers_full") + checkpoints_timed = _delta(observation.before, observation.after, "checkpoints_timed") + checkpoints_req = _delta(observation.before, observation.after, "checkpoints_req") + sample_wal_rate = wal_bytes / observation.elapsed_seconds + cumulative_wal_seconds = _seconds_since(observation.after, "wal_stats_reset") + cumulative_wal_rate = ( + _integer(observation.after.get("wal_bytes"), "wal_bytes") / cumulative_wal_seconds + ) + selected_wal_rate = max(sample_wal_rate, cumulative_wal_rate) + interval_wal_bytes = math.ceil(selected_wal_rate * timeout_seconds) + interval_wal_segments = ( + math.ceil(interval_wal_bytes / segment_bytes) if interval_wal_bytes else 0 + ) + + current_max_wal = _integer(after_settings.get("max_wal_size_bytes"), "max_wal_size_bytes") + current_wal_buffers = _integer(after_settings.get("wal_buffers_bytes"), "wal_buffers_bytes") + proposed_max_wal = max(current_max_wal, interval_wal_segments * segment_bytes) + cumulative_wal_buffers_full = _integer( + observation.after.get("wal_buffers_full"), "wal_buffers_full" + ) + proposed_wal_buffers = ( + segment_bytes + if wal_buffers_full or cumulative_wal_buffers_full + else current_wal_buffers + ) + + existing_wal_reservation = max(current_max_wal, observation.pg_wal_bytes) + additional_reservation = max(0, proposed_max_wal - existing_wal_reservation) + if additional_reservation > observation.data_filesystem_free_bytes: + raise TuningPlanError( + "measured filesystem free space cannot hold the additional WAL reservation" + ) + if ( + observation.container_memory_limit_bytes is not None + and proposed_wal_buffers > observation.container_memory_limit_bytes + ): + raise TuningPlanError("container memory limit cannot hold the proposed WAL buffers") + + plan: dict[str, Any] = { + "contract_version": 1, + "requires_controlled_restart": True, + "evidence": { + "server_version_num": observation.after["server_version_num"], + "before_captured_at": observation.before.get("captured_at"), + "after_captured_at": observation.after.get("captured_at"), + "wal_stats_reset": observation.after["wal_stats_reset"], + "checkpoint_stats_reset": observation.after["checkpoint_stats_reset"], + "elapsed_seconds": observation.elapsed_seconds, + "wal_bytes": wal_bytes, + "sample_wal_bytes_per_second": sample_wal_rate, + "cumulative_wal_seconds": cumulative_wal_seconds, + "cumulative_wal_bytes_per_second": cumulative_wal_rate, + "selected_wal_bytes_per_second": selected_wal_rate, + "wal_buffers_full": wal_buffers_full, + "cumulative_wal_buffers_full": cumulative_wal_buffers_full, + "checkpoints_timed": checkpoints_timed, + "checkpoints_requested": checkpoints_req, + "checkpoint_timeout_seconds": timeout_seconds, + "wal_segment_size_bytes": segment_bytes, + "container_memory_limit_bytes": observation.container_memory_limit_bytes, + "data_filesystem_free_bytes": observation.data_filesystem_free_bytes, + "pg_wal_bytes": observation.pg_wal_bytes, + "additional_wal_reservation_bytes": additional_reservation, + }, + "proposed": { + "max_wal_size_bytes": proposed_max_wal, + "wal_buffers_bytes": proposed_wal_buffers, + **{ + field: _durability_value(after_settings, field) + for field in DURABILITY_SETTINGS + }, + }, + "rollback": { + "max_wal_size_bytes": current_max_wal, + "wal_buffers_bytes": current_wal_buffers, + "fsync": str(after_settings["fsync"]), + "full_page_writes": str(after_settings["full_page_writes"]), + "synchronous_commit": str(after_settings["synchronous_commit"]), + }, + "retained_unmeasured": { + name: after_settings.get(name) + for name in ( + "shared_buffers_bytes", + "maintenance_work_mem_bytes", + "effective_io_concurrency", + "maintenance_io_concurrency", + "wal_compression", + "min_wal_size_bytes", + ) + }, + } + canonical = json.dumps(plan, sort_keys=True, separators=(",", ":")).encode() + plan["plan_id"] = hashlib.sha256(canonical).hexdigest() + return plan + + +def plan_environment(plan: Mapping[str, Any], *, rollback: bool = False) -> str: + """Render the proposed or rollback settings as a Compose environment file.""" + section_name = "rollback" if rollback else "proposed" + section = plan.get(section_name) + if not isinstance(section, Mapping): + raise TuningPlanError(f"{section_name} settings are unavailable") + max_wal = _integer(section.get("max_wal_size_bytes"), "max_wal_size_bytes") + wal_buffers = _integer(section.get("wal_buffers_bytes"), "wal_buffers_bytes") + if max_wal % MIB: + raise TuningPlanError("max_wal_size must be a whole MiB value") + if wal_buffers % KIB: + raise TuningPlanError("wal_buffers must be a whole KiB value") + wal_buffers_setting = ( + f"{wal_buffers // MIB}MB" + if wal_buffers % MIB == 0 + else f"{wal_buffers // KIB}kB" + ) + durability = { + field: _durability_value(section, field) for field in DURABILITY_SETTINGS + } + return ( + f"POSTGRES_TUNED_MAX_WAL_SIZE={max_wal // MIB}MB\n" + f"POSTGRES_TUNED_WAL_BUFFERS={wal_buffers_setting}\n" + f"POSTGRES_TUNED_FSYNC={durability['fsync']}\n" + f"POSTGRES_TUNED_FULL_PAGE_WRITES={durability['full_page_writes']}\n" + f"POSTGRES_TUNED_SYNCHRONOUS_COMMIT={durability['synchronous_commit']}\n" + ) + + +def _run(command: Sequence[str], *, input_text: str | None = None) -> str: + """Run one bounded local command and return standard output.""" + completed = subprocess.run( + list(command), input=input_text, text=True, capture_output=True, check=False + ) + if completed.returncode: + raise TuningPlanError(completed.stderr.strip() or "command failed") + return completed.stdout.strip() + + +def _postgres_snapshot() -> dict[str, Any]: + """Read one PostgreSQL statistics snapshot through canonical Compose.""" + postgres_user = _run( + ["docker", "compose", "exec", "-T", "postgres", "printenv", "POSTGRES_USER"] + ) + postgres_database = _run( + ["docker", "compose", "exec", "-T", "postgres", "printenv", "POSTGRES_DB"] + ) + output = _run( + [ + "docker", "compose", "exec", "-T", "postgres", "psql", + "-X", "-v", "ON_ERROR_STOP=1", "-At", "-U", + postgres_user, "-d", postgres_database, "-c", SNAPSHOT_SQL, + ] + ) + value = json.loads(output) + if not isinstance(value, dict): + raise TuningPlanError("PostgreSQL snapshot is not a JSON object") + return value + + +def _container_resources() -> tuple[int | None, int, int]: + """Measure cgroup memory and data-volume space from the PostgreSQL container.""" + output = _run( + [ + "docker", "compose", "exec", "-T", "postgres", "sh", "-eu", "-c", + "if [ -r /sys/fs/cgroup/memory.max ]; then cat /sys/fs/cgroup/memory.max; " + "elif [ -r /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then " + "cat /sys/fs/cgroup/memory/memory.limit_in_bytes; else printf 'max\\n'; fi; " + "df -Pk /var/lib/postgresql/data | awk 'NR==2 {print $4}'; " + "du -sk /var/lib/postgresql/data/pg_wal | awk '{print $1}'", + ] + ).splitlines() + if len(output) != 3: + raise TuningPlanError("container resource measurement is incomplete") + memory = None if output[0] == "max" else _integer(output[0], "container_memory_limit_bytes") + return ( + memory, + _integer(output[1], "data_filesystem_free_kib") * 1024, + _integer(output[2], "pg_wal_kib") * 1024, + ) + + +def measure(sample_seconds: float, *, sleeper: Callable[[float], None] = time.sleep) -> Observation: + """Measure PostgreSQL deltas over an explicitly selected observation window.""" + if sample_seconds <= 0: + raise TuningPlanError("sample_seconds must be positive") + before = _postgres_snapshot() + started = time.monotonic() + sleeper(sample_seconds) + after = _postgres_snapshot() + elapsed = time.monotonic() - started + memory, free_bytes, pg_wal_bytes = _container_resources() + return Observation(before, after, elapsed, memory, free_bytes, pg_wal_bytes) + + +def _load_plan(path: Path) -> dict[str, Any]: + """Load and authenticate one generated audit plan.""" + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict) or "plan_id" not in value: + raise TuningPlanError("plan is incomplete") + plan_id = value.pop("plan_id") + canonical = json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + expected = hashlib.sha256(canonical).hexdigest() + value["plan_id"] = plan_id + if plan_id != expected: + raise TuningPlanError("plan content does not match plan_id") + return value + + +def validate_compose(plan: Mapping[str, Any], env_path: Path) -> None: + """Validate the explicit tuning overlay without changing a container.""" + env_path.write_text(plan_environment(plan), encoding="utf-8") + _run( + [ + "docker", "compose", "--env-file", str(env_path), + "-f", "docker-compose.yml", "-f", TUNED_COMPOSE_FILE, "config", "--quiet", + ] + ) + + +def controlled_restart(plan: Mapping[str, Any], env_path: Path, approval: str) -> None: + """Apply a validated plan only through an explicit PostgreSQL recreation.""" + if approval != plan.get("plan_id"): + raise TuningPlanError("--approve-plan-id must match the audited plan") + current = _settings(_postgres_snapshot()) + rollback = plan.get("rollback") + if not isinstance(rollback, Mapping): + raise TuningPlanError("rollback settings are unavailable") + for field in ("max_wal_size_bytes", "wal_buffers_bytes"): + if _integer(current.get(field), field) != _integer(rollback.get(field), field): + raise TuningPlanError(f"current {field} no longer matches the audited plan") + _require_durability(current) + validate_compose(plan, env_path) + _run( + [ + "docker", "compose", "--env-file", str(env_path), + "-f", "docker-compose.yml", "-f", TUNED_COMPOSE_FILE, + "up", "-d", "--wait", "--no-deps", "--force-recreate", "postgres", + ] + ) + applied = _settings(_postgres_snapshot()) + proposed = plan["proposed"] + for field in ("max_wal_size_bytes", "wal_buffers_bytes"): + if _integer(applied.get(field), field) != _integer(proposed.get(field), field): + raise TuningPlanError(f"PostgreSQL did not apply {field}") + for field in DURABILITY_SETTINGS: + if str(applied.get(field, "")).lower() != str(proposed.get(field, "")).lower(): + raise TuningPlanError(f"PostgreSQL did not preserve {field}") + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + """Parse the tuning procedure command line.""" + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + plan_parser = subparsers.add_parser("plan") + plan_parser.add_argument("--sample-seconds", type=float, required=True) + plan_parser.add_argument("--output", type=Path, required=True) + validate_parser = subparsers.add_parser("validate") + validate_parser.add_argument("--plan", type=Path, required=True) + validate_parser.add_argument("--env-output", type=Path, required=True) + apply_parser = subparsers.add_parser("apply") + apply_parser.add_argument("--plan", type=Path, required=True) + apply_parser.add_argument("--env-output", type=Path, required=True) + apply_parser.add_argument("--approve-plan-id", required=True) + rollback_parser = subparsers.add_parser("rollback") + rollback_parser.add_argument("--plan", type=Path, required=True) + rollback_parser.add_argument("--env-output", type=Path, required=True) + rollback_parser.add_argument("--approve-plan-id", required=True) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + """Execute the selected measure, validate, apply, or rollback phase.""" + args = parse_args(argv) + if args.command == "plan": + plan = build_plan(measure(args.sample_seconds)) + args.output.write_text(json.dumps(plan, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(plan["plan_id"]) + return 0 + plan = _load_plan(args.plan) + if args.command == "validate": + validate_compose(plan, args.env_output) + return 0 + if args.command == "rollback": + rollback_plan = dict(plan) + rollback_plan["proposed"] = plan["rollback"] + rollback_plan["rollback"] = plan["proposed"] + controlled_restart(rollback_plan, args.env_output, args.approve_plan_id) + return 0 + controlled_restart(plan, args.env_output, args.approve_plan_id) + return 0 + + +if __name__ == "__main__": # pragma: no cover - main() is exercised directly. + raise SystemExit(main()) diff --git a/tests/test_postgres_tuning_plan.py b/tests/test_postgres_tuning_plan.py new file mode 100644 index 000000000..97b165e67 --- /dev/null +++ b/tests/test_postgres_tuning_plan.py @@ -0,0 +1,475 @@ +"""Evidence-derived PostgreSQL Compose tuning procedure contracts.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path +from types import SimpleNamespace +from types import ModuleType + +import pytest + +_ROOT = Path(__file__).resolve().parents[1] +_SCRIPT = _ROOT / "scripts" / "plan_postgres_tuning.py" + + +def _load_module() -> ModuleType: + spec = importlib.util.spec_from_file_location("plan_postgres_tuning", _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 + + +tuning = _load_module() + + +def _snapshot(**changes: object) -> dict[str, object]: + snapshot: dict[str, object] = { + "captured_at": "2026-08-26T00:00:00Z", + "server_version_num": 160014, + "wal_stats_reset": "2026-08-24T00:00:00Z", + "checkpoint_stats_reset": "2026-08-24T00:00:00Z", + "wal_bytes": "0", + "wal_buffers_full": 0, + "checkpoints_timed": 0, + "checkpoints_req": 0, + "wal_segment_size_bytes": 16 * tuning.MIB, + "settings": { + "checkpoint_timeout_seconds": 300, + "max_wal_size_bytes": 1024 * tuning.MIB, + "min_wal_size_bytes": 80 * tuning.MIB, + "wal_buffers_bytes": 4 * tuning.MIB, + "shared_buffers_bytes": 128 * tuning.MIB, + "maintenance_work_mem_bytes": 64 * tuning.MIB, + "effective_io_concurrency": 1, + "maintenance_io_concurrency": 10, + "wal_compression": "off", + "fsync": "on", + "full_page_writes": "on", + "synchronous_commit": "on", + }, + } + snapshot.update(changes) + return snapshot + + +def _observation(before: dict[str, object], after: dict[str, object], **changes: object): + values = { + "before": before, + "after": after, + "elapsed_seconds": 60.0, + "container_memory_limit_bytes": 8 * 1024 * tuning.MIB, + "data_filesystem_free_bytes": 100 * 1024 * tuning.MIB, + "pg_wal_bytes": 1024 * tuning.MIB, + } + values.update(changes) + return tuning.Observation(**values) + + +def test_plan_uses_measured_checkpoint_interval_and_segment_boundary() -> None: + before = _snapshot() + after = _snapshot( + wal_bytes=str(600 * tuning.MIB), + wal_buffers_full=12, + checkpoints_req=4, + ) + + plan = tuning.build_plan(_observation(before, after)) + + # 600 MiB / 60 s * 300 s = 3000 MiB, rounded to a 16 MiB WAL segment. + assert plan["proposed"]["max_wal_size_bytes"] == 3008 * tuning.MIB + assert plan["proposed"]["wal_buffers_bytes"] == 16 * tuning.MIB + assert plan["evidence"]["checkpoints_requested"] == 4 + assert plan["retained_unmeasured"]["effective_io_concurrency"] == 1 + assert plan["retained_unmeasured"]["wal_compression"] == "off" + + +def test_plan_retains_settings_when_observation_has_no_pressure() -> None: + before = _snapshot() + after = _snapshot(checkpoints_timed=1) + + plan = tuning.build_plan(_observation(before, after)) + + assert plan["proposed"]["max_wal_size_bytes"] == 1024 * tuning.MIB + assert plan["proposed"]["wal_buffers_bytes"] == 4 * tuning.MIB + + +def test_plan_keeps_historical_pressure_distinct_from_idle_sample() -> None: + before = _snapshot(wal_bytes=str(287 * 1024 * tuning.MIB), wal_buffers_full=7_404_489) + after = _snapshot( + captured_at="2026-08-26T00:00:00Z", + wal_bytes=str(287 * 1024 * tuning.MIB), + wal_buffers_full=7_404_489, + checkpoints_req=21_990, + checkpoints_timed=257, + ) + + plan = tuning.build_plan(_observation(before, after)) + + assert plan["evidence"]["wal_bytes"] == 0 + assert plan["evidence"]["sample_wal_bytes_per_second"] == 0 + assert plan["evidence"]["cumulative_wal_bytes_per_second"] > 0 + # The cumulative average alone does not justify exceeding the current 1 GiB. + assert plan["proposed"]["max_wal_size_bytes"] == 1024 * tuning.MIB + assert plan["proposed"]["wal_buffers_bytes"] == 16 * tuning.MIB + + +@pytest.mark.parametrize( + ("before", "after", "message"), + [ + (_snapshot(), _snapshot(wal_stats_reset="later"), "wal_stats_reset"), + (_snapshot(wal_bytes="2"), _snapshot(wal_bytes="1"), "wal_bytes decreased"), + ( + _snapshot(settings={**_snapshot()["settings"], "fsync": "off"}), + _snapshot(settings={**_snapshot()["settings"], "fsync": "off"}), + "durability setting fsync", + ), + ], +) +def test_plan_rejects_incomparable_or_unsafe_evidence( + before: dict[str, object], after: dict[str, object], message: str +) -> None: + with pytest.raises(tuning.TuningPlanError, match=message): + tuning.build_plan(_observation(before, after)) + + +def test_plan_rejects_exact_additional_wal_beyond_free_space() -> None: + before = _snapshot() + after = _snapshot(wal_bytes=str(600 * tuning.MIB)) + + with pytest.raises(tuning.TuningPlanError, match="free space"): + tuning.build_plan( + _observation(before, after, data_filesystem_free_bytes=1983 * tuning.MIB) + ) + + +def test_environment_preserves_durability_and_supports_rollback() -> None: + before = _snapshot() + after = _snapshot(wal_buffers_full=1) + plan = tuning.build_plan(_observation(before, after)) + + proposed = tuning.plan_environment(plan) + rollback = tuning.plan_environment(plan, rollback=True) + + assert "POSTGRES_TUNED_WAL_BUFFERS=16MB" in proposed + assert "POSTGRES_TUNED_WAL_BUFFERS=4MB" in rollback + assert "POSTGRES_TUNED_FSYNC=on" in proposed + assert "POSTGRES_TUNED_FULL_PAGE_WRITES=on" in proposed + assert "POSTGRES_TUNED_SYNCHRONOUS_COMMIT=on" in proposed + + +def test_environment_preserves_retained_block_aligned_wal_buffers() -> None: + settings = {**_snapshot()["settings"], "wal_buffers_bytes": 640 * tuning.KIB} + plan = tuning.build_plan( + _observation(_snapshot(settings=settings), _snapshot(settings=settings)) + ) + + assert "POSTGRES_TUNED_WAL_BUFFERS=640kB" in tuning.plan_environment(plan) + + +def test_compose_overlay_has_no_unmeasured_tuning_or_durability_relaxation() -> None: + overlay = (_ROOT / "docker-compose.postgres-tuned.yml").read_text(encoding="utf-8") + + assert "max_wal_size=${POSTGRES_TUNED_MAX_WAL_SIZE:" in overlay + assert "wal_buffers=${POSTGRES_TUNED_WAL_BUFFERS:" in overlay + assert "fsync=${POSTGRES_TUNED_FSYNC:" in overlay + assert "shared_buffers" not in overlay + assert "maintenance_work_mem" not in overlay + assert "effective_io_concurrency" not in overlay + assert "wal_compression" not in overlay + + +def test_measure_uses_explicit_window_and_container_evidence(monkeypatch) -> None: + snapshots = iter([_snapshot(), _snapshot(wal_bytes="10")]) + sleeps: list[float] = [] + monotonic = iter([100.0, 112.5]) + monkeypatch.setattr(tuning, "_postgres_snapshot", lambda: next(snapshots)) + monkeypatch.setattr( + tuning, "_container_resources", lambda: (2048 * tuning.MIB, 4096, 1024) + ) + monkeypatch.setattr(tuning.time, "monotonic", lambda: next(monotonic)) + + observation = tuning.measure(12.5, sleeper=sleeps.append) + + assert sleeps == [12.5] + assert observation.elapsed_seconds == 12.5 + assert observation.container_memory_limit_bytes == 2048 * tuning.MIB + + +def test_controlled_restart_checks_old_and_new_settings(monkeypatch, tmp_path: Path) -> None: + plan = tuning.build_plan( + _observation(_snapshot(), _snapshot(wal_buffers_full=1)) + ) + snapshots = iter( + [ + _snapshot(), + _snapshot( + settings={ + **_snapshot()["settings"], + "wal_buffers_bytes": 16 * tuning.MIB, + } + ), + ] + ) + commands: list[list[str]] = [] + monkeypatch.setattr(tuning, "_postgres_snapshot", lambda: next(snapshots)) + monkeypatch.setattr( + tuning, "_run", lambda command, **_kwargs: commands.append(list(command)) or "" + ) + + tuning.controlled_restart(plan, tmp_path / "tuning.env", plan["plan_id"]) + + assert any("config" in command and "--quiet" in command for command in commands) + apply = next(command for command in commands if "up" in command) + assert "--wait" in apply + assert "--force-recreate" in apply + + +def test_controlled_restart_rejects_stale_plan_before_compose(monkeypatch, tmp_path: Path) -> None: + plan = tuning.build_plan(_observation(_snapshot(), _snapshot())) + stale = _snapshot( + settings={ + **_snapshot()["settings"], + "max_wal_size_bytes": 2048 * tuning.MIB, + } + ) + monkeypatch.setattr(tuning, "_postgres_snapshot", lambda: stale) + monkeypatch.setattr( + tuning, + "_run", + lambda *_args, **_kwargs: pytest.fail("Compose must not run for a stale plan"), + ) + + with pytest.raises(tuning.TuningPlanError, match="no longer matches"): + tuning.controlled_restart(plan, tmp_path / "tuning.env", plan["plan_id"]) + + +@pytest.mark.parametrize("value", [None, "bad"]) +def test_integer_rejects_non_integer(value: object) -> None: + with pytest.raises(tuning.TuningPlanError, match="must be an integer"): + tuning._integer(value, "value") + + +def test_integer_rejects_negative() -> None: + with pytest.raises(tuning.TuningPlanError, match="must not be negative"): + tuning._integer(-1, "value") + + +@pytest.mark.parametrize( + ("changes", "message"), + [ + ({"elapsed_seconds": 0}, "elapsed_seconds"), + ({"data_filesystem_free_bytes": -1}, "filesystem measurements"), + ({"pg_wal_bytes": -1}, "filesystem measurements"), + ({"container_memory_limit_bytes": 1}, "memory limit"), + ], +) +def test_plan_rejects_invalid_observation_resources( + changes: dict[str, object], message: str +) -> None: + before = _snapshot() + after = _snapshot(wal_buffers_full=1) + with pytest.raises(tuning.TuningPlanError, match=message): + tuning.build_plan(_observation(before, after, **changes)) + + +@pytest.mark.parametrize( + ("before", "after", "message"), + [ + (_snapshot(), _snapshot(server_version_num=170000), "PostgreSQL 16"), + (_snapshot(), _snapshot(settings=None), "settings are unavailable"), + ( + _snapshot(), + _snapshot(settings={**_snapshot()["settings"], "wal_compression": "on"}), + "settings changed", + ), + (_snapshot(), _snapshot(wal_segment_size_bytes=0), "wal_segment_size"), + (_snapshot(), _snapshot(wal_segment_size_bytes=tuning.MIB + 1), "wal_segment_size"), + ( + _snapshot(settings={**_snapshot()["settings"], "checkpoint_timeout_seconds": 0}), + _snapshot(settings={**_snapshot()["settings"], "checkpoint_timeout_seconds": 0}), + "checkpoint_timeout", + ), + ], +) +def test_plan_rejects_unsupported_database_evidence( + before: dict[str, object], after: dict[str, object], message: str +) -> None: + with pytest.raises(tuning.TuningPlanError, match=message): + tuning.build_plan(_observation(before, after)) + + +def test_environment_rejects_missing_or_misaligned_values() -> None: + with pytest.raises(tuning.TuningPlanError, match="settings are unavailable"): + tuning.plan_environment({}) + with pytest.raises(tuning.TuningPlanError, match="whole MiB"): + tuning.plan_environment( + { + "proposed": { + "max_wal_size_bytes": tuning.MIB + 1, + "wal_buffers_bytes": tuning.MIB, + "fsync": "on", + "full_page_writes": "on", + "synchronous_commit": "on", + } + } + ) + + +def test_durability_value_rejects_unsupported_mode() -> None: + with pytest.raises(tuning.TuningPlanError, match="unsupported durability"): + tuning._durability_value({"synchronous_commit": "off"}, "synchronous_commit") + + +def test_run_returns_stdout_and_reports_command_failure(monkeypatch) -> None: + monkeypatch.setattr( + tuning.subprocess, + "run", + lambda *_args, **_kwargs: SimpleNamespace(returncode=0, stdout=" ok \n", stderr=""), + ) + assert tuning._run(["command"]) == "ok" + monkeypatch.setattr( + tuning.subprocess, + "run", + lambda *_args, **_kwargs: SimpleNamespace(returncode=1, stdout="", stderr="bad"), + ) + with pytest.raises(tuning.TuningPlanError, match="bad"): + tuning._run(["command"]) + + +def test_snapshot_reads_compose_environment_and_json(monkeypatch) -> None: + outputs = iter(["app-user", "app-db", '{"settings": {}}']) + commands: list[list[str]] = [] + + def fake_run(command, **_kwargs): + commands.append(list(command)) + return next(outputs) + + monkeypatch.setattr(tuning, "_run", fake_run) + assert tuning._postgres_snapshot() == {"settings": {}} + assert "app-user" in commands[-1] + assert "app-db" in commands[-1] + + monkeypatch.setattr(tuning, "_run", lambda *_args, **_kwargs: "[]") + with pytest.raises(tuning.TuningPlanError, match="JSON object"): + tuning._postgres_snapshot() + + +def test_container_resource_measurement_handles_cgroup_limit(monkeypatch) -> None: + monkeypatch.setattr(tuning, "_run", lambda *_args, **_kwargs: "max\n4096\n1024") + assert tuning._container_resources() == (None, 4096 * 1024, 1024 * 1024) + monkeypatch.setattr(tuning, "_run", lambda *_args, **_kwargs: "8192\n4096\n1024") + assert tuning._container_resources() == (8192, 4096 * 1024, 1024 * 1024) + monkeypatch.setattr(tuning, "_run", lambda *_args, **_kwargs: "incomplete") + with pytest.raises(tuning.TuningPlanError, match="incomplete"): + tuning._container_resources() + + +def test_measure_rejects_non_positive_window() -> None: + with pytest.raises(tuning.TuningPlanError, match="sample_seconds"): + tuning.measure(0) + + +@pytest.mark.parametrize( + ("snapshot", "message"), + [ + ({"captured_at": "bad", "wal_stats_reset": "also-bad"}, "window is invalid"), + ( + {"captured_at": "2026-08-24T00:00:00Z", "wal_stats_reset": "2026-08-24T00:00:00Z"}, + "must be positive", + ), + ], +) +def test_cumulative_window_rejects_invalid_timestamps( + snapshot: dict[str, str], message: str +) -> None: + with pytest.raises(tuning.TuningPlanError, match=message): + tuning._seconds_since(snapshot, "wal_stats_reset") + + +def test_load_plan_authenticates_content(tmp_path: Path) -> None: + plan = tuning.build_plan(_observation(_snapshot(), _snapshot())) + path = tmp_path / "plan.json" + path.write_text(json.dumps(plan), encoding="utf-8") + assert tuning._load_plan(path) == plan + path.write_text('{"plan_id": "wrong"}', encoding="utf-8") + with pytest.raises(tuning.TuningPlanError, match="does not match"): + tuning._load_plan(path) + path.write_text("[]", encoding="utf-8") + with pytest.raises(tuning.TuningPlanError, match="incomplete"): + tuning._load_plan(path) + + +def test_controlled_restart_rejects_approval_and_missing_rollback(tmp_path: Path) -> None: + plan = tuning.build_plan(_observation(_snapshot(), _snapshot())) + with pytest.raises(tuning.TuningPlanError, match="approve-plan-id"): + tuning.controlled_restart(plan, tmp_path / "env", "wrong") + invalid = {**plan, "rollback": None} + with pytest.MonkeyPatch.context() as patch: + patch.setattr(tuning, "_postgres_snapshot", _snapshot) + with pytest.raises(tuning.TuningPlanError, match="rollback settings"): + tuning.controlled_restart(invalid, tmp_path / "env", plan["plan_id"]) + + +@pytest.mark.parametrize( + ("applied_changes", "message"), + [ + ({"wal_buffers_bytes": 8 * tuning.MIB}, "did not apply wal_buffers"), + ({"synchronous_commit": "remote_apply"}, "did not preserve synchronous_commit"), + ], +) +def test_controlled_restart_verifies_applied_settings( + monkeypatch, tmp_path: Path, applied_changes: dict[str, object], message: str +) -> None: + plan = tuning.build_plan(_observation(_snapshot(), _snapshot(wal_buffers_full=1))) + applied = _snapshot( + settings={ + **_snapshot()["settings"], + "wal_buffers_bytes": 16 * tuning.MIB, + **applied_changes, + } + ) + snapshots = iter([_snapshot(), applied]) + monkeypatch.setattr(tuning, "_postgres_snapshot", lambda: next(snapshots)) + monkeypatch.setattr(tuning, "_run", lambda *_args, **_kwargs: "") + with pytest.raises(tuning.TuningPlanError, match=message): + tuning.controlled_restart(plan, tmp_path / "env", plan["plan_id"]) + + +def test_main_plan_validate_apply_and_rollback(monkeypatch, tmp_path: Path, capsys) -> None: + plan = tuning.build_plan(_observation(_snapshot(), _snapshot())) + plan_path = tmp_path / "plan.json" + env_path = tmp_path / "env" + calls: list[tuple[str, str]] = [] + monkeypatch.setattr(tuning, "measure", lambda _seconds: _observation(_snapshot(), _snapshot())) + assert tuning.main(["plan", "--sample-seconds", "1", "--output", str(plan_path)]) == 0 + assert capsys.readouterr().out.strip() == json.loads(plan_path.read_text())["plan_id"] + monkeypatch.setattr(tuning, "validate_compose", lambda *_args: calls.append(("validate", ""))) + assert tuning.main(["validate", "--plan", str(plan_path), "--env-output", str(env_path)]) == 0 + + def fake_restart(selected, _env, approval): + calls.append(("restart", approval)) + assert selected["plan_id"] == plan["plan_id"] + + monkeypatch.setattr(tuning, "controlled_restart", fake_restart) + for command in ("apply", "rollback"): + assert ( + tuning.main( + [ + command, + "--plan", + str(plan_path), + "--env-output", + str(env_path), + "--approve-plan-id", + plan["plan_id"], + ] + ) + == 0 + ) + assert calls[0][0] == "validate" + assert [item[0] for item in calls].count("restart") == 2 From a0be16461c9b6845d288e7ee3fb6c0170fdad19d Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 11:16:21 +0900 Subject: [PATCH 083/393] fix: report only observed backfill counts --- backend/app/main.py | 2 +- backend/app/post_content_queue.py | 11 ++++------- scripts/queue_post_content_backfill.py | 2 +- tests/test_post_content_backfill_endpoint.py | 6 ++---- tests/test_post_content_queue.py | 5 +---- tests/test_queue_post_content_backfill_script.py | 2 +- 6 files changed, 10 insertions(+), 18 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 5c90eda4a..efd8494eb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -821,7 +821,7 @@ async def queue_post_content_backfill( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), valkey: redis.Redis = Depends(get_valkey), -) -> dict[str, int | bool]: +) -> dict[str, int]: """Queue one bounded corpus page and return before semantic work runs.""" _require_post_admin(account) settings = load_settings() diff --git a/backend/app/post_content_queue.py b/backend/app/post_content_queue.py index dfc9181d9..2cc7b361d 100644 --- a/backend/app/post_content_queue.py +++ b/backend/app/post_content_queue.py @@ -315,7 +315,7 @@ async def enqueue_post_content_backfill( limit: int, require_embedding: bool, require_structure: bool, -) -> dict[str, int | bool]: +) -> dict[str, int]: """Durably enqueue one bounded page of eligible incomplete source posts. PostgreSQL is committed before Valkey is touched. A missing wake-up is @@ -376,7 +376,6 @@ async def enqueue_post_content_backfill( for update of post skip locked """ requests: list[PostContentJobRequest] = [] - has_more = False async with pool.acquire() as conn: async with conn.transaction(): # Safe SQL: the eligibility predicate is an immutable schema fragment; values are bound. @@ -385,10 +384,9 @@ async def enqueue_post_content_backfill( SUCCEEDED, require_embedding, require_structure, - limit + 1, + limit, ) - has_more = len(rows) > limit - for row in rows[:limit]: + for row in rows: post_id = str(row["post_id"]) complete = await post_content_is_complete( conn, @@ -414,11 +412,10 @@ async def enqueue_post_content_backfill( ): published += 1 return { - "selected_posts": min(len(rows), limit), + "selected_posts": len(rows), "queued_posts": len(requests), "published_events": published, "recovery_pending": len(requests) - published, - "has_more": has_more, } diff --git a/scripts/queue_post_content_backfill.py b/scripts/queue_post_content_backfill.py index c94d3ca97..d1b7119a3 100644 --- a/scripts/queue_post_content_backfill.py +++ b/scripts/queue_post_content_backfill.py @@ -44,7 +44,7 @@ async def queue_post_content_backfill( valkey_url: str, *, limit: int, -) -> dict[str, int | bool]: +) -> dict[str, int]: """Queue one bounded page through the shared durable producer.""" if not 1 <= limit <= 200: raise ValueError("limit must be between 1 and 200") diff --git a/tests/test_post_content_backfill_endpoint.py b/tests/test_post_content_backfill_endpoint.py index 0be04bd8d..3e81bf8eb 100644 --- a/tests/test_post_content_backfill_endpoint.py +++ b/tests/test_post_content_backfill_endpoint.py @@ -51,14 +51,13 @@ def test_backfill_endpoint_only_enqueues_durable_work( """The accepted response delegates once and never invokes a provider.""" observed: dict[str, object] = {} - async def enqueue(pool: object, valkey: object, **kwargs: object) -> dict[str, int | bool]: + async def enqueue(pool: object, valkey: object, **kwargs: object) -> dict[str, int]: observed.update(pool=pool, valkey=valkey, **kwargs) return { "selected_posts": 1, "queued_posts": 1, "published_events": 1, "recovery_pending": 0, - "has_more": False, } monkeypatch.setattr(main, "enqueue_post_content_backfill", enqueue) @@ -97,14 +96,13 @@ def test_backfill_endpoint_does_not_require_missing_model_evidence( """An unwired orchestrator remains unavailable instead of being fabricated.""" observed: dict[str, object] = {} - async def enqueue(_pool: object, _valkey: object, **kwargs: object) -> dict[str, int | bool]: + async def enqueue(_pool: object, _valkey: object, **kwargs: object) -> dict[str, int]: observed.update(kwargs) return { "selected_posts": 0, "queued_posts": 0, "published_events": 0, "recovery_pending": 0, - "has_more": False, } monkeypatch.setattr(main, "enqueue_post_content_backfill", enqueue) diff --git a/tests/test_post_content_queue.py b/tests/test_post_content_queue.py index 0377067ce..2c820cbfb 100644 --- a/tests/test_post_content_queue.py +++ b/tests/test_post_content_queue.py @@ -63,11 +63,10 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, str]]: assert "source_deleted_flag" in query assert "job.post_id is null or job.status_code = $1" in query assert "for update of post skip locked" in query.lower() - assert args == (SUCCEEDED, True, True, 3) + assert args == (SUCCEEDED, True, True, 2) return [ {"post_id": "00000000-0000-0000-0000-000000000001", "post_body": "one"}, {"post_id": "00000000-0000-0000-0000-000000000002", "post_body": "two"}, - {"post_id": "00000000-0000-0000-0000-000000000003", "post_body": "three"}, ] class Acquire: @@ -113,7 +112,6 @@ async def publish(*_args: object, **_kwargs: object) -> str | None: "queued_posts": 2, "published_events": 1, "recovery_pending": 1, - "has_more": True, } @@ -172,7 +170,6 @@ async def ensure( "queued_posts": 0, "published_events": 0, "recovery_pending": 0, - "has_more": False, } diff --git a/tests/test_queue_post_content_backfill_script.py b/tests/test_queue_post_content_backfill_script.py index d7835991c..c76589644 100644 --- a/tests/test_queue_post_content_backfill_script.py +++ b/tests/test_queue_post_content_backfill_script.py @@ -59,7 +59,7 @@ async def aclose(self) -> None: async def create_pool(*_args: object, **_kwargs: object) -> Pool: return pool - async def enqueue(_pool: object, _client: object, **kwargs: object) -> dict[str, int | bool]: + async def enqueue(_pool: object, _client: object, **kwargs: object) -> dict[str, int]: assert (_pool, _client) == (pool, client) assert kwargs == { "limit": 12, From 2a91907956f44acd39776cbb5bc4508c356125f9 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 11:30:40 +0900 Subject: [PATCH 084/393] fix(ask): find related source evidence automatically --- CHANGELOG.md | 6 +++ backend/app/post_chat_ingestion.py | 19 +++++++- .../adr/0206-evidence-operations-dashboard.md | 9 ++-- docs/product-technical-gap-baseline.md | 9 +++- frontend/src/App.css | 47 +++++++++++++++++++ frontend/src/App.tsx | 34 +++++++++----- .../components/OperationsDashboard.test.tsx | 2 +- .../src/components/OperationsDashboard.tsx | 6 +-- tests/test_post_chat_ingestion.py | 31 ++++++++++++ 9 files changed, 141 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9efed2cde..41ac253fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project are documented here. Format follows ### Changed +- Ask Agent now searches posts with the same persisted semantic project key as + part of its authorized evidence window before re-analysis. Missing evidence + remains a retry state rather than a request for the reader to attach an + original, and the composer now uses an accessible form with a stable action + and separate progress status. + - Docker Compose now has one canonical `lineageweave` project containing the complete synthetic product stack. The backend receives the existing TEPP API credential contract, and the OIDC smoke target installs its declared backend diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index 2777e8c66..6d9a9daa4 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -270,6 +270,23 @@ async def find_linked_post_ids(conn: asyncpg.Connection, post_id: str) -> Linked ) sibling_post_ids = list({str(row["post_id"]) for row in sibling_rows} | {post_id}) + project_rows = await conn.fetch( + "select distinct project_key from post_project_mention where post_id = $1", + post_id, + ) + project_keys = [str(row["project_key"]) for row in project_rows] + project_sibling_ids: set[str] = set() + if project_keys: + project_sibling_rows = await conn.fetch( + "select distinct post_id from post_project_mention " + "where project_key = any($1::text[])", + project_keys, + ) + project_sibling_ids = { + str(row["post_id"]) for row in project_sibling_rows + } - {post_id} + sibling_post_ids = list(set(sibling_post_ids) | project_sibling_ids) + edges = await load_visible_subgraph(conn, sibling_post_ids) start = node_key(NODE_POST, post_id) scores = random_walk_with_restart(adjacency_from_edges(edges), start_node=start) @@ -278,7 +295,7 @@ async def find_linked_post_ids(conn: asyncpg.Connection, post_id: str) -> Linked node_id for key, _ in related if (node_id := parse_node_key(key)[1]) and parse_node_key(key)[0] == NODE_POST - ) - {post_id} + ).union(project_sibling_ids) - {post_id} return LinkedPostIds(direct=direct_ids - {post_id}, indirect=indirect_ids - direct_ids) diff --git a/docs/adr/0206-evidence-operations-dashboard.md b/docs/adr/0206-evidence-operations-dashboard.md index 5e48ba6c2..349147137 100644 --- a/docs/adr/0206-evidence-operations-dashboard.md +++ b/docs/adr/0206-evidence-operations-dashboard.md @@ -63,7 +63,9 @@ provenance. absent from the authorized corpus. The analysis input reuses the post-chat source assembler: focal post first, then bounded Event Lineage and semantic-neighborhood posts after the same - corporate-entity/process-unit ABAC check. Every classification and fact + corporate-entity/process-unit ABAC check. The semantic window includes posts + carrying the same persisted `post_project_mention.project_key`; display-name + similarity and keyword matching do not create that link. Every classification and fact persists its evidence post id and the SHA-256 of the exact numbered input document. A span that does not occur in that identified document rejects the whole provider response; linked evidence is never rewritten as focal @@ -71,8 +73,9 @@ provenance. 8. Claim-investigation and rebid/handover panels include positively classified cases and show extracted answers plus cited spans. A required answer that the source does not support is stored in the normalized - `operations_case_missing_fact` relation as an explicit missing fact, so the - next action is collection or human correction rather than keyword guessing. + `operations_case_missing_fact` relation as an explicit retry state while the + system searches the authorized semantic source window and re-analyzes the + case. The reader is not asked to attach the source manually. A provider result is invalid unless every required question is represented exactly once as either a cited supported fact or an explicit missing fact; a fact cannot be both. Missing facts carry no invented value or evidence diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index c7ddd5308..016769cd4 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -28,7 +28,7 @@ containers while preserving named volumes. | Apple-Silicon mathematical acceleration | ADR 0226 macOS-native Rust owner service with authenticated MLX Metal execution receipts | Normative boundary accepted; TEPP, fast-mlsirm, and RankWeave owner implementations and actual Metal parity receipts remain required before activation | | Ask answer citation-to-event navigation | ADR 0225; authorized cited source, observed source clock, focused evidence layer, and full-post navigation | Stacked candidate renders numbered answer citations and chronologically ordered evidence cards with bidirectional focus. Event and record clocks stay distinct; this list does not claim a Project Journey. Storybook and component interaction evidence are included; authenticated runtime screenshots remain required at the exact candidate head. | | Repeat issue to design improvement | `repeat_issue`, `issue_pattern`, and `improvement_action` cited facts | Candidate semantic contract; design-system connector acceptance pending | -| Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback; authenticated runtime acceptance pending | +| Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback. Post-scoped evidence collection now also follows exact persisted `project_key` membership, so the system searches authorized related originals before re-analysis instead of asking the reader to attach them. Focused backend tests pass; authenticated exact-head runtime acceptance remains pending. | | Similar VOC, customer cohort, prior action | Persisted repeat-issue candidate semantics plus orchestrator pair adjudication and extractive evidence | Candidate live post endpoint and post-detail UI implemented; authenticated runtime acceptance pending | | TEPP independent Event Lineage anchor | Accepted, persisted TEPP criterion bound to exact snapshot/cutoff before fast-mlsirm activation | Consumer PR #606 is on protected main; TEPP producer PR #237 remains open, so no end-to-end accepted artifact is release evidence yet | | Temporal Lineage topics and multilevel important posts | ADR 0210; TEPP posterior topic/plausible-value contract followed by fast-mlsirm observed-information case-deletion influence | This stacked candidate adds normalized persistence, exact run/snapshot/cutoff binding, pre-aggregation scope authorization, API diagnostics, and populated/unavailable Storybook surfaces. TEPP PR #247 remains open at `063f10f3`; stacked #251–#254 provide fail-closed input validation, full joint precision, deterministic joint plausible-value draws, and the canonical research register, while complete provenance assembly remains gated. fast-mlsirm PR #1418 validates the Rust consumer envelope but intentionally returns `EstimatorUnavailable` until the scientific estimator lands. Runtime therefore remains honestly unavailable with no local Python or fallback score. | @@ -87,6 +87,13 @@ required before protected delivery can be claimed. Authenticated authorized-corpus acceptance remains separate and may return only aggregate, non-identifying evidence to this repository. +The 2026-08-26 canonical runtime audit found the Ask composer visually +compressed even though its asynchronous enqueue path remained responsive. The +current stacked candidate replaces that loose control row with a labeled form, +a stable submit action, and a separate live status. Component screenshot +acceptance remains pending until the candidate image is rebuilt. This is not +protected-main delivery evidence. + The current candidate was also re-rendered from the isolated synthetic stack at 1440×1100 and 402×1200 after the touch-target repair. Both viewports had document width equal to viewport width, no browser console/page errors, and no diff --git a/frontend/src/App.css b/frontend/src/App.css index f9bd2ee0b..020bc246d 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -465,6 +465,53 @@ opacity: 0.8; } +.ask-agent-panel { + width: min(100% - 2rem, 70rem); + min-height: 36rem; + margin: 0 auto; + padding: 3rem 0; +} + +.ask-agent-composer { + display: grid; + gap: 0.75rem; + max-width: 52rem; + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius-panel); + background: var(--surface); +} + +.ask-agent-source { + display: grid; + gap: 0.5rem; + font-weight: 600; +} + +.ask-agent-source textarea { + width: 100%; + min-height: 7rem; + resize: vertical; + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--surface); + color: inherit; + font: inherit; + line-height: 1.5; +} + +.ask-agent-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.75rem; +} + +.ask-agent-actions [role="status"] { + color: var(--text-muted); +} + .lineage-list { list-style: none; padding: 0; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 16b814666..87c847692 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4848,23 +4848,31 @@ function AskAgentPanel({ } return ( -
          +

          {t("Evidence-grounded questions")}

          {t("Ask Agent")}

          {t("Questions use authorized posts and their evidence.")}

          {error ?

          {error}

          : null} -