katalyst-forge 5.1.0__tar.gz → 5.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/.gitignore +8 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/CHANGELOG.md +24 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/PKG-INFO +1 -1
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/pyproject.toml +6 -1
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/schemas/nodes/organizational.schema.json +4 -0
- katalyst_forge-5.2.0/src/katalyst_forge/metrics/__init__.py +57 -0
- katalyst_forge-5.2.0/src/katalyst_forge/metrics/batch.py +174 -0
- katalyst_forge-5.2.0/src/katalyst_forge/metrics/billing.py +35 -0
- katalyst_forge-5.2.0/src/katalyst_forge/metrics/contracts.py +128 -0
- katalyst_forge-5.2.0/src/katalyst_forge/metrics/core.py +385 -0
- katalyst_forge-5.2.0/src/katalyst_forge/metrics/dashboard.py +214 -0
- katalyst_forge-5.2.0/src/katalyst_forge/metrics/errors.py +122 -0
- katalyst_forge-5.2.0/src/katalyst_forge/metrics/generate_dashboard.py +245 -0
- katalyst_forge-5.2.0/src/katalyst_forge/metrics/html_report.py +383 -0
- katalyst_forge-5.2.0/src/katalyst_forge/metrics/money.py +43 -0
- katalyst_forge-5.2.0/src/katalyst_forge/metrics/protocols.py +83 -0
- katalyst_forge-5.2.0/src/katalyst_forge/metrics/providers/__init__.py +9 -0
- katalyst_forge-5.2.0/src/katalyst_forge/metrics/providers/forge_model_router.py +100 -0
- katalyst_forge-5.2.0/src/katalyst_forge/metrics/providers/llm_gateway.py +20 -0
- katalyst_forge-5.2.0/src/katalyst_forge/metrics/providers/opencode.py +269 -0
- katalyst_forge-5.2.0/src/katalyst_forge/metrics/redaction.py +43 -0
- katalyst_forge-5.2.0/src/katalyst_forge/metrics/registry.py +57 -0
- katalyst_forge-5.2.0/src/katalyst_forge/metrics/routing_policy.py +97 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/relationships.py +13 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/wings/metrics.py +2 -2
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/wings/practices.py +1 -1
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/templates/CHANGELOG.md +147 -0
- katalyst_forge-5.2.0/tests/fake_providers_opencode.py +38 -0
- katalyst_forge-5.2.0/tests/fake_providers_router.py +31 -0
- katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/expected-modified.txt +2 -0
- katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/expected-new.txt +16 -0
- katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/modified-inplace.patch +124 -0
- katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/delivery/work_items/ric-1580-forge-model-routing.yaml +20 -0
- katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/capabilities/CAP-011-per-dispatch-model-tier-routing.md +110 -0
- katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/capabilities/CAP-012-dispatch-parity-and-containment.md +109 -0
- katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/capabilities/CAP-013-per-stage-model-token-telemetry.md +99 -0
- katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/nfrs/dispatched-stage-executes-on-requested-tier.yaml +48 -0
- katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/nfrs/routed-dispatch-preserves-task-parity.yaml +46 -0
- katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/nfrs/tier-resolution-fails-before-any-child-session.yaml +48 -0
- katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/nfrs/zero-committed-vendor-model-identity.yaml +45 -0
- katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-024-operator-selects-model-tier-per-forge-run.md +81 -0
- katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-025-model-tiers-from-env-vars-no-vendor-pins.md +85 -0
- katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-026-routing-tier-recorded-on-work-item.md +73 -0
- katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-027-per-stage-model-and-token-telemetry.md +80 -0
- katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-028-dispatch-tool-enforces-agent-allowlist.md +76 -0
- katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-029-silent-or-aborted-dispatch-yields-blocked.md +83 -0
- katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-030-dispatched-write-stage-stays-in-worktree.md +73 -0
- katalyst_forge-5.2.0/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-031-single-stage-spike-preserves-eleven-stage-rollout.md +76 -0
- katalyst_forge-5.2.0/tests/forge-reconcile/reconcile.test.mjs +182 -0
- katalyst_forge-5.2.0/tests/test_cost_observability_wing.py +224 -0
- katalyst_forge-5.2.0/tests/test_metrics_batch_hygiene.py +241 -0
- katalyst_forge-5.2.0/tests/test_metrics_billing_isolation.py +112 -0
- katalyst_forge-5.2.0/tests/test_metrics_content_contract.py +307 -0
- katalyst_forge-5.2.0/tests/test_metrics_contracts.py +95 -0
- katalyst_forge-5.2.0/tests/test_metrics_dashboard.py +228 -0
- katalyst_forge-5.2.0/tests/test_metrics_dashboard_end_to_end.py +203 -0
- katalyst_forge-5.2.0/tests/test_metrics_html_report.py +182 -0
- katalyst_forge-5.2.0/tests/test_metrics_lazy_providers.py +102 -0
- katalyst_forge-5.2.0/tests/test_metrics_money_and_redaction.py +182 -0
- katalyst_forge-5.2.0/tests/test_metrics_providers.py +419 -0
- katalyst_forge-5.2.0/tests/test_metrics_resolve_compute.py +477 -0
- katalyst_forge-5.2.0/tests/test_metrics_routing_policy.py +102 -0
- katalyst_forge-5.2.0/tests/test_metrics_wing.py +64 -0
- katalyst_forge-5.2.0/tests/test_relationships.py +34 -0
- katalyst_forge-5.2.0/tests/test_validate_templates_gates.py +259 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/tests/test_wing_schemas.py +15 -0
- katalyst_forge-5.2.0/tests/test_writer_bundle_schema.py +117 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/LICENSE +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/schemas/CHANGELOG.md +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/schemas/README.md +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/schemas/agents/agent.schema.json +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/schemas/agents/config.schema.json +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/schemas/agents/manifest.json +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/schemas/agents/skill.schema.json +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/schemas/environment.schema.json +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/schemas/hooks/hook-config.schema.json +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/schemas/katalyst-lock.schema.json +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/schemas/nodes/common.schema.json +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/schemas/nodes/design.schema.json +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/schemas/nodes/portfolio.schema.json +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/schemas/nodes/technical.schema.json +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/schemas/taxonomy-node.schema.json +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/__init__.py +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/bundle.py +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/py.typed +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/creation/_base.yaml.j2 +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/creation/layer.yaml.j2 +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/creation/project.yaml.j2 +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/creation/stack.yaml.j2 +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/creation/system.yaml.j2 +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/creation/team.yaml.j2 +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/creation/team_member.yaml.j2 +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/app-docker/.dockerignore +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/app-docker/.env.example +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/app-docker/README.md +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/app-docker/base/Dockerfile +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/app-docker/docker-compose.yaml +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/app-docker/env-template/Dockerfile +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/app-docker/env-template/docker-compose.override.yaml +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/app-docker/env-template/environment.yaml +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/app-docker/layer.yaml +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/README.md +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/composite/terraform/main.tf +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/composite/terraform/outputs.tf +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/composite/terraform/variables.tf +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/env-template/environment.yaml +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/env-template/terragrunt.hcl +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/global/iac/context.hcl +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/global/iac/dev.yaml +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/global/iac/prod.yaml +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/global/iac/staging.yaml +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/layer.yaml +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/version.yaml +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/README.md +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/base/app/argocd-application.yaml +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/base/kustomization.yaml +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/base/namespace.yaml +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/environment-template/argocd-application.yaml +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/environment-template/kustomization.yaml +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/environment-template/patch-argocd-application.yaml +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/layer.yaml +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/README.md +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/base/deployment.yaml +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/base/kustomization.yaml +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/base/namespace.yaml +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/base/service.yaml +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/environment-template/kustomization.yaml +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/environment-template/patch-deployment.yaml +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/global/k8s/base/kustomization.yaml +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/global/k8s/base/namespace.yaml +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/global/k8s/kustomization.yaml +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/layer.yaml +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/wings/__init__.py +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/wings/_practice_refs.py +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/wings/agentic.py +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/wings/delivery.py +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/wings/design.py +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/wings/org.py +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/wings/pipeline.py +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/src/katalyst_forge/wings/system.py +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/tests/conftest.py +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/tests/test_agent_run.py +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/tests/test_delivery_doc_consistency.py +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/tests/test_delivery_traceability.py +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/tests/test_entry_point_discovery.py +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/tests/test_forge.py +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/tests/test_practice_assignment.py +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/tests/test_storage_contract.py +0 -0
- {katalyst_forge-5.1.0 → katalyst_forge-5.2.0}/tests/test_wings.py +0 -0
|
@@ -21,3 +21,11 @@ orientation/packet.json
|
|
|
21
21
|
# Machine-local orientation fallback config (RIC-731): holds an absolute packetPath,
|
|
22
22
|
# written beside the installed contract; never source, never commit.
|
|
23
23
|
orientation-config.json
|
|
24
|
+
|
|
25
|
+
# bun.lock is the committed lockfile; npm's is not
|
|
26
|
+
package-lock.json
|
|
27
|
+
|
|
28
|
+
# Watchdog runtime state (RIC-1592): markers and ledger entries are produced at runtime
|
|
29
|
+
# outside any repo, under KATALYST_WATCHDOG_STATE_DIR. Ignored so a stray write can never
|
|
30
|
+
# pollute the working tree that RIC-1591 has to diff cleanly.
|
|
31
|
+
.katalyst-watchdog/
|
|
@@ -16,6 +16,30 @@ PyPI package version. A breaking node-type/model change bumps `schema_version`.
|
|
|
16
16
|
|
|
17
17
|
---
|
|
18
18
|
|
|
19
|
+
## [5.2.0] -- 2026-08-08
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **Cost observability as metrics-wing content** (RIC-1590). Run cost is modelled as `metric_definition` plus `signal_source` content rather than bespoke reporting code. Ships `src/katalyst_forge/metrics/` with a pure computation core (no I/O), a deny-by-default projection into `CanonicalSignalBatch`, `SignalProvider` / `PriceCatalogue` / `RoutingPolicy` protocols, lazily resolved providers for OpenCode and forge-model-router, a text dashboard, an HTML report, and the `cost-observability` template bundle with its global action and Just recipe. Metered and subscription-notional spend are kept apart by construction: `MetricResult` is keyed by billing mode and offers no total, and `__add__`, `__radd__` and `__float__` raise.
|
|
24
|
+
- **Routing comparison policy** (RIC-1616). `routing-headroom` and `routing-savings-realized` can now produce a figure. The comparison models come from the environment (`FORGE_COST_ORCHESTRATOR_MODEL`, `FORGE_COST_TARGET_MODEL`, `FORGE_COST_TARGET_MODEL_<STAGE>`), so no model identifier is hard-coded in code or content. An unconfigured environment yields no policy, leaving the previous behaviour of absent figures rather than zeros.
|
|
25
|
+
- **`optional_fields` on a signal source** (RIC-1617). A provider-synthesized value that may legitimately be absent, such as a Forge stage inferred from a session that is not a Forge stage, no longer discards the whole batch.
|
|
26
|
+
- **Bundle content and non-ownership in the writer reference** (RIC-1621). `kata-tax-writer` now has schema entries for template-bundle content, and explicitly disclaims `tests/`, `src/`, `scripts/` and `.github/workflows/`.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- **The metered cost path never returned a record** (RIC-1615). The OpenCode adapter read its root sessions from a `query_config` key that only ever existed in a test fixture, so every real run walked an empty session list. Run identity is now an explicit argument on `SignalProvider.fetch`, interpreted per adapter.
|
|
31
|
+
- **The session walk dropped spend it should have counted** (RIC-1615). It began at the root's children, so an orchestrator's own tokens went uncounted and a single-session run reported nothing, and it descended only one level even though the children endpoint returns direct children only. It now yields the root and every transitive descendant once each.
|
|
32
|
+
- **Router telemetry was rejected outright** (RIC-1615). A real run writes `cost_usd` as a JSON number; decoding without `parse_float=Decimal` lost the exactness `parse_money` requires and aborted the dashboard.
|
|
33
|
+
- **The price catalogue was built eagerly** (RIC-1615), demanding OpenCode credentials from users who only wanted router figures. It is now built on first price lookup.
|
|
34
|
+
- **`routing-headroom` reported a fabricated zero** (RIC-1616) when no record had a resolvable comparison target. A rendered `$0.00` reads as routing already being optimal rather than as routing never having been measured, so it now yields no figure unless a comparison was made.
|
|
35
|
+
- **One uncomputable metric aborted the whole report** (RIC-1616). `compute` is now isolated per metric, matching the per-source isolation already in place.
|
|
36
|
+
- **The action schema documentation did not match the loader** (RIC-1619). `katalyst-action-builder` documented `kind: Action` on an unrecognised apiVersion with `spec.type`, `spec.timeoutSeconds` and `spec.tags`; following it produced zero actions and, for the kind and apiVersion errors, zero errors as well.
|
|
37
|
+
- **The writer's Kind Mapping carried the same defect** (RIC-1621). `plugin-action` mapped to `kind: Action` on `katalyst.taxonomy.plugins.actions/v1alpha`, so the single writer would have produced action files that no loader reads.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- CI now runs `ruff format --check` over `src`, `tests` and `scripts` (RIC-1620), with the pre-existing drift reformatted first so the gate did not turn open branches red.
|
|
42
|
+
|
|
19
43
|
## [5.1.0] -- 2026-07-28
|
|
20
44
|
|
|
21
45
|
- **Canonical ADR/NFR storage** (RIC-1358) — ADR and NFR definitions now declare flat identity-derived storage under `.global/taxonomy/design/adrs/` and `.global/taxonomy/design/nfrs/`. Forge agents, skills, templates, fixtures, and documentation use the plural directories; legacy multi-document examples are labeled explicitly.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "katalyst-forge"
|
|
3
|
-
version = "5.
|
|
3
|
+
version = "5.2.0"
|
|
4
4
|
description = "Katalyst Forge: the delivery-lifecycle bundle (node kinds, wings, agents, templates, schemas)"
|
|
5
5
|
license = { text = "Proprietary" }
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -66,6 +66,11 @@ dev = [
|
|
|
66
66
|
# test_wing_schemas.py validates the shipped JSON Schemas
|
|
67
67
|
"jsonschema>=4.22",
|
|
68
68
|
"referencing>=0.35",
|
|
69
|
+
# test_validate_templates_gates.py loads scripts/validate-templates.py, which
|
|
70
|
+
# imports these. CI's templates-validate job installs them ad hoc; the pytest
|
|
71
|
+
# job needs them in its own environment to exercise the gate logic.
|
|
72
|
+
"pyyaml>=6",
|
|
73
|
+
"jinja2>=3.1",
|
|
69
74
|
]
|
|
70
75
|
|
|
71
76
|
[tool.ruff]
|
|
@@ -96,6 +96,10 @@
|
|
|
96
96
|
"type": "string",
|
|
97
97
|
"minLength": 1,
|
|
98
98
|
"description": "Human-readable display name for the competency (e.g. 'Kubernetes Administration')."
|
|
99
|
+
},
|
|
100
|
+
"competency_type": {
|
|
101
|
+
"type": "string",
|
|
102
|
+
"description": "Optional free-form kind of competency: practice, system, or tools. No allowed_values enum is enforced."
|
|
99
103
|
}
|
|
100
104
|
}
|
|
101
105
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Public API for the cost/observability metrics adapter.
|
|
2
|
+
|
|
3
|
+
Must NOT import anything from .providers, which would defeat the registry's
|
|
4
|
+
lazy provider resolution (see registry.py and providers/__init__.py).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from katalyst_forge.metrics.batch import CanonicalSignalBatch, project_batch
|
|
10
|
+
from katalyst_forge.metrics.billing import BillingMode
|
|
11
|
+
from katalyst_forge.metrics.core import MetricResult, compute, resolve
|
|
12
|
+
from katalyst_forge.metrics.errors import (
|
|
13
|
+
BatchConstructionError,
|
|
14
|
+
ContractViolationError,
|
|
15
|
+
CrossBillingModeError,
|
|
16
|
+
MetricsAdapterError,
|
|
17
|
+
MissingCredentialError,
|
|
18
|
+
MissingMappedFieldError,
|
|
19
|
+
MoneyPrecisionError,
|
|
20
|
+
PriceUnavailableError,
|
|
21
|
+
ProviderPayloadError,
|
|
22
|
+
ProviderUnavailableError,
|
|
23
|
+
UnknownBillingModeError,
|
|
24
|
+
UnknownProviderError,
|
|
25
|
+
UnmappedFieldError,
|
|
26
|
+
)
|
|
27
|
+
from katalyst_forge.metrics.money import parse_money
|
|
28
|
+
from katalyst_forge.metrics.protocols import PriceCatalogue, SignalProvider
|
|
29
|
+
from katalyst_forge.metrics.redaction import redact
|
|
30
|
+
from katalyst_forge.metrics.registry import SignalProviderRegistry
|
|
31
|
+
|
|
32
|
+
__all__ = (
|
|
33
|
+
"BillingMode",
|
|
34
|
+
"CanonicalSignalBatch",
|
|
35
|
+
"project_batch",
|
|
36
|
+
"MetricResult",
|
|
37
|
+
"resolve",
|
|
38
|
+
"compute",
|
|
39
|
+
"SignalProviderRegistry",
|
|
40
|
+
"SignalProvider",
|
|
41
|
+
"PriceCatalogue",
|
|
42
|
+
"parse_money",
|
|
43
|
+
"redact",
|
|
44
|
+
"MetricsAdapterError",
|
|
45
|
+
"BatchConstructionError",
|
|
46
|
+
"UnmappedFieldError",
|
|
47
|
+
"MissingMappedFieldError",
|
|
48
|
+
"ContractViolationError",
|
|
49
|
+
"MoneyPrecisionError",
|
|
50
|
+
"PriceUnavailableError",
|
|
51
|
+
"UnknownBillingModeError",
|
|
52
|
+
"CrossBillingModeError",
|
|
53
|
+
"UnknownProviderError",
|
|
54
|
+
"MissingCredentialError",
|
|
55
|
+
"ProviderUnavailableError",
|
|
56
|
+
"ProviderPayloadError",
|
|
57
|
+
)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Canonical signal batch: deny-by-default projection and a non-bypassable type.
|
|
2
|
+
|
|
3
|
+
CanonicalSignalBatch can only be produced by project_batch. The constructor
|
|
4
|
+
requires a private guard sentinel that lives only in this module, so no
|
|
5
|
+
outside code, including copy.copy, copy.deepcopy, and pickle, can forge or
|
|
6
|
+
clone an instance. Projection itself is deny-by-default: only canonical
|
|
7
|
+
fields named in a source's field_mapping are ever copied out of a provider
|
|
8
|
+
record, so any field a provider returns but nobody mapped (including
|
|
9
|
+
prompt-shaped fields such as "instructions" or "system_prompt") is dropped,
|
|
10
|
+
never passed through.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Mapping, Sequence
|
|
16
|
+
from types import MappingProxyType
|
|
17
|
+
from typing import Final, cast
|
|
18
|
+
|
|
19
|
+
from katalyst_forge.metrics.billing import BillingMode
|
|
20
|
+
from katalyst_forge.metrics.errors import BatchConstructionError, MissingMappedFieldError
|
|
21
|
+
|
|
22
|
+
__all__ = ("CanonicalSignalBatch", "project_batch")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _ProjectionGuard:
|
|
26
|
+
"""Sentinel type; only _GUARD below is a valid instance of it."""
|
|
27
|
+
|
|
28
|
+
__slots__ = ()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_GUARD: Final[_ProjectionGuard] = _ProjectionGuard()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CanonicalSignalBatch:
|
|
35
|
+
"""An immutable, already-projected batch of canonical signal records."""
|
|
36
|
+
|
|
37
|
+
__slots__ = ("_billing_mode", "_run_id", "_source_name", "_records")
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
_guard: _ProjectionGuard,
|
|
42
|
+
*,
|
|
43
|
+
billing_mode: BillingMode,
|
|
44
|
+
run_id: str,
|
|
45
|
+
source_name: str,
|
|
46
|
+
records: tuple[Mapping[str, object], ...],
|
|
47
|
+
) -> None:
|
|
48
|
+
if _guard is not _GUARD:
|
|
49
|
+
raise BatchConstructionError("only project_batch may construct a CanonicalSignalBatch")
|
|
50
|
+
self._billing_mode = billing_mode
|
|
51
|
+
self._run_id = run_id
|
|
52
|
+
self._source_name = source_name
|
|
53
|
+
self._records = records
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def billing_mode(self) -> BillingMode:
|
|
57
|
+
return self._billing_mode
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def run_id(self) -> str:
|
|
61
|
+
return self._run_id
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def source_name(self) -> str:
|
|
65
|
+
return self._source_name
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def records(self) -> tuple[Mapping[str, object], ...]:
|
|
69
|
+
return self._records
|
|
70
|
+
|
|
71
|
+
def __copy__(self) -> CanonicalSignalBatch:
|
|
72
|
+
raise BatchConstructionError("CanonicalSignalBatch cannot be copied")
|
|
73
|
+
|
|
74
|
+
def __deepcopy__(self, memo: dict[int, object]) -> CanonicalSignalBatch:
|
|
75
|
+
raise BatchConstructionError("CanonicalSignalBatch cannot be deepcopied")
|
|
76
|
+
|
|
77
|
+
def __reduce__(self) -> tuple[object, ...]:
|
|
78
|
+
raise BatchConstructionError("CanonicalSignalBatch cannot be pickled")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _as_mapping(value: object) -> Mapping[str, object] | None:
|
|
82
|
+
"""Narrow value to a concretely-typed Mapping, or None if it is not one."""
|
|
83
|
+
if isinstance(value, Mapping):
|
|
84
|
+
return cast("Mapping[str, object]", value)
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _get_mapping(doc: Mapping[str, object], key: str) -> Mapping[str, object]:
|
|
89
|
+
"""Read doc[key] as a Mapping, defaulting to empty rather than raising."""
|
|
90
|
+
mapped = _as_mapping(doc.get(key))
|
|
91
|
+
return mapped if mapped is not None else {}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _str_frozenset(value: object) -> frozenset[str]:
|
|
95
|
+
"""Read a YAML string list as a set, tolerating a bare string or absence."""
|
|
96
|
+
if isinstance(value, str):
|
|
97
|
+
return frozenset({value})
|
|
98
|
+
if isinstance(value, (list, tuple)):
|
|
99
|
+
items = cast("Sequence[object]", value)
|
|
100
|
+
return frozenset(item for item in items if isinstance(item, str))
|
|
101
|
+
return frozenset()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _read_path(record: Mapping[str, object], path: str) -> object | None:
|
|
105
|
+
"""Walk a dotted path through record, returning None on any non-Mapping hop."""
|
|
106
|
+
current: object = record
|
|
107
|
+
for segment in path.split("."):
|
|
108
|
+
current_mapping = _as_mapping(current)
|
|
109
|
+
if current_mapping is None:
|
|
110
|
+
return None
|
|
111
|
+
current = current_mapping.get(segment)
|
|
112
|
+
return current
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def project_batch(
|
|
116
|
+
*,
|
|
117
|
+
signal_source_doc: Mapping[str, object],
|
|
118
|
+
records: Sequence[Mapping[str, object]],
|
|
119
|
+
billing_mode: BillingMode,
|
|
120
|
+
run_id: str,
|
|
121
|
+
source_name: str,
|
|
122
|
+
) -> CanonicalSignalBatch:
|
|
123
|
+
"""Deny-by-default projection: keep only canonical fields named in field_mapping.
|
|
124
|
+
|
|
125
|
+
field_mapping is canonical_input_name -> provider.dotted.path. For every
|
|
126
|
+
provider record, a new dict is built containing only those canonical
|
|
127
|
+
keys, each pulled from the mapped path. A field the provider returns but
|
|
128
|
+
no mapping references is never copied. A mapped path missing from one
|
|
129
|
+
record is simply omitted from that record's projection; a mapping
|
|
130
|
+
that resolves for zero records across the whole batch is an error, since
|
|
131
|
+
that indicates the mapping itself is wrong rather than sparse data, unless
|
|
132
|
+
the source names the field in optional_fields.
|
|
133
|
+
|
|
134
|
+
optional_fields exists for a value the provider synthesizes and may
|
|
135
|
+
legitimately not have, such as a Forge stage inferred from a session that is
|
|
136
|
+
not a Forge stage at all. Holding such a field to the zero-resolution guard
|
|
137
|
+
discarded a run's entire cost rather than only its stage attribution, so a
|
|
138
|
+
total vanished because one attribution dimension was absent. The trade is
|
|
139
|
+
that a typo in an optional field's path stops being caught, which is why it
|
|
140
|
+
is declared per source rather than assumed.
|
|
141
|
+
"""
|
|
142
|
+
spec = _get_mapping(signal_source_doc, "spec")
|
|
143
|
+
field_mapping = _get_mapping(spec, "field_mapping")
|
|
144
|
+
optional_fields = _str_frozenset(spec.get("optional_fields"))
|
|
145
|
+
|
|
146
|
+
canonical_paths: tuple[tuple[str, str], ...] = tuple(
|
|
147
|
+
(canonical_key, path)
|
|
148
|
+
for canonical_key, path in field_mapping.items()
|
|
149
|
+
if isinstance(path, str)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
resolved_counts: dict[str, int] = {canonical_key: 0 for canonical_key, _ in canonical_paths}
|
|
153
|
+
projected_records: list[Mapping[str, object]] = []
|
|
154
|
+
|
|
155
|
+
for record in records:
|
|
156
|
+
projected: dict[str, object] = {}
|
|
157
|
+
for canonical_key, path in canonical_paths:
|
|
158
|
+
value = _read_path(record, path)
|
|
159
|
+
if value is not None:
|
|
160
|
+
projected[canonical_key] = value
|
|
161
|
+
resolved_counts[canonical_key] += 1
|
|
162
|
+
projected_records.append(MappingProxyType(projected))
|
|
163
|
+
|
|
164
|
+
for canonical_key, count in resolved_counts.items():
|
|
165
|
+
if count == 0 and canonical_key not in optional_fields:
|
|
166
|
+
raise MissingMappedFieldError(canonical_field=canonical_key, source_name=source_name)
|
|
167
|
+
|
|
168
|
+
return CanonicalSignalBatch(
|
|
169
|
+
_GUARD,
|
|
170
|
+
billing_mode=billing_mode,
|
|
171
|
+
run_id=run_id,
|
|
172
|
+
source_name=source_name,
|
|
173
|
+
records=tuple(projected_records),
|
|
174
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Billing mode enum and strict parsing (metered vs. subscription-notional)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
from katalyst_forge.metrics.errors import UnknownBillingModeError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BillingMode(StrEnum):
|
|
11
|
+
"""The two ways a signal source's cost figures are billed.
|
|
12
|
+
|
|
13
|
+
METERED means the provider charges per unit consumed. SUBSCRIPTION_NOTIONAL
|
|
14
|
+
means the spend is a flat subscription and the figure is a notional
|
|
15
|
+
allocation, not an invoice line. These are different quantities and must
|
|
16
|
+
never be combined (see core.MetricResult).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
METERED = "metered"
|
|
20
|
+
SUBSCRIPTION_NOTIONAL = "subscription_notional"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_billing_mode(raw: object) -> BillingMode:
|
|
24
|
+
"""Parse a billing mode from a document value, never defaulting.
|
|
25
|
+
|
|
26
|
+
A missing or unrecognized billing mode is a contract error, not a hint to
|
|
27
|
+
guess metered as a safe default; guessing wrong silently mislabels cost.
|
|
28
|
+
"""
|
|
29
|
+
if isinstance(raw, BillingMode):
|
|
30
|
+
return raw
|
|
31
|
+
if isinstance(raw, str):
|
|
32
|
+
for mode in BillingMode:
|
|
33
|
+
if mode.value == raw:
|
|
34
|
+
return mode
|
|
35
|
+
raise UnknownBillingModeError(raw)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Contract validation: does a source's field_mapping cover a metric's inputs?
|
|
2
|
+
|
|
3
|
+
field_mapping is canonical_input_name -> provider.dotted.path, so "does this
|
|
4
|
+
source cover this metric's inputs" is checked against the mapping's KEYS
|
|
5
|
+
(the canonical side), never the provider paths on the right. unit_price_usd
|
|
6
|
+
is exempt from coverage because it is supplied by the injected price
|
|
7
|
+
catalogue at compute time, not projected out of a provider record. Inputs
|
|
8
|
+
listed in BILLING_MODE_SCOPED_INPUTS are exempt in any billing mode other
|
|
9
|
+
than the ones listed, because the quantity they describe (e.g. headroom
|
|
10
|
+
against a subscription cap) simply does not exist outside that mode.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Mapping, Sequence
|
|
16
|
+
from typing import Final, cast
|
|
17
|
+
|
|
18
|
+
from katalyst_forge.metrics.billing import BillingMode
|
|
19
|
+
from katalyst_forge.metrics.errors import ContractViolationError
|
|
20
|
+
|
|
21
|
+
CATALOGUE_PROVIDED_INPUTS: Final[frozenset[str]] = frozenset({"unit_price_usd"})
|
|
22
|
+
|
|
23
|
+
# Supplied by the resolve() caller, not projected from a provider record.
|
|
24
|
+
# Neither provider carries a Forge run identifier in its payload, so requiring
|
|
25
|
+
# a field_mapping entry for run_id would mean naming a path that does not exist.
|
|
26
|
+
CONTEXT_PROVIDED_INPUTS: Final[frozenset[str]] = frozenset({"run_id"})
|
|
27
|
+
|
|
28
|
+
# Supplied by the injected RoutingPolicy. A routing counterfactual compares
|
|
29
|
+
# against a model nobody's payload reports, so these are policy, not data.
|
|
30
|
+
POLICY_PROVIDED_INPUTS: Final[frozenset[str]] = frozenset(
|
|
31
|
+
{"orchestrator_model", "routing_target_model"}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
BILLING_MODE_SCOPED_INPUTS: Final[Mapping[str, frozenset[BillingMode]]] = {
|
|
35
|
+
"metered_cost_usd": frozenset({BillingMode.METERED}),
|
|
36
|
+
"notional_cost_usd": frozenset({BillingMode.SUBSCRIPTION_NOTIONAL}),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _as_mapping(value: object) -> Mapping[str, object] | None:
|
|
41
|
+
if isinstance(value, Mapping):
|
|
42
|
+
return cast("Mapping[str, object]", value)
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _get_mapping(doc: Mapping[str, object], key: str) -> Mapping[str, object]:
|
|
47
|
+
mapped = _as_mapping(doc.get(key))
|
|
48
|
+
return mapped if mapped is not None else {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _str_tuple(value: object) -> tuple[str, ...]:
|
|
52
|
+
"""Normalise a scalar-or-list document field to a tuple of strings.
|
|
53
|
+
|
|
54
|
+
A bare string must normalise to a one-element tuple, not an empty one.
|
|
55
|
+
metric_ref widened from a scalar to a list in slice A and stayed
|
|
56
|
+
backwards compatible, so the two shipped scalar docs still reach here as
|
|
57
|
+
plain strings; returning () for them would silently skip contract
|
|
58
|
+
validation entirely rather than checking it.
|
|
59
|
+
"""
|
|
60
|
+
if isinstance(value, str):
|
|
61
|
+
return (value,)
|
|
62
|
+
if isinstance(value, (list, tuple)):
|
|
63
|
+
items = cast("Sequence[object]", value)
|
|
64
|
+
return tuple(item for item in items if isinstance(item, str))
|
|
65
|
+
return ()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def required_inputs(
|
|
69
|
+
metric_definition_doc: Mapping[str, object], billing_mode: BillingMode
|
|
70
|
+
) -> frozenset[str]:
|
|
71
|
+
"""Return the metric's inputs a source must map, given a billing mode."""
|
|
72
|
+
spec = _get_mapping(metric_definition_doc, "spec")
|
|
73
|
+
inputs = frozenset(_str_tuple(spec.get("inputs")))
|
|
74
|
+
|
|
75
|
+
scoped_out = {
|
|
76
|
+
name
|
|
77
|
+
for name in inputs
|
|
78
|
+
if name in BILLING_MODE_SCOPED_INPUTS
|
|
79
|
+
and billing_mode not in BILLING_MODE_SCOPED_INPUTS[name]
|
|
80
|
+
}
|
|
81
|
+
return (
|
|
82
|
+
inputs
|
|
83
|
+
- CATALOGUE_PROVIDED_INPUTS
|
|
84
|
+
- CONTEXT_PROVIDED_INPUTS
|
|
85
|
+
- POLICY_PROVIDED_INPUTS
|
|
86
|
+
- scoped_out
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def validate_source_contract(
|
|
91
|
+
*,
|
|
92
|
+
signal_source_doc: Mapping[str, object],
|
|
93
|
+
metric_definitions: Mapping[str, Mapping[str, object]],
|
|
94
|
+
billing_mode: BillingMode,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Raise ContractViolationError if source's field_mapping under-covers a metric_ref."""
|
|
97
|
+
spec = _get_mapping(signal_source_doc, "spec")
|
|
98
|
+
source_name = _doc_name(signal_source_doc)
|
|
99
|
+
metric_refs = _str_tuple(spec.get("metric_ref"))
|
|
100
|
+
field_mapping = _get_mapping(spec, "field_mapping")
|
|
101
|
+
|
|
102
|
+
mapped_keys = frozenset(field_mapping.keys())
|
|
103
|
+
|
|
104
|
+
for metric_name in metric_refs:
|
|
105
|
+
metric_doc = metric_definitions.get(metric_name)
|
|
106
|
+
if metric_doc is None:
|
|
107
|
+
raise ContractViolationError(
|
|
108
|
+
metric_name=metric_name,
|
|
109
|
+
source_name=source_name,
|
|
110
|
+
missing_inputs=("<unknown metric>",),
|
|
111
|
+
)
|
|
112
|
+
needed = required_inputs(metric_doc, billing_mode)
|
|
113
|
+
missing = needed - mapped_keys
|
|
114
|
+
if missing:
|
|
115
|
+
raise ContractViolationError(
|
|
116
|
+
metric_name=metric_name,
|
|
117
|
+
source_name=source_name,
|
|
118
|
+
missing_inputs=tuple(sorted(missing)),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _doc_name(doc: Mapping[str, object]) -> str:
|
|
123
|
+
identity = _get_mapping(doc, "identity")
|
|
124
|
+
name = identity.get("name")
|
|
125
|
+
if isinstance(name, str):
|
|
126
|
+
return name
|
|
127
|
+
name = doc.get("name")
|
|
128
|
+
return name if isinstance(name, str) else "<unnamed>"
|