norm developer guide¶
How to extend and maintain the reactive normalizer.
Design goals¶
- Nothing more, nothing less — a handler builds only the nodes/edges its span shape directly implies. No inference, no placeholder/"unknown" fabrication, no reconstructing information the span doesn't carry.
- Ontology-first output — Pydantic models generated from
scope-ontologyTTL, validated withextra="forbid"so an undeclared field is a hard error, not a silently-dropped one. - Best-effort post-processing stays separate and idempotent — anything
that needs signals the SDK doesn't yet provide (call ordering, agent
handoffs) lives in
heuristics/, runs after every handler, and must be safe to re-run over a superset of the same spans without creating duplicates.
Architecture¶
spans (raw ClickHouse-shaped dicts)
-> build_kg: dispatch each span to a handler by SpanName
-> link_agent_handoffs / chain_capability_calls / assign_container_boundary_states
-> (nodes, edges) as plain dicts
See normalization.md for the full dispatch table.
Adding a span handler¶
- Add
handlers/my_shape.pywithhandle_my_shape(span, span_attrs, registry). - Read every value straight off
span_attrs(alreadyattrs(span)) or the rawspandict (SpanId/ParentSpanId/Timestamp/Duration) — seefields.pyfor the shared derivations (get_session_id,get_agent_id,get_start_time,get_duration_ms, ...). - Build ontology nodes/edges via
registry.add/add_if_not_exists/upsert/add_edge— never construct a node with a field the ontology doesn't declare (extra="forbid"will raisepydantic.ValidationErrorimmediately if you do). - If the span carries its own input/output content, call
trajectory.add_state_pair_transitionto give it an initial/finalState Transition— everyExecutionElementneeds one.- Wire it into
build.py's dispatch (_HANDLERSdict for an exactSpanName, or anelif span_name.endswith(...)branch for a suffix). - If a required attribute is missing, return early and build nothing — do not guess or fabricate a placeholder.
- Add a test.
Adding a post-processing heuristic¶
Only add one when there's a genuine signal gap the SDK doesn't cover yet (e.g. no way to order sibling calls). Before writing one:
- Confirm no real signal already exists — check span attributes and
ordering fields empirically against a real trace first (see
capability_chain.py's docstring for the kind of investigation that ruled outSpanId/ParentSpanId/gen_ai.task.parent.idordering before falling back tostartTime). - Prefer reusing
trajectory.bridge_states_if_mismatched(bridge two existing States with a syntheticProcessingCallif their content differs) ortrajectory.adopt_boundary_states(share an existing State directly, for a pure container with no content of its own) over inventing a new synthesis primitive. - Make it idempotent — running it again over the same (or a superset of
the same) registry state must not create duplicates or reassign existing
edges.
bridge_states_if_mismatched's deterministic hashed ids give you this for free if you build on it. - Never remove or reassign an existing node's own edges to "fix" a mismatch — bridge with a new synthetic node between them instead. Replacing an existing State's ownership silently discards information a future signal might need.
- Wire it into
build.py, after every handler has run, in its real dependency order (e.g.assign_container_boundary_statesmust run last, since it reads AgentCalls' own boundary State edges in their final, possibly-bridged form). - Add a test against real fixture data, not just synthetic spans — check the actual node/edge counts and identities, not just that something didn't crash.
Picking up new ontology classes¶
Model generation lives in scope-ontology itself
(scripts/generate_models.py there), not in norm. When scope-ontology
releases new classes:
Handlers wire new ontology classes explicitly; verifier.py's
_OntologyIndex picks up new node/edge types and domain/range declarations
automatically since it derives everything from the ontology TTL at import
time.
Testing¶
| Test | Purpose |
|---|---|
test_validate_trace.py |
End-to-end: load -> normalize -> verify (mirrors the README quickstart), against data/otel_traces_export.jsonl |
test_normalizer.py |
normalize()/Normalizer orchestration |
test_pipeline_functional.py |
Full-pipeline regression guard against tests/fixtures/noa_trip_planner.otel_export.jsonl |
test_otel_io.py |
load_otel_export/infer_run_id |
test_compare.py |
KG regression diff (compare_kg) |
test_verifier.py |
Structural invariant checks, one per check_* function |
test_api_boundaries.py |
Guards that deleted internal modules (e.g. norm.completeness) stay gone |
Prefer fixture-based tests over synthetic-span mocks where the assertion
depends on real trace shape (span-name mix, timing, real attribute
combinations) — a hand-built span can accidentally assert something that's
only true because the test author, not ioa_observe, shaped it that way.