Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions CHANGELOG.d/2.23.2-project-history-lineage-counts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
### Added

- Project history now shows how many displayed authorized project posts
participate in persisted forward lineage edges and how many distinct
evidence-lineage groups those posts form. The same bounded counts appear from
post and Ask entry points.
15 changes: 15 additions & 0 deletions docs/adr/0128-authorized-project-history-buyer-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ preserving Project History as the Event Lineage focus. Counts, display names,
responsibility transitions, and related paths are bounded projections, not an
HR ledger or a causal graph.

The projection reports three explicit evidence-fusion statistics over the same
bounded authorized event set: `event_count` counts displayed project posts,
`connected_post_count` counts posts incident to at least one forward
`post_lineage_edge`, and `lineage_count` counts weakly connected components
containing those posts. Isolated project posts remain in `event_count` but do
not become a lineage. These are LineageWeave evidence-DAG statistics, not TEPP
topic identities; TEPP topic birth, split, merge, dormancy, and reactivation
stay on the separate fail-closed TEPP contract.

The reader-facing label always says that the post count is the shown set. When
`truncated` is true, none of these statistics claims to cover project evidence
outside the bounded response.

The UI uses the existing design-token and Storybook component boundary. The
Figma file above is the design source for the desktop, mobile, and evidence
boundary states; no second component-specific token system is introduced.
Expand All @@ -45,6 +58,8 @@ boundary states; no second component-specific token system is introduced.
source evidence in one workflow.
- Hidden or post-cutoff records cannot affect the index, counts, transitions,
or related paths.
- A reader can distinguish all project posts from posts already connected into
one or more evidence lineages without treating an isolated post as a topic.
- Semantic project mentions remain visibly inferred and do not overwrite a
source project identity.
- The current document-time fallback remains explicit until a durable event
Expand Down
17 changes: 17 additions & 0 deletions docs/product-technical-gap-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,23 @@ runtime note into a shipped/live claim.
- The next stacked slice attaches this same canonical timeline and TEPP metadata to
Global Ask and post-scoped Ask without re-retrieving hidden evidence.

## Project-history lineage counts (2026-08-23)

- The shared timeline now distinguishes displayed authorized project posts from
posts incident to a persisted forward `post_lineage_edge`, and reports the
number of weakly connected evidence-lineage components in that bounded set.
- Isolated project posts remain visible but do not inflate the lineage count.
These counts describe LineageWeave's evidence-fusion DAG; they are not TEPP
topic identities and do not close the TRSL-TM topic-lineage dependency.
- The same projection supplies the dedicated Project history destination, each
post's project-evidence action, post-scoped Ask, and Global Ask, so those
surfaces cannot drift into competing count definitions.
- Stacked delivery evidence is PR #487 on PR #258. Local evidence is the full
993-test backend suite, focused Project History UI tests, the complete App test
file, lint, production build, Storybook build, and rendered-story inspection.
Protected merge, authenticated runtime, and the TEPP topic-identity result
contract remain open and must not be represented as protected-main behavior.

## Ask-to-project-history integration (2026-08-21)

- Protected-stack checkpoint: PR #342 is based on PR #339 head
Expand Down
1 change: 1 addition & 0 deletions docs/storybook-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ reader-facing control you can click before changing product CSS.
| `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` |
| `AnalysisRun/CutoffKnownBody` | Read the cutoff-known sentence, then compare it with the live body below. | `--color-accent-border`, `--space-panel-block`, `--radius-panel`, `CutoffKnownBody` |
| `Analysis/LineageEntityPicker` | Choose which corp to reconstruct, then click Request a lineage reconstruction. | `--space-control-gap`, `--size-control-min`, `--radius-control`, `LineageEntityPicker` |
| `Evidence/ProjectHistoryTimeline` | Compare all project posts with connected-post and evidence-lineage counts, then open the selected source. | shared spacing, status, focus, and timeline tokens; `ProjectHistoryTimeline` |
| `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` |
| `Evidence/LineageDag` | Inspect a branching Event Lineage, then open a record or read its evidence trail. | `--color-primary`, `--color-accent-orange`, `LineageDag` |

Expand Down
2 changes: 2 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1914,6 +1914,8 @@ describe("App, authenticated", () => {
focus_event_id: "post-1",
time_basis_code: "source_post_created_at_fallback",
event_count: 1,
connected_post_count: 0,
lineage_count: 0,
distinct_actor_count: 0,
distinct_observed_actor_count: 0,
evidence_boundary_code: "authorized_visible_source_posts",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/AskProjectHistoryLinks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const projection: ProjectHistoryProjection = {
knowledge_cutoff: "2026-08-20T12:00:00Z",
evidence_boundary_code: "authorized_visible_source_posts",
event_count: 1,
connected_post_count: 0,
lineage_count: 0,
distinct_actor_count: 0,
distinct_observed_actor_count: 0,
truncated: false,
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/ProjectHistoryTimeline.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ const projection: ProjectHistoryProjection = {
knowledge_cutoff: "2026-08-20T00:00:00Z",
evidence_boundary_code: "authorized_visible_source_posts",
event_count: 5,
connected_post_count: 5,
lineage_count: 1,
distinct_actor_count: 3,
distinct_observed_actor_count: 2,
truncated: false,
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/ProjectHistoryTimeline.tepp.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const projection = {
knowledge_cutoff: "2026-08-20T12:00:00Z",
evidence_boundary_code: "authorized_visible_source_posts",
event_count: 1,
connected_post_count: 0,
lineage_count: 0,
distinct_actor_count: 0,
distinct_observed_actor_count: 0,
truncated: false,
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/components/ProjectHistoryTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const projection: ProjectHistoryProjection = {
knowledge_cutoff: "2026-08-20T00:00:00+00:00",
evidence_boundary_code: "authorized_visible_source_posts",
event_count: 3,
connected_post_count: 3,
lineage_count: 1,
distinct_actor_count: 2,
distinct_observed_actor_count: 1,
truncated: false,
Expand Down Expand Up @@ -142,6 +144,7 @@ describe("ProjectHistoryTimeline", () => {
expect(screen.getByText(/evidence gap/i, { selector: "dd" })).toBeInTheDocument();
expect(screen.getByText(/related history, not causality/i)).toBeInTheDocument();
expect(screen.getByText(/permission, visibility, publication, and cutoff gates/i)).toBeInTheDocument();
expect(screen.getByText(/3 shown project posts · 3 connected · lineage count 1/i)).toBeInTheDocument();

fireEvent.click(screen.getByRole("button", { name: /open source record: VOC received/i }));
expect(onOpenPost).toHaveBeenCalledWith("post-voc");
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/ProjectHistoryTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ export function ProjectHistoryTimeline({
<p className="project-history-counts">
{projectHistoryText(locale, "summaryCounts", {
events: projection.event_count,
connected: projection.connected_post_count,
lineages: projection.lineage_count,
actors: actorCount,
})}
</p>
Expand Down
12 changes: 7 additions & 5 deletions frontend/src/projectHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ export interface ProjectHistoryProjection {
knowledge_cutoff?: string;
evidence_boundary_code?: "authorized_visible_source_posts" | string;
event_count: number;
connected_post_count: number;
lineage_count: number;
distinct_actor_count?: number;
distinct_observed_actor_count: number;
truncated: boolean;
Expand Down Expand Up @@ -253,7 +255,7 @@ const EN: Record<ProjectHistoryMessageKey, string> = {
openProject: "Open project: {name}",
evidenceBoundary: "Only source posts that pass the current permission, visibility, publication, and cutoff gates are included.",
heading: "Project event timeline",
summaryCounts: "{events} events · {actors} actors in evidence",
summaryCounts: "{events} shown project posts · {connected} connected · lineage count {lineages} · {actors} actors in evidence",
sourcePostTime: "Dates use source-post creation time because a separate event clock is not recorded.",
documentTime: "Dates use the document time recorded by the source.",
truncated: "This bounded timeline is truncated. The selected event remains included.",
Expand Down Expand Up @@ -304,7 +306,7 @@ const MESSAGES: Record<Locale, Record<ProjectHistoryMessageKey, string>> = {
openProject: "프로젝트 열기: {name}",
evidenceBoundary: "현재 권한·공개 범위·게시 상태·기준 시각을 통과한 원천 게시물만 포함합니다.",
heading: "프로젝트 이벤트 타임라인",
summaryCounts: "이벤트 {events} · 근거에 등장한 담당자 {actors}명",
summaryCounts: "표시 중 프로젝트 글 {events} · 연결된 글 {connected}개 · Lineage {lineages}개 · 근거 담당자 {actors}명",
sourcePostTime: "별도 사건 시각이 없어 날짜는 원천 게시물 생성 시각을 사용합니다.",
documentTime: "날짜는 기록된 문서 시각을 사용합니다.",
truncated: "이 제한된 타임라인은 일부만 표시합니다. 선택한 이벤트는 계속 포함됩니다.",
Expand Down Expand Up @@ -352,7 +354,7 @@ const MESSAGES: Record<Locale, Record<ProjectHistoryMessageKey, string>> = {
openProject: "打开项目:{name}",
evidenceBoundary: "仅包含通过当前权限、可见性、发布状态和截止时间检查的源帖子。",
heading: "项目事件时间线",
summaryCounts: "{events} 个事件 · 依据中出现 {actors} 名责任人",
summaryCounts: "当前显示 {events} 条项目记录 · {connected} 条已连接 · {lineages} 条 Lineage · 依据中出现 {actors} 名责任人",
sourcePostTime: "未记录独立事件时钟,因此日期采用源帖子创建时间。",
documentTime: "日期采用记录的文档时间。",
truncated: "此有界时间线已截断,但所选事件仍保留。",
Expand Down Expand Up @@ -400,7 +402,7 @@ const MESSAGES: Record<Locale, Record<ProjectHistoryMessageKey, string>> = {
openProject: "プロジェクトを開く: {name}",
evidenceBoundary: "現在の権限・可視性・公開状態・基準時刻を通過した原資料だけを含みます。",
heading: "プロジェクトイベントのタイムライン",
summaryCounts: "イベント {events}件 · 根拠内の担当者 {actors}名",
summaryCounts: "表示中のプロジェクト投稿 {events}件 · 接続済み {connected}件 · Lineage {lineages}件 · 根拠内の担当者 {actors}名",
sourcePostTime: "独立したイベント時刻がないため、原資料の作成時刻を使用します。",
documentTime: "日付は記録された文書時刻を使用します。",
truncated: "この上限付きタイムラインは省略されていますが、選択イベントは保持されます。",
Expand Down Expand Up @@ -448,7 +450,7 @@ const MESSAGES: Record<Locale, Record<ProjectHistoryMessageKey, string>> = {
openProject: "Mở dự án: {name}",
evidenceBoundary: "Chỉ bao gồm bài nguồn vượt qua quyền, khả năng hiển thị, trạng thái xuất bản và mốc thời gian hiện tại.",
heading: "Dòng thời gian sự kiện dự án",
summaryCounts: "{events} sự kiện · {actors} người xuất hiện trong bằng chứng",
summaryCounts: "Đang hiển thị {events} bài dự án · {connected} bài đã kết nối · {lineages} lineage · {actors} người trong bằng chứng",
sourcePostTime: "Không có đồng hồ sự kiện riêng, nên dùng thời gian tạo bài nguồn.",
documentTime: "Ngày sử dụng thời gian tài liệu được ghi nhận.",
truncated: "Dòng thời gian có giới hạn này đã bị rút gọn nhưng vẫn giữ sự kiện đang chọn.",
Expand Down
32 changes: 32 additions & 0 deletions lineageweave/project_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,35 @@ def _prior_paths(
return result


def _lineage_counts(
ordered_event_ids: Sequence[str], edge_rows: Sequence[Mapping[str, Any]]
) -> tuple[int, int]:
"""Count posts and components connected by distinct forward edges."""

event_index = {event_id: index for index, event_id in enumerate(ordered_event_ids)}
edges = {
(str(row["parent_post_id"]), str(row["child_post_id"]))
for row in edge_rows
if str(row["parent_post_id"]) in event_index
and str(row["child_post_id"]) in event_index
and event_index[str(row["parent_post_id"])] < event_index[str(row["child_post_id"])]
}
adjacency: dict[str, set[str]] = {}
for parent, child in edges:
adjacency.setdefault(parent, set()).add(child)
adjacency.setdefault(child, set()).add(parent)
remaining = set(adjacency)
lineage_count = 0
while remaining:
lineage_count += 1
stack = [remaining.pop()]
while stack:
neighbors = remaining.intersection(adjacency[stack.pop()])
remaining.difference_update(neighbors)
stack.extend(neighbors)
return len(adjacency), lineage_count
Comment thread
seonghobae marked this conversation as resolved.


def build_project_history_projection(
*,
project_key: str,
Expand Down Expand Up @@ -425,6 +454,7 @@ def build_project_history_projection(
maximum_depth=maximum_depth,
maximum_paths_per_event=maximum_paths_per_event,
)
connected_post_count, lineage_count = _lineage_counts(ordered_ids, edge_rows)

events: list[dict[str, Any]] = []
previous_actor_keys: Sequence[str] | None = None
Expand Down Expand Up @@ -484,6 +514,8 @@ def build_project_history_projection(
"focus_event_id": effective_focus,
"time_basis_code": PROJECT_HISTORY_TIME_BASIS,
"event_count": len(events),
"connected_post_count": connected_post_count,
"lineage_count": lineage_count,
"distinct_actor_count": len(distinct_actor_keys),
"distinct_observed_actor_count": len(distinct_observed_actor_keys),
"truncated": bool(truncated),
Expand Down
50 changes: 50 additions & 0 deletions tests/test_project_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,56 @@ def test_project_history_rejects_nonfinite_lineage_scores(invalid_score: float)
)


def test_project_history_counts_connected_posts_and_distinct_lineages() -> None:
"""Only forward edges form connected-post and lineage-component counts."""

events = []
for ordinal in range(1, 7):
event = _event_row(f"00000000-0000-0000-0000-{ordinal:012d}")
event["created_at"] = datetime(2022, 3, 10 + ordinal, 9, tzinfo=timezone.utc)
events.append(event)
edges = [
{
"parent_post_id": events[0]["post_id"],
"child_post_id": events[1]["post_id"],
"fused_score": 0.9,
},
{
"parent_post_id": events[1]["post_id"],
"child_post_id": events[2]["post_id"],
"fused_score": 0.8,
},
{
"parent_post_id": events[3]["post_id"],
"child_post_id": events[4]["post_id"],
"fused_score": 0.7,
},
{
"parent_post_id": events[1]["post_id"],
"child_post_id": events[2]["post_id"],
"fused_score": 0.8,
},
{
"parent_post_id": events[5]["post_id"],
"child_post_id": events[0]["post_id"],
"fused_score": 0.6,
},
]

projection = build_project_history_projection(
project_key="P-100",
focus_event_id=None,
event_rows=events,
match_rows=[],
role_rows=[],
edge_rows=edges,
)

assert projection["event_count"] == 6
assert projection["connected_post_count"] == 5
assert projection["lineage_count"] == 2


def test_matching_observed_project_code_keeps_its_distinct_display_name() -> None:
"""A matching code may carry a human display name that is not itself the key."""

Expand Down