Metrics Engine¶
Verified against the code · 9e119ef0950aa1f8c8f87a640132addf0109872c · 2026-10-20
956 D1b-3 (2026-10-20): 1 finding(s) fixed — GET /activity/heatmap success responses carry no errors key; the audit-unavailable branch adds errors, degraded: true and changesOnRemovedNodes null (server.py:4433-4442). Re-verified against main 9e119ef0.¶
Layer: backend
Status — Wave 1 (2026-09-15, #625): every backend read-endpoint that used to hand-roll its own counts now reads from the catalog through
metrics.engine./info,/admin/model-stats,/admin/export/full-model/info,/activity/heatmapand/metrics/batchall delegate to the engine; the Wave 0 shimbackend/domain/node_metrics.pyis gone, and the three named_get_all_descendantswalkers plus the tombstone disk-glob and heatmap audit fold are retired. Two AST/behavior ratchets intests/test_metrics_ratchets.pydefend the invariant.Wave 1 semantic changes: - Delete-preview
descendantCountis now exclusive of the anchor (matches the catalog'sdescendantsmetric and the detail-panel semantics — the frontend one-liner that used to subtract 1 was retired in the same commit). -relations_by_predicatenow maps each predicate to explicit relations only (was blended with inherited expansions). Userelations_inheritedfor expansion counts. -active_users(window-aware) added alongside the existingactive_users_30d(hard-wired 30-day window). The/activity/heatmapendpoint usesactive_users. -GET /metrics/batchuses an explicit opt-in allowlist (_BATCH_SAFEinbackend/server.py) — new node-scope metrics are excluded by default until IAM-reviewed and explicitly added. See the dev reference for the security invariant. -/info.relationsis unchanged from pre-W1: explicit-only count (backward compat)./info.relations_totalis the W1 additive field (explicit + inherited). The server-info popup's "Assoc. edges" figure rendersrelations(explicit), matching its pre-W1 meaning. Consumers include the admin cross-domain aggregation and eidosdash. -/admin/export/full-model/infonow includes a top-levelrelationsfield (Σ explicit across domains, matches view-mode download content) alongsiderelations_total(Σ explicit + inherited, matches archive-mode content). The export dialog renders both: "N relations (M incl. inherited)".Detail panel and tree chips move onto the engine in Wave 3; EIDOS Dash in Wave 4.
Wave 3 / 4.2a (#665, tree chips): there is now ONE path to a tree-chip refresh — a mutation dispatches
EidosCore.dispatchMutation({kinds:['property-value'…]})and thetree-chipsreconciler drives the refresh; the old directEidosCore.refreshTreeChips()poke is retired (ratchet:tests/test_frontend_chip_refresh_single_path.py). At startup the chip registry (TREE_CHIP_DEFS) is validated againstGET /metrics/catalog(through the canonicalEidosApilayer): every chip key must be a registered catalog metric id, or it is hidden (unselectable) and, on a dev host, logged — a chip can never reference a metric that no longer exists. The one exception ismy notes, which is a per-user count served by a dedicated batch path, not a shared model metric, and is markeduserScopedso the catalog check exempts it. Chips never count anything themselves — the catalog is the single source of truth.Wave 3 / 4.2b (#665, detail-panel headers): three of the four zone headers (What's inside / Specifications / Documents) now take their count from the metrics catalog (
subitems/specifications/attached_documents) instead of counting rows themselves. #700 (connectivity-scoped metric, resolved):ConnectionsZoneis now wired through the catalog like the other zone headers (#665). The panel is anisConnectedToallowlist (_relConnection(), a getter that readswindow.EidosCore.connectivityPredicates, populated byapp.jsfrom the catalog'sconnectivity.predicatesfield; there is no in-panel literal fallback — before the catalog arrives the getter returns an empty Set, soConnectionsZoneshows "Nothing connected yet" until_validateTreeChipDefs()runs). The zone header count now reads the catalog'sconnectivitymetric (via the shared/metrics/batchpage cache +_headerCount), and the tree chip maps to the same metric. Because chip, header and panel body all derive from the sameCONNECTIVITY_PREDICATESset, they agree on every node — on DEV they used to coincide only becauseisConnectedTois the sole internal predicate. Dedupe:connectionsdedupes outgoing by(predicate, target)per its catalog definition;connectivityand theConnectionsZonepanel body both count rows directly (no dedupe), so they stay aligned. The single authoritative definition of the allowlist isCONNECTIVITY_PREDICATES = frozenset({'isConnectedTo'})inbackend/metrics/catalog_node.py; the catalog exposes it viaconnectivity.predicates; no second literal lives in the frontend. If a user's saved tree chip pick was the oldconnectionsid,_loadChipsForCurrentTree()migrates it toconnectivityon first read (one-shot, persisted). The olderconnectionsmetric is unchanged (still in the catalog for other consumers). Ratchet:tests/test_connectivity_chip_panel_parity.py. DocumentsZone wiring (#665) is complete: the zone now readsattached_documentsvia_headerCountthrough the shared page-lifetime/metrics/batchcache. The mismatcheddocumentscatalog metric (which counts doc-URL engineering properties, not the attached document nodes the zone shows — 19/21 DEV doc-bearing nodes get their docs via rulebook channels, not directhasDocument) is NOT used.attached_documentswas added in #698 and gives the IAM-correct per-caller count (option a: per-caller filtered count, identical to whatGET /node/{uid}/documentsreturns — a cheap unfilteredlen(merged)is forbidden because it would leak restricted-doc existence, same reasondescendantsis kept out of_BATCH_SAFE).NodeDetailPanelfetchessubitems/specifications/attached_documents/connectivityONCE per node view through a page-lifetime/metrics/batchcache shared with the tree chips (EidosCore.getNodeMetrics, invalidated by thetree-chipsreconciler on every mutation), so at most one request per node view. Each header runs through_headerCount(metricValue, rowCount, zone): it shows the engine value; if that disagrees with the local row count it still shows the engine value and warns on a dev host (a disagreement means panel and catalog count differently — surfaced, not hidden); and if the metric is absent (partial response) it falls back to the local row count rather than a false 0. Ratchet:tests/test_frontend_panel_headers_from_catalog.py. Sanctioned local exceptions: (1) the BL-FE-095 heatmap keeps its LOCAL_computeDescendantCounts—descendantsis deliberately held out of_BATCH_SAFEfor IAM reasons, and the local walk over the tree the user actually sees is IAM-correct by construction (owner-decided deviation from spec §6, because the engine cannot give an IAM-correct number cheaply). ConnectionsZone no longer counts rows for its header: it readsconnectivitythrough the same page-lifetime cache (#700 resolved). DocumentsZone readsattached_documents(#665 resolved). #645:/admin/model-statsnow exposes two additive fields —relations_all_domainsandtree_edges_all_domains(the cross-domain sums the aggregation loop already accumulates; not a re-count). The "Edges total" line rendersedges_total (relations_all_domains assoc. + tree_edges_all_domains hier. + relations_inherited inherited), e.g.17435 (10495 assoc. + 6940 hier.). The inherited summand sits inside the parenthesis and is omitted when 0. Inherited expansions are part ofedges_total(unchanged); whether derived expansions should count as edges at all is an open owner question (#695).#696/#697:
relations_inheritedandrelations_by_predicateare now all-domain on/admin/model-stats. The route no longer carries its own fan-out: it delegates to the ONE canonical cross-domain collectormetrics.collection.collect_model(02.01 §3.6), which hits each secondary's/metrics/model?domain=selfand merges contributions by each metric's declared rule (sumfor inherited,dict_sumfor predicates). The merge rule and the partial state have exactly one owner;/infono longer carries these fields. The three terms ofedges_totalare at the same all-domain scope —edges_total = relations_all_domains + tree_edges_all_domains + relations_inherited— andrelations_explicit,relations_inheritedandrelations_totalshare that consistent scope sorelations_total = relations_explicit + relations_inheritedholds in the response. Regression:tests/test_admin_misc_router_wiring.py::test_scope_agreement_inherited_all_domain, revert-red per Contract 04 §3.8 R49.Partial scope — one flag per field.
/admin/model-statsemitsrelations_inherited_scopeANDrelations_by_predicate_scopeseparately, so a secondary whose engine fails on one metric but not the other flags only the affected line.Named per-domain state. Every per-domain row carries a
statefield — one ofok,errored,older,unreachable— so the UI can distinguish an older secondary (reachable but missing a wanted metric id in its 200) from one whose own engine self-reports an upstream fault for that metric (errored) from a failing one (5xx / timeout / garbage body / invalid JSON —unreachable). The states, derived from the HTTP response alone:
ok— status 200, body is a well-formed dict, every wanted id is present with a typed value. The answer enters the totals.errored— status 200 with one or more wanted ids missing frommetricsAND every missing id explained in the body'serrorsdict. The secondary IS current (not an older build), it just has an upstream fault for that metric. Values are atomically dropped and the per-metric error message is preserved in the merged response'serrors.older— status 404 or 400, OR status 200 with one or more wanted ids missing AND at least one missing id NOT inerrors. The secondary genuinely doesn't know what we asked for (an older build).unreachable— anything else: 5xx, timeout, network error, non-dict body, non-numeric scalar, dict payload that isn'tstr → number.All three non-
okstates drop out of the totals and flip the top-level scope flags topartial; the row renders differently so a user can tell "upstream fault" (errored) from "deploy the newer secondary" (older) from "the secondary is down" (unreachable). The states are also emitted on the/metrics/modelresponse asdomain_states(a declared field oncontract.model_response, so the archive and the HTTP response share one shape) —domain_states: {<domain>: "ok"|"errored"|"older"|"unreachable", ...}.Atomic + typed body validation. The collector strictly validates every value in a 200 answer before taking it, by each metric's declared shape: a scalar must be a number, a
unit="dict"payload (e.g.relations_by_predicate) must bestr → number, and aunit="id_count"max payload (max_container,max_relations_node) must be{id: str, iri: str|null, count: number}. A null, string, list, or any other malformed dict value marks the whole domainunreachable, so an earlier-parsed count can never leak into the all-domain sum AND/admin/model-statsand/metrics/model?domain=allcannot 500 on a malformed secondary response. A 200 answer missing one or more wanted ids marks the domainerrored(every missing id self-explained) orolder(at least one unexplained) — a missing id makes the whole domain partial, not just that one metric. Non-200 responses are logged with status AND body (truncated) so a failure is diagnosable.Atomic drop on wide-request paths.
errored,olderandunreachableall drop the whole secondary's values atomically — including on wide-request paths like/metrics/model?domain=alland the FME archive snapshot. On anerroredsecondary the numbers it did validate (e.g. five of six wanted ids) are therefore NOT folded into the all-domain sum: they appear as null inmetrics.*.domains.<name>, the response ispartial: true, and the row'sdomain_states[name]is"errored". The upstream error message is still preserved in the merged response'serrorsdict, so the operator sees the real signal and can tellerroredfrom the other two kinds of partial. The reason for atomic drop (rather than dropping only the explained ids) is spec §3.6 — "validate the whole answer, then take it, or take none of it" — and so thatrelations_total = relations_explicit + relations_inheritedholds unconditionally in the response.
Why an engine¶
Before Wave 0, metrics were computed per feature, on the fly, with divergent
definitions: connections meant one thing in the tree chip and another in the
detail panel; "changes" had two audit folds (chip vs heatmap); sub-items,
descendants and parts were four counters over the same hierarchy; and EIDOS
Dash re-implemented ~25 numbers from the export archive. Users could see
numbers that disagreed, and nobody could look up what a number meant.
The Metrics Engine makes the definition the unit of ownership. A metric exists only if it is in the catalog; the engine computes only what the catalog declares; this page's table is generated from the catalog at build time.
Facts, not scores¶
The engine owns facts: counts and ratios of structural things that can be
verified against disk. Weighted or composite scores (completeness,
readiness, health, maturity) are dashboard policy and stay in EIDOS Dash — but
must be defined purely over catalog ids. Instance-administration figures
(users, snapshots, tier) are not metrics and remain in /admin/model-stats.
Scope, source, merge¶
- scope
node— computed for a list of node ids (compute_node);model— computed for one domain and merged across domains. - source — the only thing a compute function may read:
graph(effective graph via the loader),audit(committed events viaaudit_service, read at most once per window — contract 13),iis(iis.storage). No direct disk or SQL (contract 01.01). - merge — how per-domain values become the model value:
sum,max,mean(weighted by the declaredinputs),dict_sum, ornone(primary only). An unreachable secondary yieldsdomains.<name>: nullandpartial: true; a merged number is never presented as complete when a contribution is missing.
Freshness¶
Model-scope results are memoised per (graph_version, date, days, ids).
graph_version is the mutation store's cross-worker sentinel counter — every
commit bumps it, so the cache dies by itself. There is no separate
invalidation mechanism to keep in sync.
IIS-sourced metrics (interfaces_*) are the one exception: the IIS store has
its own write path outside the graph sentinel, so they are computed fresh on
every request rather than served from the memo cache. The same holds for any
metric the catalog marks cache_exempt — today document_coverage, whose value
also depends on rulebook edits and on other domains, neither of which bumps the
sentinel.
Document coverage¶
document_coverage (#1142, W4 E1) is the share of product nodes (productType
not null) with at least one document reached through the same hasDocument
channel resolution the node's Documents zone uses: direct, then hierarchy, then
relation (the #508 documentPropagation rules). Each product's effective
relations come from the canonical engine and are routed by
documents_engine.route_document_rows, the single router shared with the
per-node resolver — there is no second resolution.
- No per-node HTTP. The relation channel's documents live on a target in another domain. Before the synchronous engine runs, the collector resolves each distinct target once (bounded concurrency, the client's FAST timeout) and hands the answers to the engine; ten products of one type cost one fetch.
- Admin scope. It is a model metric, served only under the model-metrics gate
(
admin_or_service) and not filtered by the caller's permissions — unlike the per-caller node metricattached_documents. - Never a fake number. A domain without product nodes reports
null(never0), and contributes nothing to the merged share. If a relation-channel target could not be resolved for a product that no other channel covers, the value carriespartial: trueinstead of silently reporting a lower share.
Response shape¶
GET /metrics/model returns {scope, graph_version, computed_at, days, partial,
metrics: {id: {value, unit, inputs?, findings?, partial?, domains}}, errors, domain_states}.
inputs carries denominators so a consumer can show the arithmetic; findings
(capped at 200) lists the offending ids for quality facts; a per-metric
partial (only on metrics that report it, e.g. document_coverage) marks a
value that could not be fully computed even though every domain answered. domain_states is a
declared {<domain>: "ok"|"errored"|"older"|"unreachable", ...} dict (one
entry per fan-out target) so a client can tell apart the three kinds of
partial. Formula text is in the catalog, not in the response.
GET /metrics/catalog returns {metrics: [...]} — an object with a single
metrics array (not a bare array). Each entry shape:
{id, scope, label, definition, unit, source, merge, inputs: string[],
predicates?: string[]}. The optional predicates key is present and
non-empty only for connectivity-scoped metrics (currently only connectivity,
with predicates: ["isConnectedTo"]); all other entries omit the key entirely
(the serialiser in backend/metrics/catalog.dump() conditionally emits it only
when the registered metric declares predicates). Frontend consumers must treat
m.predicates as undefined for non-connectivity metrics, not [].
GET /activity/heatmap returns (W1, #625):
{
"window": {"from": "<ISO date>", "to": "<ISO date>"},
"totalChanges": 42,
"changesOnRemovedNodes": 5,
"activeUsers": 3,
"nodes": [...]
}
A success response carries no errors key at all. When the audit source
is unavailable the endpoint returns the header envelope with errors
populated, a degraded flag, and changesOnRemovedNodes set to null
(not computable while the audit source is down) — backend/server.py:4433-4442
(success branches at :4418-4425 and :4490-4496):
{
"window": {"from": "<ISO date>", "to": "<ISO date>"},
"totalChanges": 42,
"changesOnRemovedNodes": null,
"activeUsers": 3,
"nodes": [],
"errors": {"<metric_id>": "<error string>"},
"degraded": true
}
totalChanges is sourced from changes_total and activeUsers from active_users (window-aware) — the same catalog metrics /metrics/model uses. The heatmap, /metrics/model and the node chips (/metrics/batch) all agree because every changes*/active_users* metric is computed by the single canonical projection metrics._audit.committed_events — there is no second, private reader (owner decision 2026-09-17 extended the config/attribution exclusion from the user-facing readers to every change metric; #632/#648). The exact wording of each metric's definition is generated from the catalog in the table below — this page never restates it. changesOnRemovedNodes is the subset of totalChanges attributed to nodes that no longer exist in the graph (e.g. a delete-heavy window): those changes are real and stay counted (a deletion is a historically stable change) but have no live node to render in nodes, so totalChanges − changesOnRemovedNodes is the part shown on live nodes — a caller can explain a totalChanges > 0 / empty-nodes result instead of a blank chart. errors (present only when the audit source is unavailable) is a {metric_id: error_string} dict; nodes is empty, degraded is true and changesOnRemovedNodes is null in that case.
Catalog¶
Relation-based node metrics (connections, connectivity, type, location, unresolved and the *_label / *_uuid companions) read a node's effective outgoing relations — own plus inherited — from the canonical resolver domain/effective_relations.py, the same engine the detail panel renders from. Inheritance is decided there by the rulebook (relationInheritance, most-specific-wins, tier-gated) or a relation's inherit_source flag; the metric catalog itself never walks ancestors. GET /metrics/batch injects the live rulebook and tier gate into the engine (rulebook=, tier_can=), so a tree chip and the panel header always show the same location and type.
Batch security invariant¶
GET /metrics/batch exposes per-node metrics to any authenticated caller who can read the node. New node-scope metrics are excluded from the batch endpoint by default and require explicit addition to _BATCH_SAFE in backend/server.py only after confirming the metric cannot leak IAM-restricted subtree information (for example, a descendant count or ancestor-scoped metric would allow any anchor-readable caller to infer the size of IAM-gated subtrees). tests/test_metrics_batch_security.py::test_batch_safe_is_explicit_allowlist pins the exact allowlist and will fail if _BATCH_SAFE is silently expanded.
IAM-asynchronous metrics are a sub-category of _BATCH_SAFE for metrics that
require per-caller identity forwarding via an async cross-domain fetch — something the
synchronous compute_node engine cannot do. The pattern (established #698):
- Register the metric with
@metric(…, async_only=True)incatalog_node.pyso it appears inGET /metrics/catalogand the chip-registry validator. Theasync_only=Trueflag is the explicit exclusion gate:compute_nodefilters out any metric whose catalog entry hasasync_only=Trueand never invokes itsfn. The catalogfnis a defensive sentinel that raisesmetrics.catalog.AsyncOnlyMetric(a typedValueErrorsubclass) if anything bypasses the engine and calls it directly — so an accidental sync invocation surfaces as a typed domain error, not an opaque crash. - Implement an async resolver (e.g.
_compute_<id>_batchin the relevant route module) with bounded fan-out — one upstream fetch per unique id across the batch, concurrency- AND count-capped — under oneasyncio.timeout(Contract 13), returning{node_id: count}. Nodes whose ids would spill past the per-request cap drop their key the same way a timeout would, so a partially-filtered count is never emitted. The count cap (phase-3 doc fetches) is configurable via the config register; seeinfra/config.attached_documents_max_fetches(envATTACHED_DOCUMENTS_MAX_FETCHES, default 200). - Add the metric id to
_BATCH_SAFEafter confirming the async resolver is IAM-correct (never returns a count the caller cannot independently obtain). - In the
metrics_batchhandler, call the async resolver and merge its result dict into the engine response aftercompute_nodereturns.
The async_only=True catalog flag is the gate; AsyncOnlyMetric is the typed
fallback — the sync engine must never be asked to compute an IAM-async metric.
The metric catalog¶
Generated from backend/metrics/catalog.py — the canonical registry. This page
never hand-lists a metric; the table is the single source for every id, scope,
unit, source, merge rule and definition string.
| id | scope | unit | source | merge | definition |
|---|---|---|---|---|---|
subitems |
node | count | graph | — | Direct children of the node (children_of[id]). |
connections |
node | count | graph | — | Effective (own + rulebook-inherited) outgoing internal relations deduped by (predicate, target) plus incoming relations; cross-domain references are excluded (see type/location). |
connectivity |
node | count | graph | — | Effective (own + rulebook-inherited) outgoing relations whose predicate is in the connectivity aspect (isConnectedTo) plus incoming relations with the same predicate. Matches the ConnectionsZone panel header count. Cross-domain isConnectedTo is included for completeness (panel parity) but does not occur in practice. Do NOT confuse with connections (all internal rels, cross-domain excluded) — see #700. |
type |
node | count | graph | — | Effective outgoing cross-domain Type references (% aspect → 'type' domain): own, plus inherited where the rulebook (or the relation's inherit_source flag) says so; deduped on cross-domain target ref. |
location |
node | count | graph | — | Effective outgoing Location references (+ aspect): own, plus inherited where the rulebook (or the relation's inherit_source flag) says so — the detail panel's number; deduped on cross-domain target ref. |
documents |
node | count | graph | — | Engineering entries whose value matches ^(https?|file):// (case-insensitive). |
specifications |
node | count | graph | — | Engineering entries whose value is NOT such a URL. |
unresolved |
node | count | graph | — | Effective outgoing relations that cannot bind: an internal unresolved_ref that key_derivation.resolve_slug cannot bind, or a cross-domain ref whose domain has no public URL. |
changes |
node | count | audit | — | Committed model-data audit events for the node in the last days days (configuration events excluded); relation create/delete/tombstone events are attributed to their source node (payload.sourceId, else payload.from) so a relation change lands on the node it hangs off, not the relation's own id. |
location_label |
node | label | graph | — | Cross-domain target ref of the first effective location, '' if none (BL-FE-129). |
location_uuid |
node | label | graph | — | UUID ('to') of the first effective location node, '' if none (BL-FE-129). |
type_label |
node | label | graph | — | Cross-domain target ref of the first effective type relation, '' if none (BL-FE-162). |
type_uuid |
node | label | graph | — | UUID ('to') of the first effective type node, '' if none (BL-FE-162). |
descendants |
node | count | graph | — | Size of the recursive subtree below the node, excluding the node itself. |
changes_subtree |
node | count | audit | — | changes summed over the node and every descendant (the activity heatmap number; configuration events excluded). |
attached_documents |
node | count | graph | — | IAM-filtered count of attached document nodes for the authenticated caller: exactly the number GET /node/{uid}/documents returns to that caller. Resolved via the canonical hasDocument channel path (direct → hierarchy → relation, #508 rulebook priority) with per-caller identity forwarding. A cheap unfiltered count is not allowed — it would leak restricted doc existence (IAM option a, #698). |
nodes_total |
model | count | graph | sum | Number of effective nodes (base + overlay − tombstoned) in the domain, excluding the structural World node; nodes_by_class still lists World. |
nodes_by_class |
model | dict | graph | dict_sum | Effective nodes grouped by class. |
nodes_by_product_type |
model | dict | graph | dict_sum | Nodes grouped by their productType field (Functional System / Technical System / Component); nodes without one are excluded, never fabricated. |
roots |
model | count | graph | sum | Nodes with no parent. |
tree_edges |
model | count | graph | sum | Sum of direct children over all nodes (= Σ subitems). |
relations_explicit |
model | count | graph | sum | Effective explicit relations (base + overlay − tombstoned). |
relations_inherited |
model | count | graph | sum | Relations materialised by inheritance: each inherit_source relation counts once per descendant of its source node; tombstoned sources excluded. |
relations_by_predicate |
model | dict | graph | dict_sum | relations_explicit grouped by predicate. |
relations_cross_domain |
model | count | graph | sum | Relations whose target lies in another domain (the relation records a target domain). |
relations_total |
model | count | graph | sum | relations_explicit + relations_inherited. |
overlay_nodes |
model | count | graph | sum | Nodes added by the mutation overlay. |
overlay_relations |
model | count | graph | sum | Relations added by the mutation overlay. |
tombstones_nodes |
model | count | graph | sum | Soft-deleted nodes (node tombstones). |
tombstones_relations |
model | count | graph | sum | Soft-deleted relations (relation tombstones). |
hierarchy_depth_max |
model | count | graph | max | Maximum root-to-node depth (root = 0). |
hierarchy_depth_avg |
model | ratio | graph | mean | Mean root-to-leaf depth over all leaves (nodes without children). |
max_container |
model | id_count | graph | max | Node with the most direct children. |
max_relations_node |
model | id_count | graph | max | Node with the most effective relations (in + out). |
orphan_nodes |
model | id_count | graph | sum | Isolated nodes: no parent, no children and no effective relations. |
unresolved_refs |
model | id_count | graph | sum | Nodes with ≥1 unresolved outgoing relation (node unresolved > 0). |
broken_relations |
model | id_count | graph | sum | Relations whose from is not an effective node, or whose to is set, internal, and not an effective node. |
duplicate_designations |
model | id_count | graph | sum | Reference designations shared by ≥2 effective nodes in the domain. |
predicate_coverage |
model | dict | graph | mean | {predicate: share of product nodes with ≥1 outgoing relation of that predicate} for every predicate in the relation catalog; a product node is a node whose productType is not null. |
document_coverage |
model | ratio | graph | mean | Share of product nodes (productType not null) with ≥1 document resolved through the canonical hasDocument channel resolution — direct, then hierarchy, then relation (the #508 documentPropagation rules, the same routing the node's Documents zone uses). Admin-scope: served only under the model-metrics gate (admin_or_service) and not filtered by the caller's permissions. null when the domain has no product nodes (never 0). partial=true when a relation-channel target in another domain could not be resolved, so the share may be low. |
changes_total |
model | count | audit | sum | Committed model-data audit events in the requested days window with a non-empty entity_id. Configuration events (IAM / rulebook / propagation admin actions) and events with no attributable entity are excluded — this counts attributable changes to the model, not configuration churn. Note: an event attributed to a since-deleted node is still counted (a deletion is a real, historically stable change); see /activity/heatmap changesOnRemovedNodes. |
changes_7d |
model | count | audit | sum | Committed model-data audit events in the last 7 days (configuration events excluded). |
changes_30d |
model | count | audit | sum | Committed model-data audit events in the last 30 days (configuration events excluded). |
changes_by_day |
model | dict | audit | dict_sum | Committed model-data audit events per calendar day, last 30 days (configuration events excluded). |
active_users_30d |
model | count | audit | sum | Distinct actors with ≥1 committed model-data audit event in the last 30 days (configuration-only actors excluded). |
active_users |
model | count | audit | sum | Distinct actors with ≥1 committed model-data audit event in the requested days window (configuration-only actors excluded). |
interfaces_total |
model | count | iis | none | Detected interfaces (IIS store total). |
interfaces_by_status |
model | dict | iis | none | Detected interfaces grouped by status. |
interfaces_by_criticality |
model | dict | iis | none | Detected interfaces grouped by criticality class. |
Frozen dash contract (eidosdash)¶
The eidosdash dashboard consumes a frozen subset of the model-scope metrics, read straight from the FME archive's eidos:metrics (W2, #656). The current definition-meaning version is m2 (metrics.catalog.DEFINITION_VERSION, stamped as eidos:metricsDefinitionVersion); it is bumped whenever a frozen metric's meaning changes. The 27 frozen ids:
nodes_totalnodes_by_classnodes_by_product_typerelations_explicitrelations_inheritedrelations_totalrelations_by_predicaterelations_cross_domaintombstones_nodestombstones_relationshierarchy_depth_maxhierarchy_depth_avgmax_containermax_relations_nodeorphan_nodesunresolved_refsbroken_relationsduplicate_designationspredicate_coveragechanges_totalchanges_7dchanges_30dchanges_by_dayactive_users_30dinterfaces_totalinterfaces_by_statusinterfaces_by_criticality
Appended to the contract after the 27 ids were frozen (additive — an appended id changes no existing number, so the append bumps no version; pinned by their own digest):
document_coverage
What the definition version means¶
This is the change log for eidos:metricsDefinitionVersion (the contract's honesty
clause, W2 addendum b): what each version's frozen numbers mean, so eidosdash
never compares numbers that were computed under different definitions. It is a
change log, not a restatement of the definitions — the exact wording lives in the
catalog table above. When catalog.DEFINITION_VERSION is bumped, a new entry
MUST be added here in the same change — a test fails otherwise.
m1¶
The first frozen version (W2, #656). What changed in the model-scope numbers going
into m1, relative to pre-W2:
changes_total,changes_7d/30d,changes_by_day,active_users*: configuration events (IAM, rulebook, propagation admin actions) are excluded (#652), and events with no attributableentity_idare not counted (#654). DEV example:changes_totalwent1997 → 1972.- Relation-change attribution: a relation create / delete / tombstone is charged
to its source node (#654). This shifts node-scope
changesand the/activity/heatmap— not the frozen model-scope ids. Dash should not look for an effect on the 27 frozen ids from this change. relations_by_predicatecounts explicit relations only; the inherited expansions are carried byrelations_inherited(#626).
Added within m1 (no bump): document_coverage (#1142) is appended to the
dash contract. It is a new id, so no existing number changes meaning; the 27 frozen
definitions and their digest are unchanged, and the new id is pinned by its own digest.
Consequence for Dash: an archive whose metrics came from Dash's own
collector-fallback (metrics_source: "collector-fallback", i.e. no
eidos:metricsDefinitionVersion) was computed under the pre-m1 definitions and
must not be placed in the same time series as m1 numbers.
m2¶
nodes_total no longer counts the structural World node (#1152). Every
domain's node map carries one World node: the parent of the domain root, not a
model object. m1 counted it, so the catalog was one higher than the count of the
domain's model nodes. The node is matched by its fixed UID, not by its class. Only
the number changes: nodes_by_class, roots and tree_edges still include World,
so the excluded node stays visible. A parent-less node that is not World, such as a
stray second root, is still counted. EIDOS's own UI (the server-info panel and the
per-domain rows) reads nodes_total from the catalog, so it shows the same number.
DEV example: location went 4915 → 4914.
Consequence for Dash: an m1 archive's nodes_total is one higher per domain
than an m2 archive's (the all-domain sum is higher by the number of domains). Do
not place nodes_total from both versions in the same time series.