From 0caa44cc96161284f93900462967411180763eba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 12:29:05 +0000 Subject: [PATCH 1/3] feat(ask): batch cited lineage graph fetch (#568) Global Ask now loads source_post and post_lineage_edge once for every cited post, keeps cited posts first at the landing node bound, and names truncated so a click still opens that post (ADR 0169). --- AGENTS.md | 6 + ARCHITECTURE.md | 5 +- .../2.14.0-ask-batched-lineage-graph.md | 2 + CHANGELOG.md | 10 + CLAUDE.md | 4 +- backend/app/lineage_ingestion.py | 183 +++++++++++------- docs/adr/0151-ask-multi-lineage-graph.md | 12 +- docs/adr/0169-ask-batched-lineage-graph.md | 43 ++++ docs/adr/README.md | 2 +- frontend/package.json | 2 +- pyproject.toml | 2 +- tests/test_lineage_ingestion.py | 98 ++++++++++ uv.lock | 2 +- 13 files changed, 284 insertions(+), 87 deletions(-) create mode 100644 CHANGELOG.d/2.14.0-ask-batched-lineage-graph.md create mode 100644 docs/adr/0169-ask-batched-lineage-graph.md diff --git a/AGENTS.md b/AGENTS.md index b34194c8d..ff462f8ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -202,6 +202,12 @@ persist to `report_leftover_pair` with signed residual `R`, observed plus leftover-map rank so rank 0 is not read as structure. They sit above the member list so a click opens that post. +Global Ask merged Event Lineage (ADR 0151 / 0169) loads visible posts +and lineage edges once for every cited post, keeps cited posts first +when the graph is larger than the landing bound, and names +**truncated** so the reader can open a cited post for the full +thread. Do not refetch the edge table per citation. + `frontend/` has its own toolchain (Node pinned via `frontend/mise.toml`, pnpm via Corepack -- do not add a second Node package manager or a floating Node version): diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fe6a94941..b6e1f1378 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -218,9 +218,12 @@ lives in `lineageweave/keyman_extraction.py` and talks to contextual-orchestrator; persist is `backend/app/keyman_ingestion.py`. `GET /api/lineage` returns the ABAC-filtered reconstruct graph -(`{nodes, edges}`) from persisted `post_lineage_edge` rows. Each node +(`{nodes, edges, truncated}`) from persisted `post_lineage_edge` rows. Each node includes `group` from the same `reconstruct_group_key()` rebuild uses (persisted `thread_group_key`, else process unit, else corp). +Global Ask merges cited threads from one post/edge fetch pair and +caps the payload at the landing node bound, keeping cited posts first +(ADR 0169). Open a cited post to read the focused thread. `POST /api/lineage/rebuild` (`post_admin`) re-runs `reconstruct()` over every `source_post` and rewrites those edges. Reconstruct grouping is stored on the post as `thread_group_key` / `secondary_grouping_key` diff --git a/CHANGELOG.d/2.14.0-ask-batched-lineage-graph.md b/CHANGELOG.d/2.14.0-ask-batched-lineage-graph.md new file mode 100644 index 000000000..e292715f2 --- /dev/null +++ b/CHANGELOG.d/2.14.0-ask-batched-lineage-graph.md @@ -0,0 +1,2 @@ +Global Ask merges cited Event Lineage from one post/edge fetch pair +and names truncation at the landing node bound (ADR 0169 / #568). diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d0005ecd..2d7033965 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,16 @@ All notable changes to this project are documented here. Format follows environment, so local OIDC and synthetic-data workflows resolve the same pinned dependencies as CI. +## [2.14.0] - 2026-08-24 + +### Changed + +- Global Ask now builds the merged cited Event Lineage graph from one + `source_post` scan and one `post_lineage_edge` read, keeps cited posts + first when the graph exceeds the landing node bound, and names + **truncated** so a click still opens that cited post (ADR 0169 / #568). + Do not invent a theta. + ## [2.12.18] - 2026-08-24 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 42b091e42..ec9e16932 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,9 @@ and to read its mean θ and member posts, then open a post. Those members land immediately under that next action, ahead of Other Corp and the week strip. After `make seed`, leftover closest/farthest pairs sit above the member list with leftover-map rank; rank 0 names no -leftover structure. Opening Public post names the next action: read +leftover structure. Global Ask Event Lineage for cited posts comes +from one post/edge fetch pair and names truncation when the merged +graph hits the landing bound (ADR 0169). Opening Public post names the next action: read Event Lineage, Keyman, and evaluation on that post. The popup Event Lineage DAG marks that post current. After that current node, the popup names Keyman and evaluation as the next read. After landed diff --git a/backend/app/lineage_ingestion.py b/backend/app/lineage_ingestion.py index 97f33f948..693306035 100644 --- a/backend/app/lineage_ingestion.py +++ b/backend/app/lineage_ingestion.py @@ -171,19 +171,14 @@ async def rebuild_lineage(conn: asyncpg.Connection) -> list[Edge]: return edges -async def visible_lineage_graph( - conn: asyncpg.Connection, - can_see_post, - limit: int = 500, - focus_post_id: str | None = None, - include_isolated: bool = False, -) -> dict[str, Any]: - """ABAC-filtered graph bounded for the browser's initial viewport. +# Browser landing viewport and Global Ask merged-graph payload share this +# bound. Ask keeps cited posts first, then newest remaining nodes, and +# names truncation instead of shipping an unbounded component (ADR 0169). +_LINEAGE_GRAPH_NODE_LIMIT = 500 - The persisted graph can contain tens of thousands of posts. The UI opens - individual posts for complete lineage, while this landing projection keeps - only the newest ``limit`` visible nodes and edges between them. - """ + +async def _fetch_visible_lineage_rows(conn: asyncpg.Connection, can_see_post): + """One ABAC-filtered ``source_post`` scan plus one edge-table read.""" posts = await conn.fetch( "select post_id, post_title, voc_type_code, visibility_code, " "corporate_entity_id, process_unit_id, thread_group_key, created_at " @@ -193,40 +188,43 @@ async def visible_lineage_graph( edge_rows = await conn.fetch( "select parent_post_id, child_post_id, fused_score from post_lineage_edge" ) + return visible_all, edge_rows - if focus_post_id is None: - visible = sorted( - visible_all, - key=lambda row: (row["created_at"], str(row["post_id"])), - reverse=True, - )[:limit] - truncated = len(visible_all) > len(visible) - else: - focus_id = str(focus_post_id) - focus_visible = any(str(row["post_id"]) == focus_id for row in visible_all) - neighbors: dict[str, set[str]] = {} - for edge in edge_rows: - parent_id = str(edge["parent_post_id"]) - child_id = str(edge["child_post_id"]) - neighbors.setdefault(parent_id, set()).add(child_id) - neighbors.setdefault(child_id, set()).add(parent_id) - - component_ids: set[str] = set() - frontier = [focus_id] if focus_visible else [] - while frontier: - current_id = frontier.pop() - if current_id in component_ids: - continue - component_ids.add(current_id) - frontier.extend(neighbors.get(current_id, set()) - component_ids) - visible = ( - [row for row in visible_all if str(row["post_id"]) in component_ids] - if include_isolated or len(component_ids) > 1 - else [] - ) - truncated = False +def _undirected_neighbors(edge_rows) -> dict[str, set[str]]: + neighbors: dict[str, set[str]] = {} + for edge in edge_rows: + parent_id = str(edge["parent_post_id"]) + child_id = str(edge["child_post_id"]) + neighbors.setdefault(parent_id, set()).add(child_id) + neighbors.setdefault(child_id, set()).add(parent_id) + return neighbors + + +def _connected_visible_component( + focus_id: str, + neighbors: dict[str, set[str]], + allowed: set[str], +) -> set[str]: + """Walk the undirected reconstruct graph; return visible posts only. + Invisible posts may still bridge two visible posts so the component + matches ``visible_lineage_graph`` focus mode. + """ + if focus_id not in allowed: + return set() + component_ids: set[str] = set() + frontier = [focus_id] + while frontier: + current_id = frontier.pop() + if current_id in component_ids: + continue + component_ids.add(current_id) + frontier.extend(neighbors.get(current_id, set()) - component_ids) + return component_ids & allowed + + +def _lineage_graph_payload(visible, edge_rows, truncated: bool) -> dict[str, Any]: visible_ids = {str(row["post_id"]) for row in visible} visible_edges = [ row @@ -261,40 +259,77 @@ async def visible_lineage_graph( return {"nodes": nodes, "edges": edges, "truncated": truncated} +async def visible_lineage_graph( + conn: asyncpg.Connection, + can_see_post, + limit: int = _LINEAGE_GRAPH_NODE_LIMIT, + focus_post_id: str | None = None, + include_isolated: bool = False, +) -> dict[str, Any]: + """ABAC-filtered graph bounded for the browser's initial viewport. + + The persisted graph can contain tens of thousands of posts. The UI opens + individual posts for complete lineage, while this landing projection keeps + only the newest ``limit`` visible nodes and edges between them. + """ + visible_all, edge_rows = await _fetch_visible_lineage_rows(conn, can_see_post) + + if focus_post_id is None: + visible = sorted( + visible_all, + key=lambda row: (row["created_at"], str(row["post_id"])), + reverse=True, + )[:limit] + truncated = len(visible_all) > len(visible) + else: + focus_id = str(focus_post_id) + neighbors = _undirected_neighbors(edge_rows) + allowed = {str(row["post_id"]) for row in visible_all} + component_ids = _connected_visible_component(focus_id, neighbors, allowed) + visible = ( + [row for row in visible_all if str(row["post_id"]) in component_ids] + if include_isolated or len(component_ids) > 1 + else [] + ) + truncated = False + + return _lineage_graph_payload(visible, edge_rows, truncated) + + async def lineage_graphs_for_posts( conn: asyncpg.Connection, can_see_post, post_ids: list[str], + node_limit: int = _LINEAGE_GRAPH_NODE_LIMIT, ) -> dict[str, Any]: - """Merge each post's full reconstructed thread into one ``LineageGraph``. - - An Ask Agent answer can cite several posts from unrelated reconstruct - threads -- e.g. two separate customer complaints that happen to share a - keyword. The frontend's ``LineageDag`` already renders one ``LineageGraph`` - as several independent git-branch-style figures, one per - ``reconstruct_group_key`` (see ``lineageLayout.ts``'s ``layoutLineageDag``); - merging every cited post's thread into a single graph is enough to get - that multi-graph rendering for free, no new frontend layout needed. - - ponytail: one ``visible_lineage_graph`` call per post (each a bounded - ``source_post`` + full ``post_lineage_edge`` scan) -- fine for the - existing citation cap (``_POST_CHAT_SOURCE_LIMIT`` = 8), revisit with a - single batched query if that cap grows materially. + """Merge each cited post's reconstructed thread into one ``LineageGraph``. + + One ``source_post`` scan and one ``post_lineage_edge`` read cover every + citation. Cited posts stay first when the merged graph is larger than + ``node_limit``; ``truncated`` is then true so Ask can name the bound + (ADR 0151 / 0169). Isolated cited posts still appear. """ - nodes_by_id: dict[str, dict[str, Any]] = {} - edges_by_key: dict[tuple[str, str], dict[str, Any]] = {} - truncated = False - for post_id in dict.fromkeys(post_ids): - graph = await visible_lineage_graph( - conn, can_see_post, focus_post_id=post_id, include_isolated=True - ) - truncated = truncated or graph["truncated"] - for node in graph["nodes"]: - nodes_by_id[node["id"]] = node - for edge in graph["edges"]: - edges_by_key[(edge["source"], edge["target"])] = edge - return { - "nodes": list(nodes_by_id.values()), - "edges": list(edges_by_key.values()), - "truncated": truncated, - } + unique_ids = list(dict.fromkeys(str(post_id) for post_id in post_ids)) + if not unique_ids: + return {"nodes": [], "edges": [], "truncated": False} + visible_all, edge_rows = await _fetch_visible_lineage_rows(conn, can_see_post) + neighbors = _undirected_neighbors(edge_rows) + allowed = {str(row["post_id"]) for row in visible_all} + keep_ids: set[str] = set() + cited_visible: list[str] = [] + for post_id in unique_ids: + component = _connected_visible_component(post_id, neighbors, allowed) + if not component: + continue + keep_ids.update(component) + cited_visible.append(post_id) + rows_by_id = {str(row["post_id"]): row for row in visible_all} + newest_ids = sorted( + keep_ids, + key=lambda pid: (rows_by_id[pid]["created_at"], pid), + reverse=True, + ) + ordered_ids = list(dict.fromkeys([*cited_visible, *newest_ids])) + truncated = len(ordered_ids) > node_limit + visible = [rows_by_id[post_id] for post_id in ordered_ids[:node_limit]] + return _lineage_graph_payload(visible, edge_rows, truncated) diff --git a/docs/adr/0151-ask-multi-lineage-graph.md b/docs/adr/0151-ask-multi-lineage-graph.md index 22c9a14f4..1c10840ef 100644 --- a/docs/adr/0151-ask-multi-lineage-graph.md +++ b/docs/adr/0151-ask-multi-lineage-graph.md @@ -2,7 +2,7 @@ - Status: Accepted - Date: 2026-08-22 -- Related: [0090](0090-global-ask-lineage-timeline-expansion.md), [0064](0064-lineage-evidence-and-tree-assembly.md) +- Related: [0090](0090-global-ask-lineage-timeline-expansion.md), [0064](0064-lineage-evidence-and-tree-assembly.md), [0169](0169-ask-batched-lineage-graph.md) ## Context @@ -51,11 +51,9 @@ independent branch-tree figures with no new frontend layout code. - An Ask answer's lineage evidence is now visually traceable per cited thread, not summarized as prose for one anchor post only. -- `lineage_graphs_for_posts` issues one `visible_lineage_graph` call per - cited post (each a bounded `source_post` scan plus a full - `post_lineage_edge` table read); acceptable at the current citation cap - (`_POST_CHAT_SOURCE_LIMIT` = 8) and the existing `visible_lineage_graph` - precedent for the post-detail popup, revisit with a single batched query - if the citation cap grows materially. +- `lineage_graphs_for_posts` loads visible posts and lineage edges + once, then slices per-citation subgraphs in memory (ADR 0169). The + merged payload is bounded; `truncated` names that bound. Cited + posts stay first so every cited thread still appears. - The response payload grows by one field (`lineage_graph`); existing consumers that ignore unknown fields are unaffected. diff --git a/docs/adr/0169-ask-batched-lineage-graph.md b/docs/adr/0169-ask-batched-lineage-graph.md new file mode 100644 index 000000000..7a4e2f0e4 --- /dev/null +++ b/docs/adr/0169-ask-batched-lineage-graph.md @@ -0,0 +1,43 @@ +# ADR 0169 — Global Ask merges cited lineage from one fetch pair + +**Decision status:** Accepted +**Date:** 2026-08-24 +**Related:** [0151](0151-ask-multi-lineage-graph.md), [0090](0090-global-ask-lineage-timeline-expansion.md), issue [#568](https://github.com/ContextualWisdomLab/LineageWeave/issues/568) + +## Context + +ADR 0151 merges every cited post's reconstructed thread into one +`LineageGraph` so Ask can reuse `LineageDag`. The first implementation +called `visible_lineage_graph` once per citation. Each call re-read +every eligible `source_post` row and the full `post_lineage_edge` +table. Focus mode also returned the entire connected component with +`truncated: false`, so a cited post inside a large component could +inflate the Ask payload without naming a bound. + +## Decision + +1. `lineage_graphs_for_posts` loads visible posts and lineage edges + once, partitions connected components in memory, and slices the + per-citation subgraphs from that pair of reads. +2. The merged payload uses the same node bound as the landing + viewport (`_LINEAGE_GRAPH_NODE_LIMIT` = 500). Cited posts stay + first. Remaining component nodes follow newest-first. Nodes beyond + the bound are omitted and `truncated` is true. +3. Isolated cited posts still appear. Analysis-run knowledge cutoff + and leftover pairs are unchanged. Do not invent a theta. + +## Considered alternatives + +- Keep the per-citation refetch because the citation cap is 8: + rejected. The cost is the table scan, not the citation count, and + the uncapped component is independent of that cap. +- Depth-from-citation hop limit instead of a node cap: deferred. A + hop limit can hide a cited thread's own branch point; the node cap + keeps cited posts visible and names the bound. + +## Consequences + +- N citations issue one `source_post` query and one + `post_lineage_edge` query. +- A large connected component no longer ships unbounded into Ask. + Open a cited post to read the full focused thread. diff --git a/docs/adr/README.md b/docs/adr/README.md index d048dea29..e48a58df5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -10,7 +10,7 @@ decision from them. | Supporting document | Normative ADR | |---|---| | [`product-technical-gap-baseline.md`](../product-technical-gap-baseline.md) | Product/technical traceability projection across the ADR set; ADRs remain normative | -| [`lineage-bi-research-notes.md`](../lineage-bi-research-notes.md) | [0084](0084-lineage-research-grounding.md), [0062](0062-semantic-unit-embedding.md), [0064](0064-lineage-evidence-and-tree-assembly.md), [0024](0024-rankweave-fusion-fail-closed.md), [0165](0165-quantity-script-display.md), [0167](0167-rankweave-ranking-channel-evidence.md) | +| [`lineage-bi-research-notes.md`](../lineage-bi-research-notes.md) | [0084](0084-lineage-research-grounding.md), [0062](0062-semantic-unit-embedding.md), [0064](0064-lineage-evidence-and-tree-assembly.md), [0024](0024-rankweave-fusion-fail-closed.md), [0165](0165-quantity-script-display.md), [0167](0167-rankweave-ranking-channel-evidence.md), [0169](0169-ask-batched-lineage-graph.md) | | [`PROV_O_IMPLEMENTATION.md`](../PROV_O_IMPLEMENTATION.md) | [0065](0065-prov-o-provenance-boundary.md) | | [`PROV_O_IMPLEMENTATION_MATRIX.md`](../PROV_O_IMPLEMENTATION_MATRIX.md) | [0065](0065-prov-o-provenance-boundary.md) | | [`ONTOLOGY_NAMESPACE_INVENTORY.md`](../doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md) | [0157](0157-public-ontology-namespace-identity.md) | diff --git a/frontend/package.json b/frontend/package.json index 594ba6bee..33d6c7d6e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.12.18", + "version": "2.14.0", "type": "module", "scripts": { "dev": "vite", diff --git a/pyproject.toml b/pyproject.toml index 9a3975230..e7d731270 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.12.18" +version = "2.14.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/tests/test_lineage_ingestion.py b/tests/test_lineage_ingestion.py index a644a4e5e..f4d62bc19 100644 --- a/tests/test_lineage_ingestion.py +++ b/tests/test_lineage_ingestion.py @@ -443,3 +443,101 @@ async def fetch(self, query: str): merged = asyncio.run(lineage_graphs_for_posts(FakeConnection(), lambda row: True, [])) assert merged == {"nodes": [], "edges": [], "truncated": False} + + +def test_lineage_graphs_for_posts_fetches_posts_and_edges_once_for_n_citations() -> None: + """N citations must not refetch source_post / post_lineage_edge per cite.""" + + class FakeConnection: + posts = [ + { + "post_id": f"post-{index}", + "post_title": f"P{index}", + "voc_type_code": "voc", + "visibility_code": "public", + "corporate_entity_id": "corp", + "process_unit_id": "pu", + "thread_group_key": f"thread-{index}", + "created_at": datetime(2026, 1, index), + } + for index in range(1, 5) + ] + edges = [ + { + "parent_post_id": "post-1", + "child_post_id": "post-2", + "fused_score": 0.5, + } + ] + queries: list[str] = [] + + async def fetch(self, query: str): + self.queries.append(query) + return self.edges if "post_lineage_edge" in query else self.posts + + connection = FakeConnection() + merged = asyncio.run( + lineage_graphs_for_posts( + connection, + lambda row: True, + ["post-1", "post-3", "post-4", "post-1"], + ) + ) + + post_queries = [query for query in connection.queries if "from source_post" in query] + edge_queries = [query for query in connection.queries if "post_lineage_edge" in query] + assert len(post_queries) == 1 + assert len(edge_queries) == 1 + assert {node["id"] for node in merged["nodes"]} == { + "post-1", + "post-2", + "post-3", + "post-4", + } + assert merged["truncated"] is False + + +def test_lineage_graphs_for_posts_names_truncation_and_keeps_cited_posts() -> None: + posts = [ + { + "post_id": f"post-{index}", + "post_title": f"P{index}", + "voc_type_code": "voc", + "visibility_code": "public", + "corporate_entity_id": "corp", + "process_unit_id": "pu", + "thread_group_key": "thread-chain", + "created_at": datetime(2026, 1, index), + } + for index in range(1, 7) + ] + edges = [ + { + "parent_post_id": f"post-{index}", + "child_post_id": f"post-{index + 1}", + "fused_score": 0.4, + } + for index in range(1, 6) + ] + + class FakeConnection: + async def fetch(self, query: str): + return edges if "post_lineage_edge" in query else posts + + merged = asyncio.run( + lineage_graphs_for_posts( + FakeConnection(), + lambda row: True, + ["post-1", "post-6"], + node_limit=3, + ) + ) + + node_ids = [node["id"] for node in merged["nodes"]] + assert node_ids[:2] == ["post-1", "post-6"] + assert len(node_ids) == 3 + assert merged["truncated"] is True + assert all( + edge["source"] in node_ids and edge["target"] in node_ids + for edge in merged["edges"] + ) diff --git a/uv.lock b/uv.lock index 112d46840..eed84dc7a 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.12.18" +version = "2.14.0" source = { editable = "." } dependencies = [ { name = "certifi" }, From 3992302f7495fc279bf7d707705fd186333e2a1a Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 10:05:03 +0900 Subject: [PATCH 2/3] docs: drop merged conflict tail --- CLAUDE.md | 60 ------------------------------------------------------- 1 file changed, 60 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e19f060c8..f32325abc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,63 +54,3 @@ migration replay (ADR 0166), docstring coverage, and the measurement boundary are all stated in [AGENTS.md](AGENTS.md) -- read it before changing code, tests, or runtime policy rather than restating anything here. -======= -period-report run on the same snapshot (ADR 0013 / ADR 0024). The TEPP path goes through `tepp_client`. A missing -transport or an unused accepted envelope is Failed -(`tepp_not_available` / `tepp_result_not_persisted`). Do not invent a -theta or a local psychometric substitute. The home list caption stays -`kind · status · entity`; the machine failure code is detail-only -(ADR 0014). Open a Failed TEPP row, then connect a live TEPP -transport. A failed lineage row retries reconstruction -- it does not -mention TEPP. A failed period-report row rebuilds the report. A -pending TEPP row does not claim a calibrated measurement. A pending -lineage row says reconstruction has not started yet. -Digest prefixes stay audible; hover a prefix to read the full digest. -Opening a cutoff title shows the live post. Titles marked updated -after cutoff were rewritten after the run; the opened body names -both clocks and shows **Body this run knew** beside the live -rewrite. Compare those two texts before treating the live body as -reconstructed evidence (ADR 0016 / 0025). -`POST /api/analysis-runs` records Pending lineage only on an -authorized cutoff capture (ADR 0017). TEPP and period-report kinds -are 422. The Request button waits until affiliated corps load; choose -a corp if the token walks more than one. `POST /api/analysis-runs/{id}/start` -commits Running plus a durable outbox row, then reconstructs that -frozen cutoff bag (ADR 0021 / ADR 0023) or submits TEPP through -`tepp_client` (ADR 0022). A missing transport or unused accepted -envelope is Failed. Failed TEPP is terminal — connect a TEPP -transport from that Failed row. Create does not invent a Pending -TEPP row. Do not invent a theta. Hover the Result prefix to read -the parent-choice digest. -After `make seed`, open **Period report · Succeeded · Demo Corp**, -then **Open period report 2026-W02**. The home week is already -2026-W02, so the grouping comparison strip lands on Demo Corp. Report -grouping is Corporate entity and Demo Corp is current. The focused -chip name contains `Corporate entity: Demo Corp` and the persisted -mean θ. The period-report panel says Demo Corp is the opened grouping -and to read its mean θ and member posts, then open a post. Those -members land immediately under that next action, ahead of Other Corp -and the week strip. After `make seed`, leftover closest/farthest pairs -sit above the member list with leftover-map rank (rank 0 names no -leftover structure) and unexplained leftover `U` next to leftover-map -distance `d`. Leftover-map axis share badges name Gabriel inertia -of axes 1 and 2; open a leftover pair to read the post–criterion cell. -The shares do not invent a leftover score. Global Ask Event Lineage for -cited posts comes from one post/edge fetch pair and names truncation -when the merged graph hits the landing bound (ADR 0169). Opening Public -post names the next action: read -Event Lineage, Keyman, and evaluation on that post. The popup Event -Lineage DAG marks that post current. After that current node, the -popup names Keyman and evaluation as the next read. After landed -evaluation, the popup names the first Keyman as the next read. After -landed Ada West related, the popup names the first related node as -the next read. After that next action, the popup lands Priya Nair -related nodes. After those related nodes land, the popup names Ask -about this lineage as the next read. After that next action, the -popup lands Ask about this lineage. After landed chat, the popup names -the first Ask. After that next action, the popup lands the first Ask -answer. After landed first Ask answer, the popup names the first -cited source. After that next action, the popup lands the first cited -evidence. Changing the week first still -focuses the report period field. Mean θ stays on the period-report -panel. From b53d5d2887b756910c2696555af534eef78ab95e Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 12:22:35 +0900 Subject: [PATCH 3/3] fix(lineage): retain visible focus with hidden neighbors --- backend/app/lineage_ingestion.py | 2 +- tests/test_lineage_ingestion.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/backend/app/lineage_ingestion.py b/backend/app/lineage_ingestion.py index dc076966d..92ed0b432 100644 --- a/backend/app/lineage_ingestion.py +++ b/backend/app/lineage_ingestion.py @@ -353,7 +353,7 @@ async def visible_lineage_graph( component_ids = _connected_visible_component(focus_id, neighbors, allowed) visible = ( [row for row in visible_all if str(row["post_id"]) in component_ids] - if include_isolated or len(component_ids) > 1 + if include_isolated or len(component_ids) > 1 or focus_id in neighbors else [] ) truncated = False diff --git a/tests/test_lineage_ingestion.py b/tests/test_lineage_ingestion.py index 8cbbb9c0b..f65cc9188 100644 --- a/tests/test_lineage_ingestion.py +++ b/tests/test_lineage_ingestion.py @@ -566,12 +566,22 @@ async def fetch(self, query: str): connection, lambda row: True, limit=1, focus_post_id="post-c" ) ) + hidden_neighbor = asyncio.run( + ingestion.visible_lineage_graph( + connection, + lambda row: row["post_id"] != "post-b", + limit=1, + focus_post_id="post-a", + ) + ) assert [node["id"] for node in landing["nodes"]] == ["post-c"] assert {node["id"] for node in focused["nodes"]} == {"post-a", "post-b"} assert len(focused["edges"]) == 1 assert focused["truncated"] is False assert isolated == {"nodes": [], "edges": [], "truncated": False} + assert [node["id"] for node in hidden_neighbor["nodes"]] == ["post-a"] + assert hidden_neighbor["edges"] == [] def test_lineage_graphs_for_posts_merges_distinct_threads_without_duplicates() -> None: