Graphs

Graph computing

Graph computing

Grid graphs are resident, immutable, directed property multigraphs. They are named handles rather than cell values: topology is built once per source revision and reused by every graph query.

Symbol relationships and the model graph

The model itself also has a graph. Every model symbol/cell is a node; ordinary formula references contribute directed :depends edges. You can add a separate, authored relationship layer without wrapping the model in a graph block:

predicate shipment(qty: number)
 
factory = 1200 relates to warehouse via :shipment(12)
warehouse = 800 connects to retailer via :peer
 
frame downstream = MODEL.GRAPH.BFS(:factory)

A relationship clause following an assignment inherits the assignment target as its source. The first declaration above is equivalent to writing factory = 1200 and factory relates to warehouse via :shipment(12) as separate statements. The assigned value is not a graph node and the relation is not an operator on that value. Standalone relationship statements remain available for symbols declared elsewhere or symbols with no value declaration.

One declaration may attach multiple relationships inline or on directly following lines:

sensor = 0
  connects to upstream_sensor via :proximity
  relates to controller via :telemetry

Targets and relation uses may also be comma-delimited. Equal-length lists pair positionally; a singleton on either side broadcasts across the other list:

A1 connects to B1, C1, D1 via :type1, :type2, :type3
A2 connects to B2, C2, D2 via :proximity
A3 relates to B3 via :primary, :fallback

These declarations lower to independent relationships. Lists with unequal lengths are rejected unless one side is a singleton. Repeating the same source, target, direction, and kind is valid: each declaration is a distinct parallel logical relationship.

Relationships are static model metadata. Attaching one to a lazy (~=), conditional (?=), scheduled, DEFAULT, or STATE assignment does not make the edge conditional on that assignment's current value or execution state. The relationship exists for the compiled model revision; only a symbol-backed payload property refreshes as its source cell changes.

relates to is directed. connects to is bidirectional and denotes one logical relationship; the resident directed representation keeps its two traversal arcs under a shared relation_id. Relation kinds are symbols and become the edge kind property, so ordinary Graph views and predicates can select :depends, :shipment, or :peer without a separate query system. Logical relation_id values are stable under insertion of unrelated declarations. Parallel duplicates receive distinct duplicate ordinals, while the two physical arcs of one connects to declaration share one ID. Treat the ID as opaque; equality and source stability are the public contract, not its encoded spelling.

predicate declares the payload independently of its carrier. The same declaration may type a structured unary predicate, such as order is :shipment(qty) = total (the readability article is optional), and a relationship payload. An undeclared fieldless predicate such as :peer remains valid; any predicate carrying fields requires a matching declaration. The older tag and relation declaration keywords remain source-compatible aliases, but predicate is canonical. Predicate declarations may come from imported .gs libraries. A trailing one (the default) or many by (field, ...) controls how many predicate instances may attach to one symbol and how those instances are identified. It does not collapse or key graph edges: relationship identity remains the compiler-owned relation_id.

MODEL.GRAPH is a distinguished receiver, not a constructor. It uses the same Graph methods, path declarations, views, and filtering surface as an explicit graph handle, and is materialized only when a model relationship is authored or the receiver is queried. Declared relation payloads are typed and may be scalar literals or model symbols. Symbol-backed payloads refresh the edge property snapshot when their source cell changes without rebuilding the graph's topology. Both literal and symbol-backed payload values must inhabit the shared predicate field contract; mismatches fail closed rather than becoming unchecked loose-mode overlays. Payload parameter names kind, relation_id, tag, target, membership_id, and present are reserved for carrier-owned relationship, membership, or state metadata.

Unary-predicate membership forms the bipartite portion of MODEL.PREDICATES. MODEL.TAGS remains a stable unary-only compatibility view; both are separate from MODEL.GRAPH:

reviewed = MODEL.PREDICATES.NEIGHBORS(:reviewed)
predicates = MODEL.PREDICATES.IN_NEIGHBORS("A1")
instances = MODEL.PREDICATES.OUT_DEGREE(:reviewed)
memberships_and_facts = MODEL.PREDICATES.EDGES()

The predicate side uses colon-symbol node identity (:reviewed); the model-symbol side uses canonical text identity ("A1", "order"). This makes the leading colon a true namespace separator even when a model symbol and predicate share the same spelling. Each :<predicate> -> "symbol" edge has kind :applies. Structured field values and field.__type metadata are edge properties. A single predicate has at most one such edge per symbol. A many by (...) predicate has one parallel edge per identity, so adjacency returns a distinct set of symbols while degree and edge operations retain every instance. The membership edge ID derives from the predicate, target, and complete typed declared identity and is stable across payload upserts. It is an opaque, versioned ID; current IDs use tag-membership:v2:<sha256>. Consumers may compare or retain the full ID but should not parse the digest or assume an unversioned format.

Hidden unary-predicate state cells are live Graph inputs. Adds, keyed removes, and bare remove-all rule actions revise the view and invalidate cached Graph results; unchanged state reuses the exact-input cache without rebuilding adjacency. The view is materialized lazily only when queried. Changing only the lookup target reuses its resident adjacency, while a same-identity structured payload update shares the topology and replaces only the affected property data. Only a real membership add or remove rebuilds adjacency. Keeping the view distinct means unary-predicate membership cannot change MODEL.GRAPH dependency paths, components, or centrality.

MODEL.PREDICATES is the single extensional view of predicates. It combines those live unary-membership edges with authored binary facts:

next = MODEL.PREDICATES.NEIGHBORS("design")
places = MODEL.PREDICATES.NEIGHBORS("parcel")
facts = MODEL.PREDICATES.EDGES()

Binary edges carry kind: :predicate, the predicate family, authored relation alternatives, source order, and any condition metadata. Unary edges carry kind: :applies and their structured payload. The view does not materialize inferred closure as edges; query inference belongs to the Predicate dialect and is available through plain queries, MUST, MAY, CANNOT, RELATION, and WHY. This keeps extensional knowledge, inferred knowledge, and model dependency topology distinct.

Temporal, topological, and conceptual-set facts coexist in this view. Family-local inference remains separate even when the same symbols participate in several families; select temporal, topology, or sets only when an inspection would otherwise be ambiguous. See the predicate guide.

Declaring a graph

Use an exact graph named binding. A graph cannot be assigned to a cell, range, qualified sheet target, input, output, default, lazy assignment, or scheduled assignment.

frame road_rows = READ_PARQUET("roads.parquet")
 
graph roads = GRAPH(
  road_rows AS edges,
  "road_id" AS edge_id,
  "origin" AS from,
  "destination" AS to,
  ["minutes", "capacity", "build_cost"] AS edge_properties,
  TRUE AS directed
)

edges, edge_id, from, and to are required. edges may be a Frame, a finite range, or a literal row array. Column bindings may be names or indexes appropriate to the source. Stable edge IDs are mandatory. Optional nodes and node_id bindings add isolated nodes. node_properties and edge_properties select retained property columns; when omitted, lowering retains every non-binding column. Parallel edges and self-loops are supported.

Node identity is exact and type-sensitive. Strings and checked integers are valid node keys; blanks, floating-point identity, and implicit text coercion are rejected. Construction is deterministic and rejects missing endpoints, duplicate edge IDs, and schema mismatches.

Frame-backed graphs are source-revision-backed. Literal row arrays and ranges whose required cells are all literals carry portable records in LIR. A live or computed Workbook range lowers through a Frame source with explicit cell dependencies. A compiler must not mark a graph SOURCE_BACKED unless it emitted both source handles and the complete node/edge ID and endpoint column contract.

Projecting a graph into Predicate knowledge

An arbitrary named graph can be an extensional fact source for the general Predicate program:

graph knowledge = GRAPH(
  relationship_rows AS edges,
  "id" AS edge_id,
  "subject" AS from,
  "object" AS to,
  ["relation"] AS edge_properties,
  TRUE AS directed
)
 
KNOWLEDGE GRAPH knowledge VIA edge.relation WITH PROPERTIES
predicate ancestor is transitive
 
answer = MUST("ada" ancestor "charles")

VIA edge.<property> selects the property whose nonempty string or symbol value names each open binary predicate. It defaults to edge.kind, making KNOWLEDGE GRAPH knowledge sufficient for conventional typed property graphs. A missing, blank, or non-textual relationship property fails the Predicate region closed and identifies the offending stable edge ID.

WITH PROPERTIES additionally exposes the resident property graph through four ordinary, joinable logical predicates:

graph_node("knowledge", node)
graph_edge("knowledge", edge_id, from, to, relation)
graph_node_property("knowledge", node, property, value)
graph_edge_property("knowledge", edge_id, property, value)

The graph binding, edge IDs, property names, string values, and node strings use quoted string terms; symbols, exact integers, finite binary64 numbers, booleans, and BLANK retain distinct logical constants. These facts are read from the current snapshot at dispatch and need not be copied into authored Predicate facts. Omitting WITH PROPERTIES projects only the direct binary relationships and avoids the additional fact volume.

The projection reads the current resident graph revision. Frame-backed source changes and accepted atomic graph patches therefore recompute the fact base before dependent Predicate publications resolve. A directed edge contributes one fact; an undirected edge contributes both directions. Parallel graph edges remain distinct graph relationships while their projected logical truth is extensional.

Graph identity remains exact across the boundary. A symbol :1, string "1", and integer 1 are distinct Predicate constants, and string or integer node identities may be written directly in logical atoms. Projected facts may participate in ordinary laws, rules, stratified negation, graph quantifiers, virtual paths, evidence queries, and WHY. Proof output retains the contributing graph handle, exact resident revision, stable edge ID, typed endpoints, and whether an undirected reverse fact was synthesized.

This is an explicit cross-dialect projection, not a topology merge. MODEL.GRAPH, MODEL.TAGS, MODEL.PREDICATES, the source GraphHandle, and inferred Predicate closure retain their separate identities and lifecycle. Projection size counts against the Predicate region's symbol, assertion, work, metadata, and derivation bounds.

Graph-backed reachability publishes the graph's authored string and integer node values; integers use Grid's exact bigint representation, so even large identities remain lossless. The reserved typed-identity encoding used inside Predicate never appears in cell results or provenance. A complete executable model is available at examples/canonical/17-predicate-knowledge.grid.

Typed property schemas

Use a closed property schema when graph algorithms should be checked before GIR execution:

graph schema Roads {
  node kind City
  edge kind Road: City -> City
  node.kind: symbol
  node.population: number?
  edge.kind: symbol
  edge.minutes: number
  edge.capacity: number
  edge.open: boolean
}
 
graph roads: Roads = GRAPH(
  city_rows AS nodes,
  "city_id" AS node_id,
  ["kind", "population"] AS node_properties,
  road_rows AS edges,
  "road_id" AS edge_id,
  "origin" AS from,
  "destination" AS to,
  ["kind", "minutes", "capacity", "open"] AS edge_properties
)

Each entry is node.<path>: <type> or edge.<path>: <type>; a trailing ? marks an optional value. Nested paths such as node.location.latitude are valid. Types use normal Grid type tags, including semantic and dimensional tags such as currency:usd and unit:m/s. A tag must refine a native Graph property representation: number/integer, boolean, string, or symbol. Date, object, array, and other representation roots are rejected until Graph storage can preserve them losslessly.

Schemas may also declare named node kinds and endpoint-constrained edge kinds. node kind City introduces a node kind; edge kind Road: City -> City introduces an edge kind and its valid endpoint kinds. A schema with named kinds must declare the corresponding total node.kind and edge.kind property as a string or symbol tag. Unknown kinds, missing kind values, and invalid edge endpoints are rejected.

The node_properties and edge_properties arrays must exactly match the declared paths. Selectors in paths, community partitions, patterns, comprehensions, rewrites, and fixpoints are checked for scope and existence. Numeric objectives also require a non-optional property whose type refines number. Reverse, induced-subgraph, and minimum-spanning-tree bindings inherit their source schema; an explicit different schema is rejected. Bare graph bindings remain available when a closed schema is not wanted.

The closed schema is serialized into Graph GIR metadata and checked again when the resident snapshot is loaded and whenever an atomic property patch is staged. Required properties must be populated on every row, undeclared properties are rejected, and storage representations must inhabit their type tags. Equality predicates (= and !=) are also checked at compile time when one operand is a literal, so for example edge.minutes = "fast" cannot reach runtime when edge.minutes is numeric.

graph <name>: <Schema> is the preferred spelling and the formatter's output. The generic prefix tag graph:<Schema> <name> = ... remains accepted for compatibility. id is intrinsic node/edge identity rather than a property: a.id and e.id are valid pattern projections without schema entries, while pseudo-properties such as a.identity must be declared.

Querying

Method form is preferred. It desugars to the corresponding namespaced GRAPH_* intrinsic with the receiver as argument zero.

A1 = roads.NODE_COUNT()
A2 = roads.OUT_DEGREE("Denver")
A3 = roads.HAS_PATH("Denver", "Boulder")
 
frame path = roads.SHORTEST_PATH(
  "Denver",
  "Boulder",
  "minutes" AS weight
)
 
frame ranks = roads.PAGE_RANK(
  "capacity" AS weight,
  0.85 AS damping,
  1e-8 AS tolerance,
  100 AS max_iterations
)

Path queries and partitions are resident typed handles. Their data is exposed through bounded projections rather than by spilling opaque handles into cells:

path fastest = path in roads from "Denver" to "Boulder"
  minimize sum(edge.minutes)
  limit 32 AS hops
 
A4 = fastest.cost
A5 = fastest.hops
frame fastest_nodes = fastest.nodes
frame fastest_edges = fastest.edges
 
partition districts = partition nodes of roads by community(
  leiden AS method,
  0 AS seed
)
frame district_membership = districts.members
frame district_summary = districts.summary

path.nodes has columns (ordinal, node). path.edges has (ordinal, edge, from, to) followed by the source graph's edge-property columns. A missing path produces blank scalar projections and empty Frames. Partition membership has (node, partition) and summary has (partition, size, first_node, label). All rows use canonical deterministic order and count against Graph output-row and scratch-memory budgets.

Scalar operations are NODE_COUNT, EDGE_COUNT, DENSITY, DEGREE, IN_DEGREE, OUT_DEGREE, HAS_PATH, and HAS_EULERIAN_PATH. Frame results include NODES, EDGES, NEIGHBORS, IN_NEIGHBORS, BFS, DFS, SHORTEST_PATH, BELLMAN_FORD, WEAK_COMPONENTS, STRONG_COMPONENTS, TOPOLOGICAL_ORDER, PAGE_RANK, bounded DIAMETER, ARTICULATION_POINTS, BRIDGES, CLOSENESS_CENTRALITY, and BETWEENNESS_CENTRALITY. K_CORE returns per-node core numbers, TRIANGLE_COUNT returns a scalar, CLUSTERING_COEFFICIENTS returns per-node coefficients, and MAXIMUM_BIPARTITE_MATCHING(left_partition) returns the selected edges.

Weighted algorithms name an edge property for each call. SHORTEST_PATH rejects negative weights; use BELLMAN_FORD when negative weights are part of the model. Iterative and all-sources algorithms expose convergence or source bounds so work remains explicit.

Declarative paths

Use a first-class path binding when the intent is an objective rather than a specific algorithm. Source never names BFS, Dijkstra, or a heap:

path fastest = path in roads from "Denver" to "Boulder"
  minimize sum(edge.minutes)
  limit 40 AS hops
 
path resilient = path in roads from "Denver" to "Boulder"
  maximize min(edge.capacity)
 
path fewest_transfers = path in roads from origin to destination
  minimize hops

The planner maps fewest hops to breadth-first search, an unbounded non-negative sum to Dijkstra, a hop-bounded objective to expanded-state dynamic programming, and a max-min objective to widest path. This choice is visible through explainGraph and the resolved PathHandle report, but is not part of source semantics. Paths are non-spillable resident values bound to the exact source graph revision. getPathReport returns the ordered node and edge IDs, cost, objective, chosen plan and reason, deterministic flag, and implementation version.

Partitions

Connectivity, core decomposition, and community detection all produce the same first-class partition value:

partition weak = partition nodes of roads by connectivity(weak AS direction)
partition cores = partition nodes of roads by core_number
partition core4 = partition nodes of roads by k_core(4 AS k)
 
partition regions = partition nodes of roads by community(
  leiden AS method,
  modularity(1 AS resolution) AS objective,
  edge.capacity AS weight,
  symmetrize(sum) AS direction,
  0 AS seed,
  all AS hierarchy,
  50 AS max_passes,
  16 AS max_levels,
  0.000000001 AS tolerance
)

Leiden is the recommended community method. louvain is available as an explicit compatibility choice. Directedness is never guessed: select directed, undirected, or an explicit symmetrize(sum|max|mean) policy. Community IDs are canonicalized by member order and therefore do not expose backend-local labels. getPartitionReport returns provenance, convergence and work metadata, typed membership rows, hierarchy, and per-community summaries.

Bounded pattern matching

Patterns are typed Graph syntax and always require an output bound:

frame alternatives = match roads limit 1000:
  (a)-[first where first.open = TRUE]->(b)-[second where second.open = TRUE]->(c)
  return a as origin, b as via, c as destination,
         first.minutes as first_minutes, second.minutes as second_minutes

Node and edge bindings are statically scoped. Predicates use typed properties; projections must have unique aliases. An exact start identity uses an ID-seek plan, while an unconstrained start uses a canonical node scan. Expansion and output order remain deterministic. Results materialize through a declared FrameHandle; they never spill implicitly into Workbook cells.

Repeating a binding with the same entity role is a bounded equality join:

frame mutual = match roads limit 1000:
  (a)-[out]->(b)-[back]->(a)
  return a.id as origin, b.id as peer

A binding cannot change roles between node and edge positions.

DISTINCT and GROUP are not clauses of Graph pattern syntax. match first produces a typed FrameHandle; deduplication, grouping, aggregation, sorting, and windowing are downstream Frame-dialect operations over that Frame. Graph owns bounded traversal and identity semantics, while Frame owns relational row analytics and its Polars/Lance physical plans.

Graph comprehensions and immutable rewrites

A comprehension creates a property-preserving logical graph view:

graph active: Roads = roads {
  node city where city.population > 0
  edge road where road.open = TRUE
}

Graph MIR retains the node/edge predicates, source identity, consumer count, and cardinality, work, and retained-memory evidence. A single selective cardinality consumer can fuse the scan/filter and allocate no derived snapshot. Shared or topology-consuming views materialize one admitted resident snapshot. Both plans preserve canonical identity, ordering, and errors.

Rewrites are bounded immutable transactions. V2 accepts deletion of exactly one edge binding from a one-edge match:

graph normalized: Roads = rewrite roads limit 100000:
  match (a)-[road where road.open = FALSE]->(b)
  delete road

limit N bounds distinct stable edge IDs. The native scan detects one unique edit beyond the limit—even for undirected duplicate traversal—and rejects the whole operation before patch staging. Every match observes one source revision and readers never see partial edits.

Bounded graph fixed points

A fixpoint frame is the native propagation form. It has an explicit semiring, direction, initial state, seeds, convergence condition, and iteration bound:

frame minutes_from_origin = fixpoint in roads:
  using min_plus(edge.minutes AS weight)
  along AS direction
  1000000000000 AS default
  0 AS seed origin
  until max_delta < 0.000000001
  within 100 iterations

Available operators are sum_product, min_plus, max_min, and boolean_or. They specialize respectively to deterministic sparse gather, shortest-path relaxation, widest-path relaxation, and reachability propagation. along gathers through incoming edges and against through outgoing edges. Every program needs at least one seed and a positive literal iteration bound. Sum-product, min-plus, and boolean-or initial values are finite. Max-min additionally accepts negative_infinity as its default and positive_infinity for seeds, matching its widest-path identities.

EDGES() returns canonical edge-ID order. Other order-sensitive graph results use canonical type-sensitive node order, with edge ID and source order as deterministic tie-breakers. DENSITY() measures the graph's simple loop-free projection: parallel edges count once and self-loops do not count, against the directed (n*(n-1)) or undirected (n*(n-1)/2) denominator. Density is therefore always between 0 and 1; graphs with fewer than two nodes report 0.

HAS_EULERIAN_PATH() works for directed and undirected graphs. Directed graphs require all non-isolated nodes to be weakly connected and either every node balanced or exactly one out_degree - in_degree = 1 start and one -1 end. Undirected graphs require connected non-isolated nodes and zero or two odd-degree nodes. A graph with no edges has an Eulerian path.

When max_sources is supplied to DIAMETER, CLOSENESS_CENTRALITY, or BETWEENNESS_CENTRALITY, Grid deterministically spreads min(node_count, max_sources) samples across canonical node order, including both endpoints when at least two sources are selected. This avoids the adversarial bias of a canonical-prefix sample. A bound smaller than the node count still approximates the full all-sources query: bounded closeness returns only sampled source rows, while bounded betweenness returns every node's score accumulated from those sources. The native core result reports the sampled source IDs, total source count, exact/approximate status, and strategy. Current Frame schemas do not add those fields, so retain the authored max_sources argument when that provenance is needed downstream.

Transformations

Graph-producing operations must be rebound as graph handles:

graph reverse_roads = roads.REVERSE()
graph backbone = roads.MINIMUM_SPANNING_TREE("build_cost" AS weight)
graph regional = roads.INDUCED_SUBGRAPH(region_node_ids)
graph quotient = contract roads by districts

The result is another immutable graph snapshot. Existing CS_* edge-list functions remain compatibility surfaces; the compiler may promote stable, repeated edge-list operands to the resident graph core without changing their observable results.

Partition contraction produces a deterministic quotient GraphHandle. Each partition becomes an integer node with size, first_node, and optional label properties. Internal edges are removed; every cross-partition edge keeps its stable ID and properties. Parallel edges remain distinct, so directedness and multigraph semantics are preserved without an implicit aggregation policy. The partition must belong to the exact source revision. Because the quotient has a generated property shape rather than the source schema, source-schema annotation on the contraction binding is rejected.

Compatibility promotion is deliberately conservative. A MIR promotion pass may share a resident graph only when every promoted call has the same direction, exact node-key representation, edge order, weight/property source, blank handling, error contract, and deterministic tie-breaking as the legacy CS_* call. Dynamic or ambiguous operands stay on the legacy intrinsic. The source function name and source span remain attached to diagnostics, and the pass never rewrites authored source or requires migration to GRAPH.

Row-shaped results may be bound directly as resident Frames. Such bindings carry the produced FrameHandle into downstream Frame/Geo regions without a Workbook spill:

frame road_edges = roads.EDGES()
R1C1 = FRAME_GEO_FILTER_BBOX(road_edges, "geometry", -105, 39, -104, 40)

Result and work bounds

Graph handles never spill into workbook cells. Scalars may be assigned to cells; tabular results are Frames and require explicit collection to spill. Runtime limits cover nodes, edges, resident bytes, result rows, work units, iterations, per-operation scratch, deadlines, and cancellation. The declared max_scratch_bytes is intersected with host policy, including GRID_MAX_GRAPH_SCRATCH_BYTES. Kernels with scratch plans preflight conservative major phase peaks. GRID_GRAPH_SCRATCH_BUDGET means preflight rejected the operation; GRID_GRAPH_SCRATCH_ALLOCATION is reserved for the covered fallible vector reservations that fail after preflight. Grid does not claim to intercept every allocation made by Rust or its dependencies.

Tie-breaking is stable by node key, edge ID, then source order and never depends on hash-map iteration order.

Resident host API

The Rust runtime host advertises listGraphHandles, listPathHandles, listPartitionHandles, getPathReport, getPartitionReport, explainGraph, resolveGraph, patchGraph, appendGraphPatchEvent, replayGraphPatchEvents, getGraphPatchJournal, projectGraphToGeo, solveGraphMinCostFlow, and runGraphDes through its HTTP RPC capability list. listGraphHandles(modelId) returns the stable wire IDs currently resident for the model; resolveGraph(modelId) executes its Graph regions and returns bounded typed reports; and patchGraph(modelId, graphHandle, patches) atomically applies a typed patch batch. Derived handles are read-only: patch their primary source handle instead. A rejected batch leaves both the prior snapshot and active readers unchanged. Every accepted patch is also appended to the model-local GraphPatchJournalSnapshot. Durable storage persists that stream beside the compiled model as graph-patches.jsonl. Restart replay checks journal order, each graph revision transition, and the runtime's exact before/after patch evidence. A torn final record is discarded; malformed complete records or a different base graph fail closed. The journal is bounded to 512 MiB by default; GRID_GRAPH_PATCH_MAX_JOURNAL_BYTES may lower or raise that ceiling. Authored source or source Frames remain the durable base snapshot. If a patch commits in memory but its journal append or fsync fails, the host immediately unloads the resident model. The next access must rehydrate from the last complete durable prefix, so no later request can observe or extend an unjournaled revision. Batch replay is prefix-preserving: if a later event is rejected, every earlier accepted atomic event is fsynced before the error is returned, and the unapplied suffix remains available for correction or retry. GraphPatchEventRequest.clientEventId is an optional, bounded idempotency key. A retry with the same key, authority, source URI, graph handle, and patches returns the original event even when its optimistic revision coordinates are now stale; reusing the key for a different mutation fails closed. HTTP ingress overwrites authority from the trusted principal and stamps observedAt. sourceUri remains caller-provided trace context and is validated and retained with the durable event. The three domain bridge calls consume the current resident revision directly, preserve canonical node identity, and return a pinned { source, value } envelope without an external intermediate file. source contains graphHandle, the graph's local revision, optional sourceRevision, and its content fingerprint; value is the Geo, Network Flow, or Sim domain result. Derived views/rewrites may reuse a stable handle and start their own local revision at zero after reconstruction, so downstream capabilities must retain the complete pin rather than comparing only the local revision. A primary-source patch invalidates derived handles before an adapter can observe stale topology.

Portable GraphHandles with node/edge counts fixed in LIR accept property updates and count-preserving endpoint rewires. They reject node/edge additions and removals before commit with GRID_GRAPH_PATCH_SHAPE_IMMUTABLE, because the immutable handle header cannot represent a different topology shape. Dynamic source-backed handles whose counts are left open may accept shape changes under their runtime budgets.

The Node web API exposes the core lifecycle and diagnostics as thin, model-scoped facades; Graph semantics and validation remain in Rust. The typed frontend transport exports the matching calls:

Method Model-scoped endpoint Frontend transport
GET /api/models/:id/graph/handles listGraphHandles
POST /api/models/:id/graph/resolve resolveGraph
POST /api/models/:id/graph/handles/:handle/patch patchGraph
POST /api/models/:id/graph/journal/events appendGraphPatchEvent
POST /api/models/:id/graph/journal/replay replayGraphPatchEvents
GET /api/models/:id/graph/journal getGraphPatchJournal
GET /api/models/:id/graph/explain explainGraphModel
GET /api/models/:id/graph/paths listGraphPathHandles
GET /api/models/:id/graph/paths/:handle getGraphPathReport
GET /api/models/:id/graph/partitions listGraphPartitionHandles
GET /api/models/:id/graph/partitions/:handle getGraphPartitionReport

All routes accept the standard model partition query/header. Patch bodies are { patches: GraphPatch[] }; graph mutation is atomic and the response includes revision and patch evidence. Rust u64 revisions and fingerprints cross this JSON wire as exact decimal strings; legacy numeric requests remain accepted. Work, count, budget, and size fields remain numeric quantities.

Durable graph patch tails checkpoint after 8 MiB by default (configurable with GRID_GRAPH_PATCH_CHECKPOINT_TAIL_BYTES). A checkpoint retains the complete event prefix for audit and idempotency, plus exact resident images for every patched primary GraphHandle. Recovery validates those images against the authored graph's base revision and fingerprint, installs the whole checkpoint atomically, and replays only the post-checkpoint tail. Legacy event-only checkpoints remain readable and use full replay. Checkpoints publish before the tail is truncated, and recovery verifies/deduplicates that crash window. New journals are bound to the model id and source hash; source edits therefore fail closed instead of rebasing patches onto potentially different graph identities.

explainGraph(modelId) is read-only and does not execute the model. It returns one report per Graph region containing:

  • the primary handle, current revision, node/edge counts, and resident bytes;
  • hard node, edge, work, output-row, iteration, and scratch budgets;
  • every semantic operation contract and source/result slot;
  • selected physical kernel and the reason for that choice;
  • result materialization (GraphHandle, PathHandle, PartitionHandle, or FrameHandle) and current resident state.

After resolveGraph, call getPathReport(modelId, pathHandle) or getPartitionReport(modelId, partitionHandle) for data-dependent execution details. Patching a primary graph invalidates all derived paths, partitions, and derived graph descendants before the next read.

Prometheus exposure includes grid_graph_resident_artifacts{kind="path"} and {kind="partition"}, per-operation call/work/budget/latency metrics, stable Graph error codes, resident bytes, cache outcomes, and atomic patch outcomes. Scratch telemetry is grid_graph_scratch_peak_bytes, grid_graph_scratch_rejections_total, and grid_graph_scratch_allocation_errors_total, labeled by operation. The last counter covers only the fallible reservation sites described above.

Graph V2 completion contract

Graph V2 is feature-complete for the surface documented here. Completion is pinned by these acceptance gates:

  • schemas, named kinds, endpoint contracts, selector types, equality literals, hydration, and atomic property patches fail closed on invalid representation;
  • paths, partitions, patterns, views, rewrites, fixed points, projections, and contraction lower through GIR and survive LIR serialization;
  • PathHandle, PartitionHandle, FrameHandle, and derived GraphHandle lineage is revision-bound, non-spillable where applicable, and invalidated atomically;
  • deterministic sparse/dense monsters exercise work, output, iteration, and scratch fences; view fusion equals materialization and rewrite overflow leaves the source untouched;
  • the canonical example reaches the production host, returns typed projection Frames, materializes the quotient, exposes EXPLAIN, and emits scratch telemetry.

This completion claim does not move Frame DISTINCT/GROUP into Graph, claim global allocator coverage, or certify a fixed-runner performance ratio.

The end-to-end authored example for the implemented V2 slice is examples/canonical/16-graph-v2-native.grid.