Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,9 +220,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`
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.d/2.14.0-ask-batched-lineage-graph.md
Original file line number Diff line number Diff line change
@@ -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).
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,16 @@ All notable changes to this project are documented here. Format follows
is unwired, with `이 범위의 일정을 아직 받을 수 없습니다`. Weekly VOC and
newspaper stay on the board.

## [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.26] - 2026-08-24

### Added
Expand Down
57 changes: 0 additions & 57 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,60 +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. 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.
183 changes: 109 additions & 74 deletions backend/app/lineage_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,19 +236,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 "
Expand All @@ -258,40 +253,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
Expand Down Expand Up @@ -326,40 +324,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 or focus_id in neighbors
else []
)
Comment thread
seonghobae marked this conversation as resolved.
Resolved
truncated = False
Comment thread
seonghobae marked this conversation as resolved.
Resolved

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)
Comment thread
seonghobae marked this conversation as resolved.
Resolved
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]]
Comment thread
seonghobae marked this conversation as resolved.
Resolved
Comment thread
seonghobae marked this conversation as resolved.
Resolved
Comment thread
seonghobae marked this conversation as resolved.
Resolved
return _lineage_graph_payload(visible, edge_rows, truncated)
Comment thread
seonghobae marked this conversation as resolved.
Resolved
12 changes: 5 additions & 7 deletions docs/adr/0151-ask-multi-lineage-graph.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
43 changes: 43 additions & 0 deletions docs/adr/0169-ask-batched-lineage-graph.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
Loading
Loading