diff --git a/README.md b/README.md index d7449f2..c2d2e80 100644 --- a/README.md +++ b/README.md @@ -315,9 +315,11 @@ borrowing a neighbour's name. When one task raises, LangGraph cancels its siblings and records that through the same channel; a cancelled task is reported as an `unset` step with no message, because it was torn down rather than at fault. Only the latest persisted error survives per task, so repeated attempts are not reconstructed, and -`Send` packets are nameable only where `langgraph.types` is importable — an ordinary install discloses them instead. An artifact carrying derived task -steps declares artifact `schema_version` 3, so an older reader refuses it by name; artifacts -without them still serialize as version 2, byte for byte. Missing observed errors still do not +`Send` packets are nameable only where `langgraph.types` is importable — an ordinary install +discloses them instead. An artifact whose derived tree relies on EdgeOrigin precedence declares +artifact `schema_version` 4; otherwise, an artifact carrying derived task steps declares +`schema_version` 3, and artifacts without them still serialize as version 2, byte for byte. +Missing observed errors still do not prove success: an artifact ingested by an older build, or one using an unrecognized checkpoint layout, carries no derived step and looks exactly like a clean run, which is why the capture disclosure is unconditional. Link preservation diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 18fbd19..cc4d5a7 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1,6 +1,66 @@ # Tracegraph handoff -## 현재 전달 상태 — 2026-09-10 (Asia/Seoul) +## 현재 전달 상태 — 2026-09-15 (Asia/Seoul) + +- **문서 작성 시점의 상태:** Phase 1은 기본 브랜치에 병합·푸시 완료. Phase 2.1(TREE_PARENT Origin 우선순위)은 로컬 구현·검증 완료이며, 아래 기록은 커밋 전 작업 트리를 기준으로 한다. +- Phase 2.1 전달 대상은 소스 3개·테스트 1개·문서 2개, 총 6개 파일이다. 이 기록 시점에는 미커밋·미푸시이고 PR·해당 변경의 원격 CI는 대기 상태다. 이후 착지 결과는 이 문서를 포함한 Git 이력과 PR의 최종 SHA로 확인한다. +- 기준 커밋: `8f6c3fa` (Merge feat/phoenix-utc-and-incident-memo), 브랜치 `feat/tree-parent-origin-preference`. +- 이번 범위: + 1. **Phoenix naive timestamp UTC 정규화** (`src/tracegraph/adapters/phoenix_export.py`): + - timezone-naive datetime을 UTC로 명시 해석하여 `TZ` 환경(로컬 `Asia/Seoul` vs UTC CI)에 따른 `artifact_digest` 불일치를 원천 차단. 기존 `Z` 접미사 타임스탬프는 100% 바이트 불변. + 2. **memtomem LTM incident memo export** (`src/tracegraph/analysis/memo.py`, CLI `export-incident-memo`): + - 엄격한 allowlist 적용: 원시 `run_id` 대신 결정론적 해시 별칭 `run_digest`(`sha256:...[:16]`), 원시 step/UUID 대신 시퀀스 기반 별칭(`step__`), `error_msg`/평가 레이블/페이로드 원천 배제(비밀·토큰 누출 방지). + - 아티팩트 다이제스트 정규식 검증(`^sha256:[0-9a-f]{64}$`)으로 YAML 주입 방지 및 등록된 프리셋 패턴 한정 출력. + - 진실한 인과 표현: containment(`span_parent_fallback`) 및 미확인(`legacy_unknown`/None) 에지는 인과 전파 증거가 아니므로 `caused by`가 아닌 `preceded by (containment only)` 등으로 한정 공시. + - 선형 스케일링: 오류 단계별 선조 중복 순회를 배제하고 단일 `### Causal Ancestry Graph`에 $O(V+E)$로 출력. `analyze()`의 2차 다중 primary failure 탐색을 우회하고 `validate_structure()` 및 `_patterns()`를 직접 호출. + - 원자적 파일 저장: `NamedTemporaryFile` 진입 즉시 파일명을 확보하여 쓰기/fsync 실패 시 임시 파일 누수 없이 안전 정리. + 3. **`TREE_PARENT` EdgeOrigin 우선순위 및 스키마 v4 도입** (`src/tracegraph/normalize.py`, `src/tracegraph/artifact.py`, `src/tracegraph/store/ladybug.py`): + - 단일 부모 projection 시 structural execution parent(`GRAPH_PARENT`, `CHECKPOINT_PARENT`: 우선순위 0)가 containment(`SPAN_PARENT_FALLBACK`: 1), cross-reference(`SPAN_LINK`: 2), legacy(`LEGACY_UNKNOWN`/None: 3)보다 우선하도록 변경. 동일 순위 내에서는 기존의 `(seq, ts, step_id)` 시간순 tie-breaker 유지. + - 하위 호환성 canonical 검증: `validate_normalized()`가 현재의 origin 우선순위 projection뿐 아니라 이전 구현의 temporal-only projection으로 생성된 레거시 아티팩트도 canonical 형태로 인정하여 안전하게 로드. + - `LadybugStore` 바이트 라운드트립 보존: 레거시 아티팩트 로드 시 `_legacy_projection` 모드를 기억하여 `trace()` 및 `export_artifact()`가 원본 레거시 아티팩트와 바이트 단위로 동일하게 재생성. + - 아티팩트 `schema_version: 4` 조건부 스탬핑: EdgeOrigin 우선순위에 의해 트리가 변경되는 아티팩트만 `schema_version: 4`로 스탬핑하여 구버전 판독기(1~3 지원)가 명확한 스키마 불일치로 거부하도록 보장. 기존 아티팩트 및 영향 없는 아티팩트는 2 또는 3을 유지하여 기존 골든 다이제스트 바이트 불변성 보장. + +### 검증 증거 (2026-09-15) + +환경: macOS/arm64, Python 3.12.11. + +| 검증 | 명령 | 결과 | +| --- | --- | --- | +| 전체 테스트 | `uv run --no-sync pytest -q` | **620 passed** in 9.99s (이 세션에서 재검증) | +| 성능 guard | `uv run --no-sync pytest -q -m perf` | **4 passed, 616 deselected** in 2.48s | +| Codex 코드 리뷰 | `codex-20260915-164249-65907` | **SHIP** (Major 0, Blocker 0, Nit 1). 소스·테스트 4개 파일 대상이며 테스트는 실행하지 않은 리뷰. README 지적은 후속 반영했고, 이후 작성한 handoff는 이 리뷰 범위 밖이다. | +| lint | `uvx ruff@0.14.2 check src tests scripts examples` | **All checks passed** | +| diff 검증 | `git diff --check` | **Clean** (공백/줄바꿈 결함 없음) | +| 크로스 버전 호환성 | `test_cross_version_schema_signaling_for_origin_priority_and_legacy` | **PASS** (schema 4 스탬핑, 구버전 판독기 거부, 레거시 호환 로드 검증) | +| 레거시 라운드트립 | `test_legacy_temporal_artifact_loads_validates_and_roundtrips` | **PASS** (InMemoryStore 및 LadybugStore 바이트 다이제스트 불변 검증) | + +추가 호환성 확인: 기준 HEAD `8f6c3fa`의 실제 `normalize.py`·`artifact.py`를 별도로 로드해 +v2/v3 아티팩트를 생성했다. 현재 판독·canonical 검증·Ladybug 재직렬화의 바이트 보존과 +기준 HEAD 판독기의 신규 v4 거부를 모두 확인했다. 위 로컬 검증 후에는 문서만 정리했다. +리뷰의 `.final.md`, `.review.json`, `.manifest.json`은 모두 같은 invocation의 `SHIP`으로 일치했다. + +원격·실서버 증거는 별도다: + +- Phase 1 `8f6c3fa`의 [원격 tests 실행](https://github.com/memtomem/tracegraph/actions/runs/34901110223)은 **성공**했다. Phase 2.1 변경의 CI 증거는 아니다. +- Phase 2.1의 원격 CI와 설치 wheel 검증은 이 기록 시점에 **미실행**이다. PR의 정확한 최종 head에서 확인해야 한다. +- 이번 변경의 실제 Phoenix + SyncMill E2E는 **미실행**이다. [이전 성공 실행](https://github.com/memtomem/tracegraph/actions/runs/34825105597)은 `20d159b` 기준이다. + +### 남은 결정 필요 항목 (Tier 2 — 공개 계약 변경) + +1. ~~**Phoenix naive ISO의 UTC 해석**~~ — **해결됨 (2026-09-14, `130cb20` / `8f6c3fa`)**: naive datetime을 UTC로 명시 정규화 (`parsed.replace(tzinfo=timezone.utc)`). +2. **TREE_PARENT 선택** — **로컬 구현·검증 완료, 착지 대기 (2026-09-15, 브랜치 `feat/tree-parent-origin-preference`)**: EdgeOrigin 우선순위(GRAPH/CHECKPOINT > SPAN_PARENT_FALLBACK > SPAN_LINK > LEGACY) 도입, 레거시 canonical 검증 호환, LadybugStore 라운드트립 보존, 아티팩트 `schema_version: 4` 조건부 스탬핑. +3. **privacy 기본값**: OTLP `include_error_messages`의 기본값을 `False`로 바꿀지, `--redact-errors` 옵션을 추가할지. README의 범위 설명 정정은 이전 작업에서 완료했다. +4. **review-candidates 스키마**: candidate 수준이 `additionalProperties: true`라 body 필드가 v1으로 검증된다. +5. **`phoenix` 종료 코드**: 같은 환경 실패에 `doctor`는 1, `diagnose`는 2를 쓴다. +6. ~~**LICENSE 부재**~~ — **결정됨(2026-09-12, PR #23)**: Apache-2.0 + DAPADA CLA. 배포명은 `agent-tracegraph`. +7. **Trace-level `status: unset` for incomplete runs**: `interrupt()`나 recursion limit으로 중단된 미완료 실행에 대한 3-way 상태 도입 (아티팩트 스키마 및 마이그레이션 필요). +8. **Phoenix 숫자 타임스탬프**: 숫자 입력을 나노초로 가정하며 하한 검증이 없다. naive ISO의 UTC 정규화와 별개의 미해결 계약 항목이다. + +--- + +## 이전 전달 상태 — 2026-09-10 (Asia/Seoul) + +아래 본문은 당시 검증·판단 기록이다. 현재 상태와 미해결 목록은 문서 상단을 따른다. - **상태: 착지 완료.** [PR #20](https://github.com/memtomem/tracegraph/pull/20)이 병합 커밋 `6da0886`으로 기본 브랜치 `tracegraph-mvp`에 병합됐다 (2026-09-10 02:55 UTC). 기준은 `0d5091c`, @@ -33,7 +93,7 @@ | `store/ladybug.py` | 호출자의 `Trace`를 참조로 보관해 `InMemoryStore`와 갈렸다. deep-copy로 맞췄다. `COMMIT`을 `try` 안으로 옮기고 `QueryResult`를 닫는다. | | `normalize.py` | TREE_PARENT cycle 오류 메시지가 hash 순서에 의존했다. 정규 순서로 고정했다. | -### 검증 증거 +### 당시 검증 증거 환경: macOS/arm64, Python 3.12.11. @@ -82,7 +142,7 @@ diff와 무관하다** — 로그가 도구 실행 전에 끝나는 것이 판 배포명은 `agent-tracegraph`이고 import 패키지·CLI·스키마 `kind` 상수는 `tracegraph` 그대로다. -## 현재 전달 상태 — 2026-09-08 (Asia/Seoul) +## 이전 전달 상태 — 2026-09-08 (Asia/Seoul) - 기준 HEAD: `d048f400fdd39bb37c9608e45ee5931cba058b46`, 브랜치 `tracegraph-mvp`. - 아래 결과는 이 HEAD 위의 전달 변경을 **커밋 전에** 검증한 기록이다. @@ -90,7 +150,7 @@ diff와 무관하다** — 로그가 도구 실행 전에 끝나는 것이 판 - 이번 범위: CI import 복구, 설치된 wheel 검증 자동화, 문서 상태 연결. - 제품 소스·의존성·lockfile·fixture·공개 데이터 계약은 변경하지 않았다. -## 해결한 문제와 변경 +### 당시 해결한 문제와 변경 2026-09-08의 [기준 HEAD CI](https://github.com/memtomem/tracegraph/actions/runs/34198950320)는 core `389 passed, 2 failed`, Cypher `430 passed, 2 failed`였다. 실패한 두 테스트는 @@ -110,7 +170,7 @@ core `389 passed, 2 failed`, Cypher `430 passed, 2 failed`였다. 실패한 두 candidate golden의 exact bytes/digest, report privacy, Cypher 설명·매치 및 artifact round-trip을 검증한다. 이 검증기는 설치 환경에 개발 의존성을 요구하지 않는다. 공개 JSON schema는 계속 저장소-vendored 계약이다. -## 검증 증거 +### 당시 검증 증거 환경: macOS/arm64, Python 3.12.11. 아래 결과는 위 기준 HEAD와 이번 작업 트리 변경의 조합이다. @@ -138,7 +198,7 @@ uv 캐시 접근 역시 권한 확장 후 검증했다. 이를 제품 실패나 실제 서버 E2E를 실행하면 Tracegraph·SyncMill SHA, Phoenix image digest, px pin도 함께 기록한다. 절차는 [E2E runbook](phoenix-syncmill-e2e.md)과 [runtime pin runbook](phoenix-cli-contract.md)을 따른다. -### Wheel 검증 재실행 +#### 당시 Wheel 검증 재실행 Linux/macOS에서 저장소 루트의 Bash로 실행한다. `uv`와 Python 3.12가 필요하다. `WHEEL_EXTRA=cypher`로 바꾸면 선택 backend도 검증한다. 임시 설치 경로는 공유 작업 트리와 분리된다. @@ -216,7 +276,7 @@ PR #22(native 실패 수집)와 PR #23(license·CLA·PyPI 메타데이터)에 유지할 계약: - raw multi-parent `CAUSED_BY`가 정본이며 `TREE_PARENT`는 손실 가능한 비교·표시용 projection이다. -- artifact v1/v2 읽기와 v2 쓰기, analysis report v2, candidate v1을 유지한다. +- 이 변경의 artifact reader는 v1~v4를 읽는다. writer는 origin 우선순위로 트리가 달라지면 v4, 그 외 task step이 있으면 v3, 나머지는 v2를 쓴다. 레거시 v2/v3 canonical 아티팩트의 재직렬화 바이트를 보존하며, analysis report v2와 candidate v1은 유지한다. report schema는 추가 필드를 거부하므로 관측 필드 추가 전에 버전·reader 호환성을 설계한다. - 자동 retry는 명시적 producer marker만 사용한다. heuristic은 governance export에 사용하지 않는다. - `safe-v1`은 구조 식별자를 유지하는 본문 제거 정책이며 익명화가 아니다. diff --git a/src/tracegraph/artifact.py b/src/tracegraph/artifact.py index ae4940c..e5762b4 100644 --- a/src/tracegraph/artifact.py +++ b/src/tracegraph/artifact.py @@ -19,11 +19,13 @@ from tracegraph.model import NormalizedTrace, StepSource #: Bump when the on-disk shape changes incompatibly. -ARTIFACT_SCHEMA_VERSION = 3 +ARTIFACT_SCHEMA_VERSION = 4 #: Versions this build can read. 1 needs a migration (see ``_migrate_v1``); 2 -> 3 is the #: identity, because 3 adds only a new ``Step.source`` value and changes nothing existing. -_READABLE_VERSIONS = (1, 2, ARTIFACT_SCHEMA_VERSION) +#: 4 signals that TREE_PARENT was derived under EdgeOrigin precedence rather than +#: legacy temporal-only selection. +_READABLE_VERSIONS = (1, 2, 3, ARTIFACT_SCHEMA_VERSION) def _required_version(nt: NormalizedTrace) -> int: @@ -36,9 +38,17 @@ def _required_version(nt: NormalizedTrace) -> int: bump would invalidate digests already exported in review-candidate reports for artifacts whose content did not change at all. - An artifact that *does* carry a derived task step is stamped 3, so an older build refuses - it with a clear "unsupported artifact schema_version" instead of a pydantic enum error. + * An artifact whose derived TREE_PARENT relies on EdgeOrigin priority is stamped 4, + so older readers (which used temporal-only parent selection) refuse it with an + explicit "unsupported artifact schema_version 4" rather than failing canonical + validation with a confusing mismatch error. + * An artifact that carries a derived task step is stamped 3. + * Otherwise, stamped 2. """ + from tracegraph.normalize import requires_origin_priority_projection + + if requires_origin_priority_projection(nt): + return 4 task = StepSource.TASK return 3 if any(step.source is task for step in nt.steps) else 2 diff --git a/src/tracegraph/normalize.py b/src/tracegraph/normalize.py index 7a3dfa0..1b1f65a 100644 --- a/src/tracegraph/normalize.py +++ b/src/tracegraph/normalize.py @@ -6,8 +6,9 @@ * **validates** the raw graph (:func:`validate_raw`) — endpoints exist, edges are ``CAUSED_BY``, no self-edges, and every cause precedes its effect (which also makes the raw graph acyclic by construction), -* derives the ``TREE_PARENT`` layer — exactly one parent per non-root step — by picking - each step's *temporally-earliest* cause, +* derives the ``TREE_PARENT`` layer — exactly one parent per non-root step — by prioritizing + explicit structural graph parents over containment and links, breaking ties by earliest time + (with legacy temporal-only fallback for backward compatibility), * flags ``projection_lossy=True`` on any step whose real causality had to be collapsed (more than one ``CAUSED_BY``), and * re-checks the derived layer is a forest (:func:`validate_tree`). @@ -20,7 +21,15 @@ from __future__ import annotations -from tracegraph.model import Edge, EdgeType, NormalizedTrace, RawTrace, Step, StepStatus +from tracegraph.model import ( + Edge, + EdgeOrigin, + EdgeType, + NormalizedTrace, + RawTrace, + Step, + StepStatus, +) def _check_steps(trace_id: str, steps: list[Step]) -> None: @@ -124,43 +133,76 @@ def validate_normalized(nt: NormalizedTrace) -> None: steps=[s.model_copy(update={"projection_lossy": False}) for s in nt.steps], causal_edges=nt.edges_of(EdgeType.CAUSED_BY), ) - expected = normalize(raw) + expected = normalize(raw, origin_priority=True) if expected != nt: - raise ValueError( - "normalized trace is not in canonical form: derived edges, projection_lossy " - "flags, or step/edge ordering do not match what normalize() produces from " - "the raw CAUSED_BY layer. The JSON artifact must be the output of normalize() " - "verbatim — anything else would drift on the next save." - ) + # Backward compatibility: accept authentic legacy artifacts produced under + # the previous temporal-only parent selection rule. + legacy_expected = normalize(raw, origin_priority=False) + if legacy_expected != nt: + raise ValueError( + "normalized trace is not in canonical form: derived edges, projection_lossy " + "flags, or step/edge ordering do not match what normalize() produces from " + "the raw CAUSED_BY layer. The JSON artifact must be the output of normalize() " + "verbatim — anything else would drift on the next save." + ) -def _parents_by_step(caused_by: list[Edge]) -> dict[str, list[str]]: +_ORIGIN_PRIORITY: dict[EdgeOrigin | None, int] = { + EdgeOrigin.CHECKPOINT_PARENT: 0, + EdgeOrigin.GRAPH_PARENT: 0, + EdgeOrigin.SPAN_PARENT_FALLBACK: 1, + EdgeOrigin.SPAN_LINK: 2, + EdgeOrigin.LEGACY_UNKNOWN: 3, + None: 3, +} + + +def _incoming_edges_by_step(caused_by: list[Edge]) -> dict[str, list[Edge]]: """Raw causes per effect step (CAUSED_BY points effect → cause), built in one pass. Preserves the order of ``caused_by``, so feeding it the canonically sorted edge - list yields each step's parents in canonical order. + list yields each step's incoming causal edges in canonical order. """ - parents: dict[str, list[str]] = {} + incoming: dict[str, list[Edge]] = {} for e in caused_by: - parents.setdefault(e.src, []).append(e.dst) - return parents + incoming.setdefault(e.src, []).append(e) + return incoming -def _primary_parent(parent_ids: list[str], steps: dict[str, Step]) -> str: - """Pick the single TREE_PARENT: the temporally-earliest cause. +def _parents_by_step(caused_by: list[Edge]) -> dict[str, list[str]]: + """Convenience helper returning destination step IDs per effect step.""" + return {src: [e.dst for e in edges] for src, edges in _incoming_edges_by_step(caused_by).items()} - Ordering key is ``(seq, ts, step_id)`` so the choice is deterministic even when - seq or ts collide. The chosen parent is the one that happened first. + +def _primary_parent( + incoming_edges: list[Edge], + steps: dict[str, Step], + *, + origin_priority: bool = True, +) -> str: + """Pick the single TREE_PARENT. + + When ``origin_priority=True`` (default), explicit framework/graph execution parents + (CHECKPOINT_PARENT, GRAPH_PARENT) outrank span containment (SPAN_PARENT_FALLBACK), + which outranks non-hierarchical causal links (SPAN_LINK), which outranks unrecorded/legacy + origins (LEGACY_UNKNOWN, None). + + When ``origin_priority=False`` (legacy compatibility mode), parent choice is purely + temporally-earliest cause. + + In both modes, ties are broken by earliest cause ordered by ``(seq, ts, step_id)``. """ - def key(pid: str) -> tuple[int, str, str]: - p = steps[pid] - return (p.seq, p.ts or "", p.step_id) + def key(edge: Edge) -> tuple[int, int, str, str]: + p = steps[edge.dst] + prio = _ORIGIN_PRIORITY.get(edge.origin, 3) if origin_priority else 0 + return (prio, p.seq, p.ts or "", p.step_id) - return min(parent_ids, key=key) + chosen_edge = min(incoming_edges, key=key) + return chosen_edge.dst -def normalize(raw: RawTrace) -> NormalizedTrace: +def normalize(raw: RawTrace, *, origin_priority: bool = True) -> NormalizedTrace: """Validate a raw trace and return its normalized form (raw + derived layers). Deterministic and **canonically ordered**: the returned trace's steps and edges are @@ -189,17 +231,17 @@ def normalize(raw: RawTrace) -> NormalizedTrace: ) edges: list[Edge] = list(caused_by) # carry the raw layer through unchanged - parents_by_step = _parents_by_step(caused_by) + incoming_by_step = _incoming_edges_by_step(caused_by) new_steps: list[Step] = [] for step in steps_canonical: - parents = parents_by_step.get(step.step_id, []) + incoming = incoming_by_step.get(step.step_id, []) # Flag lossiness on a copy so we never mutate the caller's objects. - step = step.model_copy(update={"projection_lossy": len(parents) > 1}) + step = step.model_copy(update={"projection_lossy": len(incoming) > 1}) new_steps.append(step) edges.append(Edge(type=EdgeType.BELONGS_TO, src=step.step_id, dst=raw.trace.trace_id)) - if parents: - chosen = _primary_parent(parents, steps_by_id) + if incoming: + chosen = _primary_parent(incoming, steps_by_id, origin_priority=origin_priority) edges.append(Edge(type=EdgeType.TREE_PARENT, src=step.step_id, dst=chosen)) # The trace-level status is a derived view of its steps, not independent data: make it @@ -246,3 +288,18 @@ def validate_tree(nt: NormalizedTrace) -> None: on_path.add(cur) cur = parent_of.get(cur) proven.update(path) + + +def requires_origin_priority_projection(nt: NormalizedTrace) -> bool: + """True iff this trace's TREE_PARENT projection differs from legacy temporal-only selection.""" + try: + raw = RawTrace( + trace=nt.trace, + steps=[s.model_copy(update={"projection_lossy": False}) for s in nt.steps], + causal_edges=nt.edges_of(EdgeType.CAUSED_BY), + ) + return nt != normalize(raw, origin_priority=False) + except ValueError: + # If the trace is malformed, do not fail during serialization stamping. + # Store loading and validate_normalized remain the validation gate. + return False diff --git a/src/tracegraph/store/ladybug.py b/src/tracegraph/store/ladybug.py index c8d171d..08a4359 100644 --- a/src/tracegraph/store/ladybug.py +++ b/src/tracegraph/store/ladybug.py @@ -161,6 +161,14 @@ def from_trace(cls, nt: NormalizedTrace, *, path: str | Path | None = None) -> " store = cls(path=path) try: store.init_schema() + # Preserve whether the artifact was loaded under legacy temporal-only projection + # so trace() and export_artifact() round-trip byte-for-byte. + raw_check = RawTrace( + trace=nt.trace, + steps=[s.model_copy(update={"projection_lossy": False}) for s in nt.steps], + causal_edges=nt.edges_of(EdgeType.CAUSED_BY), + ) + store._legacy_projection = (nt != normalize(raw_check, origin_priority=True)) # Deep-copy the header, matching InMemoryStore.from_trace. Holding the caller's # object by reference let a later mutation of the input change what trace() # returns while the persisted Trace row still held the original values, so the @@ -311,7 +319,8 @@ def trace(self) -> NormalizedTrace: for src, dst, origin in caused_by_pairs ] self._trace_cache = normalize( - RawTrace(trace=self._trace, steps=raw_steps, causal_edges=raw_edges) + RawTrace(trace=self._trace, steps=raw_steps, causal_edges=raw_edges), + origin_priority=not getattr(self, "_legacy_projection", False), ) return self._trace_cache.model_copy(deep=True) diff --git a/tests/test_normalize_lossy.py b/tests/test_normalize_lossy.py index 364e0fb..2cac329 100644 --- a/tests/test_normalize_lossy.py +++ b/tests/test_normalize_lossy.py @@ -17,6 +17,7 @@ from tracegraph.analysis import explain from tracegraph.model import ( Edge, + EdgeOrigin, EdgeType, RawTrace, Step, @@ -99,3 +100,212 @@ def test_validate_tree_rejects_multi_parent(): nt.edges.append(Edge(type=EdgeType.TREE_PARENT, src="d", dst="c")) with pytest.raises(ValueError, match="more than one TREE_PARENT"): validate_tree(nt) + + +def test_origin_priority_graph_parent_over_span_link_regardless_of_seq(): + # b happened earlier (seq 1), but is only a SPAN_LINK. + # c happened later (seq 2), but is the explicit GRAPH_PARENT. + # TREE_PARENT must pick c (origin prio 0 over 2), not b. + raw = RawTrace( + trace=Trace(trace_id="t_prio", source_kind="test"), + steps=[ + Step(step_id="a", trace_id="t_prio", seq=0, kind=StepKind.CHAIN), + Step(step_id="b", trace_id="t_prio", seq=1, kind=StepKind.TOOL), + Step(step_id="c", trace_id="t_prio", seq=2, kind=StepKind.TOOL), + Step(step_id="d", trace_id="t_prio", seq=3, kind=StepKind.CHAIN), + ], + causal_edges=[ + Edge(type=EdgeType.CAUSED_BY, src="b", dst="a", origin=EdgeOrigin.GRAPH_PARENT), + Edge(type=EdgeType.CAUSED_BY, src="c", dst="a", origin=EdgeOrigin.GRAPH_PARENT), + Edge(type=EdgeType.CAUSED_BY, src="d", dst="b", origin=EdgeOrigin.SPAN_LINK), + Edge(type=EdgeType.CAUSED_BY, src="d", dst="c", origin=EdgeOrigin.GRAPH_PARENT), + ], + ) + nt = normalize(raw) + d_parents = [e.dst for e in nt.edges_of(EdgeType.TREE_PARENT) if e.src == "d"] + assert d_parents == ["c"] + + +def test_origin_priority_checkpoint_parent_over_span_parent_fallback(): + # b is SPAN_PARENT_FALLBACK (containment, seq 1). + # c is CHECKPOINT_PARENT (task parent, seq 2). + # CHECKPOINT_PARENT (0) must outrank SPAN_PARENT_FALLBACK (1). + raw = RawTrace( + trace=Trace(trace_id="t_prio2", source_kind="test"), + steps=[ + Step(step_id="b", trace_id="t_prio2", seq=1, kind=StepKind.TOOL), + Step(step_id="c", trace_id="t_prio2", seq=2, kind=StepKind.TOOL), + Step(step_id="d", trace_id="t_prio2", seq=3, kind=StepKind.CHAIN), + ], + causal_edges=[ + Edge(type=EdgeType.CAUSED_BY, src="d", dst="b", origin=EdgeOrigin.SPAN_PARENT_FALLBACK), + Edge(type=EdgeType.CAUSED_BY, src="d", dst="c", origin=EdgeOrigin.CHECKPOINT_PARENT), + ], + ) + nt = normalize(raw) + d_parents = [e.dst for e in nt.edges_of(EdgeType.TREE_PARENT) if e.src == "d"] + assert d_parents == ["c"] + + +def test_origin_priority_span_parent_fallback_over_span_link(): + # b is SPAN_LINK (cross-reference, seq 1). + # c is SPAN_PARENT_FALLBACK (containment parent, seq 2). + # SPAN_PARENT_FALLBACK (1) must outrank SPAN_LINK (2). + raw = RawTrace( + trace=Trace(trace_id="t_prio3", source_kind="test"), + steps=[ + Step(step_id="b", trace_id="t_prio3", seq=1, kind=StepKind.TOOL), + Step(step_id="c", trace_id="t_prio3", seq=2, kind=StepKind.TOOL), + Step(step_id="d", trace_id="t_prio3", seq=3, kind=StepKind.CHAIN), + ], + causal_edges=[ + Edge(type=EdgeType.CAUSED_BY, src="d", dst="b", origin=EdgeOrigin.SPAN_LINK), + Edge(type=EdgeType.CAUSED_BY, src="d", dst="c", origin=EdgeOrigin.SPAN_PARENT_FALLBACK), + ], + ) + nt = normalize(raw) + d_parents = [e.dst for e in nt.edges_of(EdgeType.TREE_PARENT) if e.src == "d"] + assert d_parents == ["c"] + + +def test_origin_priority_span_link_over_legacy_unknown(): + # b is LEGACY_UNKNOWN (seq 1). + # c is SPAN_LINK (seq 2). + # SPAN_LINK (2) must outrank LEGACY_UNKNOWN (3). + raw = RawTrace( + trace=Trace(trace_id="t_prio4", source_kind="test"), + steps=[ + Step(step_id="b", trace_id="t_prio4", seq=1, kind=StepKind.TOOL), + Step(step_id="c", trace_id="t_prio4", seq=2, kind=StepKind.TOOL), + Step(step_id="d", trace_id="t_prio4", seq=3, kind=StepKind.CHAIN), + ], + causal_edges=[ + Edge(type=EdgeType.CAUSED_BY, src="d", dst="b", origin=EdgeOrigin.LEGACY_UNKNOWN), + Edge(type=EdgeType.CAUSED_BY, src="d", dst="c", origin=EdgeOrigin.SPAN_LINK), + ], + ) + nt = normalize(raw) + d_parents = [e.dst for e in nt.edges_of(EdgeType.TREE_PARENT) if e.src == "d"] + assert d_parents == ["c"] + + +def test_origin_priority_tie_breaking_by_earliest_seq(): + # Both are tier 0 (GRAPH_PARENT and CHECKPOINT_PARENT). + # Earliest sequence (b @ seq 1 < c @ seq 2) must win the tie-breaker. + raw = RawTrace( + trace=Trace(trace_id="t_prio5", source_kind="test"), + steps=[ + Step(step_id="b", trace_id="t_prio5", seq=1, kind=StepKind.TOOL), + Step(step_id="c", trace_id="t_prio5", seq=2, kind=StepKind.TOOL), + Step(step_id="d", trace_id="t_prio5", seq=3, kind=StepKind.CHAIN), + ], + causal_edges=[ + Edge(type=EdgeType.CAUSED_BY, src="d", dst="b", origin=EdgeOrigin.GRAPH_PARENT), + Edge(type=EdgeType.CAUSED_BY, src="d", dst="c", origin=EdgeOrigin.CHECKPOINT_PARENT), + ], + ) + nt = normalize(raw) + d_parents = [e.dst for e in nt.edges_of(EdgeType.TREE_PARENT) if e.src == "d"] + assert d_parents == ["b"] + + +def test_legacy_temporal_artifact_loads_validates_and_roundtrips(): + import hashlib + import pytest + from tracegraph.normalize import validate_normalized + + raw = RawTrace( + trace=Trace(trace_id="t_legacy", source_kind="test"), + steps=[ + Step(step_id="a", trace_id="t_legacy", seq=0, kind=StepKind.CHAIN), + Step(step_id="b", trace_id="t_legacy", seq=1, kind=StepKind.TOOL), + Step(step_id="c", trace_id="t_legacy", seq=2, kind=StepKind.TOOL), + Step(step_id="d", trace_id="t_legacy", seq=3, kind=StepKind.CHAIN), + ], + causal_edges=[ + Edge(type=EdgeType.CAUSED_BY, src="b", dst="a", origin=EdgeOrigin.GRAPH_PARENT), + Edge(type=EdgeType.CAUSED_BY, src="c", dst="a", origin=EdgeOrigin.GRAPH_PARENT), + Edge(type=EdgeType.CAUSED_BY, src="d", dst="b", origin=EdgeOrigin.SPAN_LINK), + Edge(type=EdgeType.CAUSED_BY, src="d", dst="c", origin=EdgeOrigin.GRAPH_PARENT), + ], + ) + + # 1. Produce artifact under the previous implementation's temporal-only projection + legacy_nt = normalize(raw, origin_priority=False) + assert [e.dst for e in legacy_nt.edges_of(EdgeType.TREE_PARENT) if e.src == "d"] == ["b"] + + # 2. Serialize and hash + text = artifact.dumps(legacy_nt) + original_digest = hashlib.sha256(text.encode("utf-8")).hexdigest() + + # 3. Load from JSON string + loaded_nt = artifact.loads(text) + assert loaded_nt == legacy_nt + + # 4. validate_normalized must accept authentic legacy-projected artifacts + validate_normalized(loaded_nt) + + # 5. InMemoryStore round-trip + in_mem_store = InMemoryStore.from_trace(loaded_nt) + assert in_mem_store.trace() == loaded_nt + + # 6. LadybugStore byte-for-byte digest stability + try: + from tracegraph.store.ladybug import LadybugStore + with LadybugStore.from_trace(loaded_nt) as ladybug_store: + assert ladybug_store.trace() == loaded_nt + reloaded_text = artifact.dumps(ladybug_store.trace()) + assert hashlib.sha256(reloaded_text.encode("utf-8")).hexdigest() == original_digest + except ImportError: + pass + + # 7. Non-canonical tampered artifact must still be rejected + tampered_edges = [ + Edge(type=EdgeType.TREE_PARENT, src="d", dst="a") if e.type is EdgeType.TREE_PARENT and e.src == "d" + else e + for e in loaded_nt.edges + ] + tampered_nt = loaded_nt.model_copy(update={"edges": tampered_edges}) + with pytest.raises(ValueError, match="canonical form"): + validate_normalized(tampered_nt) + + +def test_cross_version_schema_signaling_for_origin_priority_and_legacy(monkeypatch): + import json + import pytest + + raw = RawTrace( + trace=Trace(trace_id="t_versioning", source_kind="test"), + steps=[ + Step(step_id="a", trace_id="t_versioning", seq=0, kind=StepKind.CHAIN), + Step(step_id="b", trace_id="t_versioning", seq=1, kind=StepKind.TOOL), + Step(step_id="c", trace_id="t_versioning", seq=2, kind=StepKind.TOOL), + Step(step_id="d", trace_id="t_versioning", seq=3, kind=StepKind.CHAIN), + ], + causal_edges=[ + Edge(type=EdgeType.CAUSED_BY, src="b", dst="a", origin=EdgeOrigin.GRAPH_PARENT), + Edge(type=EdgeType.CAUSED_BY, src="c", dst="a", origin=EdgeOrigin.GRAPH_PARENT), + Edge(type=EdgeType.CAUSED_BY, src="d", dst="b", origin=EdgeOrigin.SPAN_LINK), + Edge(type=EdgeType.CAUSED_BY, src="d", dst="c", origin=EdgeOrigin.GRAPH_PARENT), + ], + ) + + # A trace where origin priority changes TREE_PARENT must stamp schema_version 4 + origin_nt = normalize(raw, origin_priority=True) + origin_text = artifact.dumps(origin_nt) + assert json.loads(origin_text)["schema_version"] == 4 + + # A legacy-projected trace (or one where origin priority doesn't change TREE_PARENT) stamps 2 + legacy_nt = normalize(raw, origin_priority=False) + legacy_text = artifact.dumps(legacy_nt) + assert json.loads(legacy_text)["schema_version"] == 2 + + # A simulated older reader (supporting only versions 1, 2, 3) must reject the schema 4 + # artifact cleanly at the load boundary with a clear schema_version error + monkeypatch.setattr(artifact, "_READABLE_VERSIONS", (1, 2, 3)) + with pytest.raises(ValueError, match="unsupported artifact schema_version 4"): + artifact.loads(origin_text) + + # And the older reader must continue to load the schema 2 legacy artifact without error + loaded_legacy = artifact.loads(legacy_text) + assert loaded_legacy == legacy_nt