Skip to content

feat(orchestrator): pipeline profiling as a queryable subgraph - #476

Open
Disentinel wants to merge 3 commits into
feat/self-analyze-hardeningfrom
feat/pipeline-profile-graph
Open

feat(orchestrator): pipeline profiling as a queryable subgraph#476
Disentinel wants to merge 3 commits into
feat/self-analyze-hardeningfrom
feat/pipeline-profile-graph

Conversation

@Disentinel

Copy link
Copy Markdown
Owner

What

Emit the grafema analyze pipeline as a profile subgraph so the critical path + bottlenecks are a GRAPH QUERY, not log-grepping. Closes the derive-pack blind spot — derive packs were previously visible only as a Rule pack materialized pack=X ms=N edges=M log line; now each is a graph node.

Schema (profile: namespace, synthetic file __grafema_profile/<run_ts>)

Node type one per attrs
profile:run analyze ts, total_ms
profile:phase phase (resolve/derive/…) phase
profile:stage resolver-cmd / derive-pack wall_ms, edges_produced, nodes_produced, kind, phase, order
METRIC one measure value, unitreuses the existing METRIC node + OBSERVES edge (same as per-file parse_ms)

Edges: profile:phase --PART_OF--> profile:run; profile:stage --PART_OF--> profile:phase; profile:stage --PRECEDES--> profile:stage (the route); METRIC --OBSERVES--> stage/phase.

On by default (cheap — a few hundred nodes). Disable with GRAFEMA_PROFILE_SUBGRAPH=0. Profile nodes are profile:-namespaced and committed after the analyze summary counts, so code queries and existing count assertions are unaffected.

Implementation

  • profiler.rs — retain events in memory (ProfileEvent) + iso_now().
  • analyzer.rsprofile_subgraph_to_wire() builds the WireNodes/edges (+ unit tests).
  • main.rsbuild_profile_stages() from the event stream; commit the subgraph at end of analyze via commit_batch.
  • scripts/profile-graph.mjs — critical path (longest PRECEDES chain by summed wall_ms) + jams + dead stages, read from the graph.
  • _ai/profile-subgraph.md + SKILL.md — schema + documented Datalog queries + the engine binding semantics.

Query (real output from a self-analyze on grafema-dev)

716928 nodes / 1532179 edges, 0 SIGSEGV, 46 stages.

--- critical path (longest PRECEDES chain by summed wall_ms) --- phase=derive sum=242.7s
   36.8s @stdlib/js_local_refs   edges=230
   22.8s @stdlib/rust_calls      edges=2404
   18.3s @stdlib/haskell_local_refs edges=89628
   12.8s @stdlib/js_runtime_globals_edges edges=15243
   ...
--- top jams ---
   36.8s [derive] @stdlib/js_local_refs   25.4s [resolve] js_resolve   22.8s [derive] @stdlib/rust_calls
--- dead stages (wall_ms>=1s AND edges=0) — the REG-1128 class ---
   5.8s @stdlib/shape_verifier   3.5s @stdlib/rust_imports   3.3s @stdlib/js_property_access_ns
   3.0s @stdlib/java_imports   2.7s @stdlib/go_imports   1.3s @stdlib/go_context

Pure-Datalog dead-stages (CI-gate candidate, verified on the real graph → 14 zero-edge packs):

dead(S, Name) :- node(S, "profile:stage"), attr(S, "edges_produced", "0"), attr(S, "name", Name).

Note: the derive-query engine matches metadata attrs by constant value only — it does not enumerate a metadata value into an unbound var. So numeric measures (wall_ms) are read via getNode in the helper; the edges_produced=0 dead-stage query is pure Datalog.

🤖 Generated with Claude Code

Disentinel and others added 3 commits June 19, 2026 16:12
Emit the analyze pipeline as a profile subgraph so the critical path +
bottlenecks are a GRAPH QUERY, not log-grepping. Closes the derive-pack
blind spot (previously only a "Rule pack materialized" log line).

Schema (profile: namespace, synthetic file __grafema_profile/<run_ts>):
- profile:run   — one per analyze (ts, total_ms)
- profile:phase — resolve/derive/...  PART_OF -> run
- profile:stage — one per resolver-cmd / derive-pack  PART_OF -> phase,
                  PRECEDES -> next stage (the route); attrs wall_ms,
                  edges_produced, nodes_produced, kind, phase, order
- METRIC        — reuses the existing METRIC node + OBSERVES edge
                  (same as per-file parse_ms)

Implementation:
- profiler.rs: retain events in memory (ProfileEvent) + iso_now()
- analyzer.rs: profile_subgraph_to_wire() builds the WireNodes/edges
- main.rs: build_profile_stages() from the event stream; commit the
  subgraph at end of analyze via commit_batch. On by default; disable
  with GRAFEMA_PROFILE_SUBGRAPH=0.
- scripts/profile-graph.mjs: critical path (longest PRECEDES chain by
  summed wall_ms) + jams + dead stages (wall_ms high AND edges=0), read
  from the graph.
- _ai/profile-subgraph.md + SKILL.md: schema + documented Datalog queries.

Profile nodes are profile:-namespaced and excluded from the analyze
node/edge summary counts (computed pre-emission), so code queries and
existing count assertions are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The derive-query engine (executeDatalog) binds top-level node fields and
matches metadata attrs by CONSTANT value, but does not enumerate a
metadata value into an unbound variable. So query stage/run node IDS +
PRECEDES edges via Datalog (rule form, executeDatalog) and read the
numeric measures (wall_ms / edges_produced / phase / kind) off each node
via getNode(), where metadata keys are spread as top-level fields.

Verified end-to-end on grafema-dev: critical path + jams + dead stages
print from the committed profile subgraph.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The derive-query engine matches metadata attrs by constant value only;
it does not enumerate a metadata value into an unbound variable. So the
pure-Datalog dead-stages query keys off attr(S,"edges_produced","0")
(constant match), and numeric measures (wall_ms) are read via getNode in
the helper. Document the engine binding semantics + correct the queries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant