From 87ebe13b3471a9ce9fcc7959676f422a1ca59386 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:08:02 +0900 Subject: [PATCH 1/3] feat: seed a TEPP analysis run through tepp_client (v0.84.0) Buyer gap: home Analysis runs only showed lineage reconstruction. make seed now records a Demo Corp TEPP measurement via tepp_client. The default transport is unavailable, so the row is Failed / tepp_not_available -- never a fabricated theta. TEPP stays a wire client, not a local psychometric engine. --- ARCHITECTURE.md | 3 + CHANGELOG.d/0.84.0-tepp-analysis-run.md | 4 + CHANGELOG.md | 10 ++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 20 ++++ lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- scripts/seed_demo_data.py | 121 ++++++++++++++++++++++++ tests/test_seed_tepp_run.py | 9 ++ uv.lock | 2 +- 10 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 CHANGELOG.d/0.84.0-tepp-analysis-run.md create mode 100644 tests/test_seed_tepp_run.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 11cd837e8..4f27d4020 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -467,6 +467,9 @@ run's scope whose `created_at` is at or before the run `knowledge_cutoff`, so a later post cannot appear inside a historical reconstruction (ADR 0016). Detail also returns revision and configuration digest prefixes. +`make seed` also records a TEPP measurement run through +`tepp_client`; the default transport is unavailable, so that run is +Failed / `tepp_not_available` rather than a fabricated score. The home list is clickable: `GET /api/analysis-runs/{id}` fills a labeled detail (cutoff, requested date, counts, status history, digests) without exposing a DSN or raw record. Status history is detail-only diff --git a/CHANGELOG.d/0.84.0-tepp-analysis-run.md b/CHANGELOG.d/0.84.0-tepp-analysis-run.md new file mode 100644 index 000000000..18267d698 --- /dev/null +++ b/CHANGELOG.d/0.84.0-tepp-analysis-run.md @@ -0,0 +1,4 @@ +# 0.84.0 TEPP analysis-run seed + +Seed writes `analysis_run_tepp` via `tepp_client`. Missing transport +is Failed / `tepp_not_available`, not a fake measurement. diff --git a/CHANGELOG.md b/CHANGELOG.md index 42ac97268..48030f857 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.84.0] - 2026-08-16 + +### Added + +- `make seed` records a Demo Corp TEPP measurement run through + `tepp_client`. The default transport is unavailable, so the home + list shows "TEPP measurement · Failed · Demo Corp" with + `tepp_not_available` -- never a fabricated theta. TEPP stays a + wire client, not a local psychometric engine. + ## [0.83.0] - 2026-08-16 ### Fixed diff --git a/frontend/package.json b/frontend/package.json index d1e24268f..c21ed209f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.83.0", + "version": "0.84.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 368ac70a4..730c88a52 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -239,6 +239,25 @@ describe("App, authenticated", () => { }, ], }, + { + analysis_run_id: "run-demo-tepp", + run_kind_code: "analysis_run_tepp", + run_kind_label: "TEPP measurement", + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Demo Corp", + status_code: "analysis_status_failed", + status_label: "Failed", + knowledge_cutoff: "2026-01-12T12:00:00Z", + requested_at: "2026-01-12T12:34:00Z", + source_counts: [ + { + count_type_code: "analysis_count_document", + count_type_label: "Documents", + count_value: 3, + }, + ], + }, ], }), ); @@ -1377,6 +1396,7 @@ 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("3 documents"); expect(list).not.toHaveTextContent("postgresql://"); expect(list).not.toHaveTextContent("select "); diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 5bd7638d6..e89edfd07 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.83.0" +__version__ = "0.84.0" diff --git a/pyproject.toml b/pyproject.toml index f7b33f6ce..ed229d426 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.83.0" +version = "0.84.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" } diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index 70f118050..6a6471134 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -361,6 +361,11 @@ def seed( account_ids["demo.analyst"], corporate_entity_id, ) + _seed_demo_tepp_run( + cur, + account_ids["demo.analyst"], + corporate_entity_id, + ) conn.commit() finally: @@ -1326,6 +1331,122 @@ def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) - ) +def tepp_seed_outcome() -> tuple[str, str | None]: + """Ask TEPP through the published client. A missing transport is Failed. + + Never invents a psychometric score. ``tepp_not_available`` means the + channel was dropped, not a calibrated negative result. + """ + from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable + + request = AnalysisRunRequest( + idempotency_key="demo-tepp-seed-2026-w02", + tenant_workspace_id="demo-workspace", + snapshot_id="demo-source-contract-v1", + knowledge_cutoff="2026-01-12T12:00:00Z", + model_contract_version="tepp-analysis-run-v1", + output_profile="calibrated_event_measurement", + ) + try: + TeppClient().submit_analysis_run(request) + except TeppNotAvailable: + return "analysis_status_failed", "tepp_not_available" + return "analysis_status_succeeded", None + + +def _seed_demo_tepp_run(cur, requested_by_account_id, corporate_entity_id) -> None: + """Insert one Demo-Corp TEPP run so the kind is visible without a live TEPP. + + Uses :func:`tepp_seed_outcome`. Default transport is unavailable, so + the run ends Failed / ``tepp_not_available`` -- never a fake theta. + """ + import hashlib + + digest = hashlib.sha256(b"lineageweave-synthetic-tepp-snapshot-v1").hexdigest() + cur.execute( + "select analysis_source_snapshot_id from analysis_source_snapshot " + "where snapshot_sha256 = %s", + (digest,), + ) + snapshot_row = cur.fetchone() + if snapshot_row is None: + cur.execute( + """ + insert into analysis_source_snapshot + (snapshot_sha256, source_contract_version, + maximum_available_time, captured_at) + values (%s, 'demo-tepp-contract-v1', + '2026-01-12T00:00:00Z', '2026-01-12T00:05:00Z') + returning analysis_source_snapshot_id + """, + (digest,), + ) + snapshot_id = cur.fetchone()[0] + else: + snapshot_id = snapshot_row[0] + cur.execute( + """ + insert into analysis_source_count + (analysis_source_snapshot_id, count_type_code, count_value) + values (%s, 'analysis_count_document', 3) + on conflict do nothing + """, + (snapshot_id,), + ) + cur.execute( + """ + select analysis_run_id from analysis_run + where requested_by_account_id = %s + and idempotency_key = 'demo-tepp-seed-2026-w02' + """, + (requested_by_account_id,), + ) + run_row = cur.fetchone() + if run_row is None: + cur.execute( + """ + insert into analysis_run + (analysis_source_snapshot_id, run_kind_code, idempotency_key, + requested_by_account_id, knowledge_cutoff, + configuration_schema_version, configuration_sha256, + code_revision_sha, requested_at) + values (%s, 'analysis_run_tepp', 'demo-tepp-seed-2026-w02', + %s, '2026-01-12T12:00:00Z', 'tepp-run-v1', %s, %s, + '2026-01-12T12:34:00Z') + returning analysis_run_id + """, + (snapshot_id, requested_by_account_id, "d" * 64, "e" * 40), + ) + run_id = cur.fetchone()[0] + else: + run_id = run_row[0] + cur.execute( + """ + insert into analysis_run_scope + (analysis_run_id, scope_kind_code, corporate_entity_id) + values (%s, 'analysis_scope_corporate_entity', %s) + on conflict (analysis_run_id) do nothing + """, + (run_id, corporate_entity_id), + ) + final_status, failure_code = tepp_seed_outcome() + events = [ + (1, "analysis_status_pending", "2026-01-12T12:35:00Z", None), + (2, "analysis_status_running", "2026-01-12T12:36:00Z", None), + (3, final_status, "2026-01-12T12:37:00Z", failure_code), + ] + for ordinal, status, occurred, fail in events: + cur.execute( + """ + insert into analysis_run_status_event + (analysis_run_id, status_ordinal, status_code, occurred_at, failure_code) + values (%s, %s, %s, %s, %s) + on conflict do nothing + """, + (run_id, ordinal, status, occurred, fail), + ) + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--postgres-dsn", default=DEFAULT_POSTGRES_DSN) diff --git a/tests/test_seed_tepp_run.py b/tests/test_seed_tepp_run.py new file mode 100644 index 000000000..59a22699a --- /dev/null +++ b/tests/test_seed_tepp_run.py @@ -0,0 +1,9 @@ +"""Seeded TEPP analysis runs go through tepp_client, never a local model.""" + +from scripts.seed_demo_data import tepp_seed_outcome + + +def test_tepp_seed_outcome_is_unavailable_not_a_fake_score() -> None: + status, failure = tepp_seed_outcome() + assert status == "analysis_status_failed" + assert failure == "tepp_not_available" diff --git a/uv.lock b/uv.lock index 06408c2a9..411243f56 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.83.0" +version = "0.84.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From c783d49e50b7f7f31f03567df9be3fd37d44fb76 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:23:10 +0000 Subject: [PATCH 2/3] fix: seed TEPP through the shared snapshot and prove the client Keep the missing TEPP transport as Failed / tepp_not_available, reuse the Demo Corp capture for both run kinds, skip frozen count inserts on re-seed, and show the next action plus detail history so operators can tell a dropped channel from a calibrated miss. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 13 +- CHANGELOG.d/0.84.0-tepp-analysis-run.md | 6 +- CHANGELOG.md | 12 +- docs/adr/0014-authorized-analysis-run-read.md | 14 +- frontend/src/AnalysisRunsPanel.test.tsx | 95 ++++++++- frontend/src/AnalysisRunsPanel.tsx | 46 +++-- frontend/src/App.test.tsx | 64 ++++++ frontend/src/analysisRunDisplay.ts | 42 ++++ scripts/seed_demo_data.py | 184 ++++++++++-------- tests/test_seed_tepp_run.py | 80 +++++++- 10 files changed, 441 insertions(+), 115 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4f27d4020..fb529c592 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -468,17 +468,22 @@ run's scope whose `created_at` is at or before the run reconstruction (ADR 0016). Detail also returns revision and configuration digest prefixes. `make seed` also records a TEPP measurement run through -`tepp_client`; the default transport is unavailable, so that run is -Failed / `tepp_not_available` rather than a fabricated score. +`tepp_client` on that same snapshot; the default transport is +unavailable, so that run is Failed rather than a fabricated score. The home list is clickable: `GET /api/analysis-runs/{id}` fills a labeled detail (cutoff, requested date, counts, status history, digests) without exposing a DSN or raw record. Status history is detail-only and uses lookup labels plus occurrence times; a failure event keeps -its machine `failure_code` rather than an invented caption. The +its machine `failure_code` rather than an invented caption. Failed +list rows add a next-action line (open the run, then connect the +measurement service) so `tepp_not_available` is not mistaken for a +calibrated negative result. The payload is lookup labels plus non-negative aggregate counts -- never source SQL, a DSN, a raw record, or a provider body. After `make seed`, Demo Analyst and Demo Admin see "Lineage reconstruction · Succeeded · -Demo Corp" with "3 documents" and Pending / Running / Succeeded times. +Demo Corp" with "3 documents" and Pending / Running / Succeeded times, +and "TEPP measurement · Failed · Demo Corp" whose detail history ends +in Failed / `tepp_not_available`. ## Phase 6a: fast-mlsirm dependency + Rust toolchain (infra only) diff --git a/CHANGELOG.d/0.84.0-tepp-analysis-run.md b/CHANGELOG.d/0.84.0-tepp-analysis-run.md index 18267d698..080cc8240 100644 --- a/CHANGELOG.d/0.84.0-tepp-analysis-run.md +++ b/CHANGELOG.d/0.84.0-tepp-analysis-run.md @@ -1,4 +1,6 @@ # 0.84.0 TEPP analysis-run seed -Seed writes `analysis_run_tepp` via `tepp_client`. Missing transport -is Failed / `tepp_not_available`, not a fake measurement. +Seed writes `analysis_run_tepp` via `tepp_client` on the shared Demo +Corp snapshot. The home list shows Failed and the next action; detail +history keeps `tepp_not_available`. Missing transport is not a fake +measurement. diff --git a/CHANGELOG.md b/CHANGELOG.md index 48030f857..dd0fa2ddd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,14 @@ All notable changes to this project are documented here. Format follows ### Added - `make seed` records a Demo Corp TEPP measurement run through - `tepp_client`. The default transport is unavailable, so the home - list shows "TEPP measurement · Failed · Demo Corp" with - `tepp_not_available` -- never a fabricated theta. TEPP stays a - wire client, not a local psychometric engine. + `tepp_client` on the same snapshot as the lineage run (ADR 0013). + The default transport is unavailable, so the home list shows + "TEPP measurement · Failed · Demo Corp" and tells the operator to + open the run, then connect the measurement service. Detail history + keeps `tepp_not_available` -- never a fabricated theta. TEPP stays + a wire client, not a local psychometric engine. `make seed` skips + snapshot-count inserts once counts exist so a re-run does not hit + the freeze trigger. ## [0.83.0] - 2026-08-16 diff --git a/docs/adr/0014-authorized-analysis-run-read.md b/docs/adr/0014-authorized-analysis-run-read.md index 6349ae596..9f9fc8ab3 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -40,11 +40,15 @@ LineageWeave owns a fail-closed read projection of the #89 registry: ## Consequences -`make seed` writes one synthetic Demo Corp lineage run so the existing -React home page can show Analysis runs without a second application. -The detail now shows the legal lifecycle the registry already stored. -Write/rebuild APIs, TEPP submission, and a fuller Analysis Run Console -remain later slices. +`make seed` writes one synthetic Demo Corp lineage run and one TEPP +run on the same snapshot so the existing React home page can show both +kinds without a second application. The TEPP run is Failed / +`tepp_not_available` when the default transport is missing -- the list +keeps that machine code off the caption (this decision) and instead +tells the operator to open the run, then connect the measurement +service. The detail now shows the legal lifecycle the registry already +stored. Write/rebuild APIs, a live TEPP transport, and a fuller +Analysis Run Console remain later slices. ## References diff --git a/frontend/src/AnalysisRunsPanel.test.tsx b/frontend/src/AnalysisRunsPanel.test.tsx index f96b4a42e..dd3a6369a 100644 --- a/frontend/src/AnalysisRunsPanel.test.tsx +++ b/frontend/src/AnalysisRunsPanel.test.tsx @@ -2,7 +2,13 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; import { AnalysisRunsPanel } from "./AnalysisRunsPanel"; -import { analysisRunCaption, shortDigest } from "./analysisRunDisplay"; +import { + analysisRunCaption, + analysisRunCorpusHint, + analysisRunEmptyPostsHint, + analysisRunNextAction, + shortDigest, +} from "./analysisRunDisplay"; import type { AnalysisRun } from "./api"; const sampleRun: AnalysisRun = { @@ -19,6 +25,20 @@ const sampleRun: AnalysisRun = { source_counts: [], }; +const failedTeppRun: AnalysisRun = { + analysis_run_id: "run-demo-tepp", + run_kind_code: "analysis_run_tepp", + run_kind_label: "TEPP measurement", + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Demo Corp", + status_code: "analysis_status_failed", + status_label: "Failed", + knowledge_cutoff: "2026-01-12T12:00:00Z", + requested_at: "2026-01-12T12:34:00Z", + source_counts: [], +}; + describe("analysisRunCaption", () => { it("joins kind, status, and scope so the operator knows which run to open", () => { expect(analysisRunCaption(sampleRun)).toBe( @@ -27,6 +47,32 @@ describe("analysisRunCaption", () => { }); }); +describe("analysisRunNextAction", () => { + it("tells the operator to open a failed run and reconnect the service", () => { + expect(analysisRunNextAction(failedTeppRun)).toMatch(/connect the measurement service/); + }); + + it("hides a next action on a succeeded run", () => { + expect(analysisRunNextAction(sampleRun)).toBeNull(); + }); +}); + +describe("analysisRunEmptyPostsHint", () => { + it("names TEPP so the empty list is not read as a reconstruction miss", () => { + expect(analysisRunEmptyPostsHint(failedTeppRun)).toMatch(/for TEPP to measure/); + }); +}); + +describe("analysisRunCorpusHint", () => { + it("says cutoff posts are the TEPP measurement bag, not a reconstruction", () => { + expect(analysisRunCorpusHint(failedTeppRun)).toMatch(/cutoff corpus TEPP would measure/); + }); + + it("stays silent on a lineage run", () => { + expect(analysisRunCorpusHint(sampleRun)).toBeNull(); + }); +}); + describe("shortDigest", () => { it("returns a 12-character prefix for comparing an approved revision", () => { expect(shortDigest("c".repeat(40))).toBe("c".repeat(12)); @@ -80,4 +126,51 @@ describe("AnalysisRunsPanel", () => { ).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /Open run post:/ })).not.toBeInTheDocument(); }); + + it("opens a failed TEPP run and shows the machine failure code", async () => { + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/analysis-runs/run-demo-tepp")) { + return Promise.resolve( + new Response( + JSON.stringify({ + ...failedTeppRun, + visible_posts: [{ post_id: "post-1", post_title: "Public post" }], + status_history: [ + { + status_ordinal: 3, + status_code: "analysis_status_failed", + status_label: "Failed", + occurred_at: "2026-01-12T12:37:00Z", + failure_code: "tepp_not_available", + }, + ], + }), + { status: 200 }, + ), + ); + } + if (url.endsWith("/api/analysis-runs")) { + return Promise.resolve( + new Response(JSON.stringify({ analysis_runs: [failedTeppRun] }), { status: 200 }), + ); + } + return Promise.resolve(new Response(null, { status: 404 })); + }), + ); + + render( undefined} />); + expect( + await screen.findByText(/Open this run to see why it failed/), + ).toBeInTheDocument(); + await userEvent.click( + screen.getByRole("button", { + name: "Open analysis run: TEPP measurement · Failed · Demo Corp", + }), + ); + expect(await screen.findByText(/Failed 2026-01-12 12:37 · tepp_not_available/)).toBeInTheDocument(); + expect(screen.getByText(/cutoff corpus TEPP would measure/)).toBeInTheDocument(); + }); }); diff --git a/frontend/src/AnalysisRunsPanel.tsx b/frontend/src/AnalysisRunsPanel.tsx index 2d68a3ce1..059ed263c 100644 --- a/frontend/src/AnalysisRunsPanel.tsx +++ b/frontend/src/AnalysisRunsPanel.tsx @@ -5,7 +5,13 @@ import { fetchAnalysisRuns, type AnalysisRun, } from "./api"; -import { analysisRunCaption, shortDigest } from "./analysisRunDisplay"; +import { + analysisRunCaption, + analysisRunCorpusHint, + analysisRunEmptyPostsHint, + analysisRunNextAction, + shortDigest, +} from "./analysisRunDisplay"; /** * Home-page Analysis runs list and authorized detail. @@ -47,6 +53,8 @@ export function AnalysisRunsPanel({ if (error && runs === null) return

{error}

; if (runs === null) return

Loading analysis runs...

; + const corpusHint = selected ? analysisRunCorpusHint(selected) : null; + return (
@@ -64,6 +72,7 @@ export function AnalysisRunsPanel({ (count) => count.count_type_code === "analysis_count_document", ); const caption = analysisRunCaption(run); + const nextAction = analysisRunNextAction(run); return (
  • ); @@ -116,24 +126,24 @@ export function AnalysisRunsPanel({ )} {selected.visible_posts && selected.visible_posts.length > 0 ? ( -
      - {selected.visible_posts.map((post) => ( -
    • - -
    • - ))} -
    + <> + {corpusHint &&

    {corpusHint}

    } +
      + {selected.visible_posts.map((post) => ( +
    • + +
    • + ))} +
    + ) : ( -

    - No posts were available at this cutoff. Open a later run, or ask an - administrator to capture a newer snapshot. -

    +

    {analysisRunEmptyPostsHint(selected)}

    )}
    )} diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 730c88a52..7fe84cb0a 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -169,6 +169,54 @@ describe("App, authenticated", () => { jsonResponse({ post_id: "post-1", has_commitment: true, ticket }), ); } + if (url.endsWith("/api/analysis-runs/run-demo-tepp")) { + return Promise.resolve( + jsonResponse({ + analysis_run_id: "run-demo-tepp", + run_kind_code: "analysis_run_tepp", + run_kind_label: "TEPP measurement", + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Demo Corp", + status_code: "analysis_status_failed", + status_label: "Failed", + knowledge_cutoff: "2026-01-12T12:00:00Z", + requested_at: "2026-01-12T12:34:00Z", + source_counts: [ + { + count_type_code: "analysis_count_document", + count_type_label: "Documents", + count_value: 3, + }, + ], + visible_posts: [{ post_id: "post-1", post_title: "Public post" }], + configuration_schema_version: "tepp-run-v1", + configuration_sha256: "d".repeat(64), + code_revision_sha: "e".repeat(40), + status_history: [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:35:00Z", + }, + { + status_ordinal: 2, + status_code: "analysis_status_running", + status_label: "Running", + occurred_at: "2026-01-12T12:36:00Z", + }, + { + status_ordinal: 3, + status_code: "analysis_status_failed", + status_label: "Failed", + occurred_at: "2026-01-12T12:37:00Z", + failure_code: "tepp_not_available", + }, + ], + }), + ); + } if (url.endsWith("/api/analysis-runs/run-demo-lineage")) { return Promise.resolve( jsonResponse({ @@ -1397,6 +1445,9 @@ describe("App, authenticated", () => { 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( + "Open this run to see why it failed, then connect the measurement service and re-run.", + ); expect(list).toHaveTextContent("3 documents"); expect(list).not.toHaveTextContent("postgresql://"); expect(list).not.toHaveTextContent("select "); @@ -1420,6 +1471,19 @@ describe("App, authenticated", () => { await userEvent.click(screen.getByRole("button", { name: "Open run post: Public post" })); await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + + await userEvent.click( + screen.getByRole("button", { + name: "Open analysis run: TEPP measurement · Failed · Demo Corp", + }), + ); + expect( + await screen.findByRole("heading", { name: "TEPP 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(teppHistory).not.toHaveTextContent("Succeeded"); }); it("shows the calibrated period-report mean theta on the home page", async () => { diff --git a/frontend/src/analysisRunDisplay.ts b/frontend/src/analysisRunDisplay.ts index 6d5714f03..e2726af70 100644 --- a/frontend/src/analysisRunDisplay.ts +++ b/frontend/src/analysisRunDisplay.ts @@ -22,3 +22,45 @@ export function shortDigest(value: string | undefined, length = 12): string | nu if (!value) return null; return value.slice(0, length); } + +/** + * Next action for a failed run on the home list. + * + * The machine `failure_code` stays on detail history (ADR 0014). The + * list tells the operator to open the run, then reconnect the service. + */ +export function analysisRunNextAction(run: AnalysisRun): string | null { + if (run.status_code === "analysis_status_failed") { + return "Open this run to see why it failed, then connect the measurement service and re-run."; + } + return null; +} + +/** + * Empty-corpus copy that tells the operator what to do next. + */ +export function analysisRunEmptyPostsHint(run: AnalysisRun): string { + if (run.run_kind_code === "analysis_run_tepp") { + return ( + "No posts were available at this cutoff for TEPP to measure. " + + "Open a later run, or ask an administrator to capture a newer snapshot." + ); + } + return ( + "No posts were available at this cutoff. Open a later run, or ask an " + + "administrator to capture a newer snapshot." + ); +} + +/** + * Corpus copy for a TEPP run that already has cutoff posts. + * + * Those titles are the measurement bag, not a reconstruction result. + */ +export function analysisRunCorpusHint(run: AnalysisRun): string | null { + if (run.run_kind_code !== "analysis_run_tepp") return null; + return ( + "These posts are the cutoff corpus TEPP would measure. Connect a TEPP " + + "transport, then re-run, to replace Failed with a calibrated result." + ); +} diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index 6a6471134..8a783c7fe 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -19,6 +19,7 @@ from __future__ import annotations import argparse +import hashlib import os import sys from pathlib import Path @@ -30,6 +31,7 @@ import psycopg2 from lineageweave.http_client import get_json_list, post_form +from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable REALM = "lineageweave-demo" DEFAULT_POSTGRES_DSN = "postgresql://lineageweave:lineageweave_dev_only@localhost:15432/lineageweave" @@ -37,6 +39,12 @@ DEFAULT_KEYCLOAK_ADMIN_USER = os.environ.get("KEYCLOAK_ADMIN", "admin") DEFAULT_VALKEY_URL = "redis://localhost:16379/0" +# ADR 0013: one Demo Corp capture, many runs (lineage + TEPP). +DEMO_SOURCE_SNAPSHOT_MATERIAL = b"lineageweave-synthetic-demo-snapshot-v1" +DEMO_SOURCE_CONTRACT_VERSION = "demo-source-contract-v1" +DEMO_LINEAGE_IDEMPOTENCY_KEY = "demo-lineage-seed-2026-w02" +DEMO_TEPP_IDEMPOTENCY_KEY = "demo-tepp-seed-2026-w02" + # (post_title, ticket_title, due_date) -- Event Lineage fixtures a report # member click opens. Activity seed uses the same titles so Valkey matches. FIXTURE_TICKET_SPECS = ( @@ -1236,36 +1244,57 @@ def _seed_demo_period_report(cur, author_account_id, corporate_entity_id, proces _persist_seed_period_report(cur, "process_unit", high_key, w03, week3[high_key]) -def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) -> None: - """Insert one Demo-Corp lineage run so Analysis runs is not empty. +def demo_source_snapshot_sha256() -> str: + """Return the reusable Demo Corp snapshot digest (never a source row).""" + return hashlib.sha256(DEMO_SOURCE_SNAPSHOT_MATERIAL).hexdigest() - Aggregates only: three synthetic documents, one thread. The digest is - a hash of a fixed demo contract string -- never a source row or DSN. - """ - import hashlib - digest = hashlib.sha256(b"lineageweave-synthetic-demo-snapshot-v1").hexdigest() +def _ensure_demo_source_snapshot(cur): + """Return the shared Demo Corp capture, inserting it on first seed. + + Lineage and TEPP runs share this snapshot (ADR 0013: one capture, + many runs). The digest is a hash of a fixed demo contract string -- + never a source row or DSN. + """ + digest = demo_source_snapshot_sha256() cur.execute( "select analysis_source_snapshot_id from analysis_source_snapshot " "where snapshot_sha256 = %s", (digest,), ) snapshot_row = cur.fetchone() - if snapshot_row is None: - cur.execute( - """ - insert into analysis_source_snapshot - (snapshot_sha256, source_contract_version, - maximum_available_time, captured_at) - values (%s, 'demo-source-contract-v1', - '2026-01-12T00:00:00Z', '2026-01-12T00:05:00Z') - returning analysis_source_snapshot_id - """, - (digest,), - ) - snapshot_id = cur.fetchone()[0] - else: - snapshot_id = snapshot_row[0] + if snapshot_row is not None: + return snapshot_row[0] + cur.execute( + """ + insert into analysis_source_snapshot + (snapshot_sha256, source_contract_version, + maximum_available_time, captured_at) + values (%s, %s, + '2026-01-12T00:00:00Z', '2026-01-12T00:05:00Z') + returning analysis_source_snapshot_id + """, + (digest, DEMO_SOURCE_CONTRACT_VERSION), + ) + return cur.fetchone()[0] + + +def _ensure_demo_source_counts(cur, snapshot_id) -> None: + """Insert demo counts only when the snapshot still has none. + + ``enforce_analysis_source_count_freeze`` runs BEFORE INSERT. After + the first run points at the snapshot, a later ``INSERT ... ON + CONFLICT DO NOTHING`` still raises ``analysis_source_count_frozen_after_run`` + and rolls back the whole ``seed()`` transaction. Skip when counts + already exist so ``make seed`` can be re-run. + """ + cur.execute( + "select 1 from analysis_source_count " + "where analysis_source_snapshot_id = %s limit 1", + (snapshot_id,), + ) + if cur.fetchone() is not None: + return cur.execute( """ insert into analysis_source_count @@ -1275,17 +1304,27 @@ def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) - (%s, 'analysis_count_thread', 1), (%s, 'analysis_count_lineage_node', 5), (%s, 'analysis_count_lineage_edge', 4) - on conflict do nothing """, (snapshot_id, snapshot_id, snapshot_id, snapshot_id), ) + + +def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) -> None: + """Insert one Demo-Corp lineage run so Analysis runs is not empty. + + Aggregates only: three synthetic documents, one thread. Reuses the + shared Demo Corp snapshot so a later TEPP run can attach to the + same capture. + """ + snapshot_id = _ensure_demo_source_snapshot(cur) + _ensure_demo_source_counts(cur, snapshot_id) cur.execute( """ select analysis_run_id from analysis_run where requested_by_account_id = %s - and idempotency_key = 'demo-lineage-seed-2026-w02' + and idempotency_key = %s """, - (requested_by_account_id,), + (requested_by_account_id, DEMO_LINEAGE_IDEMPOTENCY_KEY), ) run_row = cur.fetchone() if run_row is None: @@ -1296,12 +1335,18 @@ def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) - requested_by_account_id, knowledge_cutoff, configuration_schema_version, configuration_sha256, code_revision_sha, requested_at) - values (%s, 'analysis_run_lineage', 'demo-lineage-seed-2026-w02', + values (%s, 'analysis_run_lineage', %s, %s, '2026-01-12T12:00:00Z', 'lineage-run-v1', %s, %s, '2026-01-12T12:30:00Z') returning analysis_run_id """, - (snapshot_id, requested_by_account_id, "b" * 64, "c" * 40), + ( + snapshot_id, + DEMO_LINEAGE_IDEMPOTENCY_KEY, + requested_by_account_id, + "b" * 64, + "c" * 40, + ), ) run_id = cur.fetchone()[0] else: @@ -1331,75 +1376,50 @@ def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) - ) -def tepp_seed_outcome() -> tuple[str, str | None]: - """Ask TEPP through the published client. A missing transport is Failed. - - Never invents a psychometric score. ``tepp_not_available`` means the - channel was dropped, not a calibrated negative result. - """ - from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable - - request = AnalysisRunRequest( - idempotency_key="demo-tepp-seed-2026-w02", +def tepp_seed_request() -> AnalysisRunRequest: + """Build the Demo Corp TEPP request against the shared snapshot digest.""" + return AnalysisRunRequest( + idempotency_key=DEMO_TEPP_IDEMPOTENCY_KEY, tenant_workspace_id="demo-workspace", - snapshot_id="demo-source-contract-v1", + snapshot_id=demo_source_snapshot_sha256(), knowledge_cutoff="2026-01-12T12:00:00Z", model_contract_version="tepp-analysis-run-v1", output_profile="calibrated_event_measurement", ) + + +def tepp_seed_outcome(client: TeppClient | None = None) -> tuple[str, str | None]: + """Ask TEPP through the published client. A missing transport is Failed. + + Never invents a psychometric score. ``tepp_not_available`` means the + channel was dropped, not a calibrated negative result. A live + envelope is also not a persistable measurement in this seed, so the + run is not stamped Succeeded. + """ + request = tepp_seed_request() try: - TeppClient().submit_analysis_run(request) + (client or TeppClient()).submit_analysis_run(request) except TeppNotAvailable: return "analysis_status_failed", "tepp_not_available" - return "analysis_status_succeeded", None + return "analysis_status_failed", "tepp_result_not_persisted" def _seed_demo_tepp_run(cur, requested_by_account_id, corporate_entity_id) -> None: """Insert one Demo-Corp TEPP run so the kind is visible without a live TEPP. - Uses :func:`tepp_seed_outcome`. Default transport is unavailable, so - the run ends Failed / ``tepp_not_available`` -- never a fake theta. + Uses :func:`tepp_seed_outcome` against the shared lineage snapshot. + Default transport is unavailable, so the run ends Failed / + ``tepp_not_available`` -- never a fake theta. """ - import hashlib - - digest = hashlib.sha256(b"lineageweave-synthetic-tepp-snapshot-v1").hexdigest() - cur.execute( - "select analysis_source_snapshot_id from analysis_source_snapshot " - "where snapshot_sha256 = %s", - (digest,), - ) - snapshot_row = cur.fetchone() - if snapshot_row is None: - cur.execute( - """ - insert into analysis_source_snapshot - (snapshot_sha256, source_contract_version, - maximum_available_time, captured_at) - values (%s, 'demo-tepp-contract-v1', - '2026-01-12T00:00:00Z', '2026-01-12T00:05:00Z') - returning analysis_source_snapshot_id - """, - (digest,), - ) - snapshot_id = cur.fetchone()[0] - else: - snapshot_id = snapshot_row[0] - cur.execute( - """ - insert into analysis_source_count - (analysis_source_snapshot_id, count_type_code, count_value) - values (%s, 'analysis_count_document', 3) - on conflict do nothing - """, - (snapshot_id,), - ) + snapshot_id = _ensure_demo_source_snapshot(cur) + _ensure_demo_source_counts(cur, snapshot_id) cur.execute( """ select analysis_run_id from analysis_run where requested_by_account_id = %s - and idempotency_key = 'demo-tepp-seed-2026-w02' + and idempotency_key = %s """, - (requested_by_account_id,), + (requested_by_account_id, DEMO_TEPP_IDEMPOTENCY_KEY), ) run_row = cur.fetchone() if run_row is None: @@ -1410,12 +1430,18 @@ def _seed_demo_tepp_run(cur, requested_by_account_id, corporate_entity_id) -> No requested_by_account_id, knowledge_cutoff, configuration_schema_version, configuration_sha256, code_revision_sha, requested_at) - values (%s, 'analysis_run_tepp', 'demo-tepp-seed-2026-w02', + values (%s, 'analysis_run_tepp', %s, %s, '2026-01-12T12:00:00Z', 'tepp-run-v1', %s, %s, '2026-01-12T12:34:00Z') returning analysis_run_id """, - (snapshot_id, requested_by_account_id, "d" * 64, "e" * 40), + ( + snapshot_id, + DEMO_TEPP_IDEMPOTENCY_KEY, + requested_by_account_id, + "d" * 64, + "e" * 40, + ), ) run_id = cur.fetchone()[0] else: diff --git a/tests/test_seed_tepp_run.py b/tests/test_seed_tepp_run.py index 59a22699a..c865c475c 100644 --- a/tests/test_seed_tepp_run.py +++ b/tests/test_seed_tepp_run.py @@ -1,9 +1,85 @@ """Seeded TEPP analysis runs go through tepp_client, never a local model.""" -from scripts.seed_demo_data import tepp_seed_outcome +from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable +from scripts.seed_demo_data import ( + _ensure_demo_source_counts, + demo_source_snapshot_sha256, + tepp_seed_outcome, + tepp_seed_request, +) -def test_tepp_seed_outcome_is_unavailable_not_a_fake_score() -> None: +class _RecordingUnavailableClient(TeppClient): + """Default-path stand-in that records the request then drops the channel.""" + + def __init__(self) -> None: + super().__init__() + self.submitted: list[AnalysisRunRequest] = [] + + def submit_analysis_run(self, request: AnalysisRunRequest) -> dict[str, object]: + self.submitted.append(request) + raise TeppNotAvailable("TEPP has no live HTTP endpoint yet.") + + +class _AcceptingClient(TeppClient): + """Transport that returns an envelope without a persistable measurement.""" + + def __init__(self) -> None: + super().__init__(transport=lambda _payload: {"status": "accepted"}) + + +class _CountCursor: + """Minimal cursor for proving re-seed skips a frozen count insert.""" + + def __init__(self, existing_counts: bool) -> None: + self.existing_counts = existing_counts + self.statements: list[str] = [] + + def execute(self, sql: str, _params=None) -> None: + self.statements.append(" ".join(sql.split())) + + def fetchone(self): + if self.existing_counts and "from analysis_source_count" in self.statements[-1]: + return (1,) + return None + + +def test_tepp_seed_request_targets_the_shared_demo_snapshot() -> None: + request = tepp_seed_request() + assert request.snapshot_id == demo_source_snapshot_sha256() + assert request.idempotency_key == "demo-tepp-seed-2026-w02" + assert request.model_contract_version == "tepp-analysis-run-v1" + assert request.output_profile == "calibrated_event_measurement" + + +def test_tepp_seed_outcome_calls_client_and_does_not_invent_a_score() -> None: + client = _RecordingUnavailableClient() + status, failure = tepp_seed_outcome(client) + assert status == "analysis_status_failed" + assert failure == "tepp_not_available" + assert client.submitted == [tepp_seed_request()] + + +def test_tepp_seed_outcome_default_client_is_unavailable_not_a_fake_score() -> None: status, failure = tepp_seed_outcome() assert status == "analysis_status_failed" assert failure == "tepp_not_available" + + +def test_tepp_seed_outcome_does_not_treat_an_empty_envelope_as_success() -> None: + status, failure = tepp_seed_outcome(_AcceptingClient()) + assert status == "analysis_status_failed" + assert failure == "tepp_result_not_persisted" + + +def test_ensure_demo_source_counts_skips_insert_when_counts_exist() -> None: + cursor = _CountCursor(existing_counts=True) + _ensure_demo_source_counts(cursor, "snapshot-1") + assert any("from analysis_source_count" in sql for sql in cursor.statements) + assert not any(sql.lstrip().startswith("insert into analysis_source_count") for sql in cursor.statements) + + +def test_ensure_demo_source_counts_inserts_when_the_snapshot_is_empty() -> None: + cursor = _CountCursor(existing_counts=False) + _ensure_demo_source_counts(cursor, "snapshot-1") + assert any(sql.lstrip().startswith("insert into analysis_source_count") for sql in cursor.statements) From aa20be017ddff281d6526b0db2928332c51ebc3f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:23:15 +0000 Subject: [PATCH 3/3] docs(adr): note the seeded TEPP client on the shared snapshot Co-authored-by: Seongho Bae --- docs/adr/0013-normalized-analysis-run-registry.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/adr/0013-normalized-analysis-run-registry.md b/docs/adr/0013-normalized-analysis-run-registry.md index 1d5a59866..f7c386189 100644 --- a/docs/adr/0013-normalized-analysis-run-registry.md +++ b/docs/adr/0013-normalized-analysis-run-registry.md @@ -242,7 +242,9 @@ Acceptance requires: read-only administrator surface. 3. Add a normalized PostgreSQL outbox and Valkey delivery worker. 4. Add TEPP and contextual-orchestrator adapters only after their versioned - contracts are present on reviewed main branches. + contracts are present on reviewed main branches. Seed now records a + Failed TEPP run through `tepp_client` on the shared Demo Corp snapshot; + a live transport remains a later slice. 5. Execute private actual-data analysis and store only signed aggregate and reproducibility manifests outside public source control. 6. Run browser E2E through real OIDC, product navigation, and evidence drill-down.