diff --git a/.gitignore b/.gitignore
index 576a92e..7312913 100644
--- a/.gitignore
+++ b/.gitignore
@@ -45,3 +45,6 @@ docs/superpowers/
.rp1/context/meta.json
.rp1/settings.toml
# rp1:end
+
+# rp1 workspace artifacts — never commit
+.rp1/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 20f4c0d..198682d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,17 @@
# Changelog
+## Unreleased
+
+- docs: README credibility pass — CI/downloads badges, production-scale benchmark callout, architecture diagram, maintainer credit; "For organizations" now states that the SR 11-7/SR 26-2, EU AI Act Annex IV, and NIST AI RMF validation profiles ship in the OSS core
+- docs: quickstart and all Ledger examples persist to `./ledger.db` (the previous `./inventory.db` name collided with the CLI's legacy Inventory-format default and crashed `model-ledger list`); quickstart `history()` example now shows (and CI-asserts) newest-first ordering
+- fix(cli): bare installs get a one-line install hint from `model-ledger --help` instead of a `ModuleNotFoundError` traceback (typer/rich live in the `[cli]` extra)
+- fix(cli): inventory commands detect a Ledger event-log database and exit with guidance instead of an sqlite traceback
+- fix(cli): `model-ledger validate` with an unknown profile exits with the available profile names instead of a `ValueError` traceback
+- refactor!: remove the `scanner` module (`Scanner`, `InventoryScanner`, `ModelCandidate`, `ScanReport`), deprecated since v0.4.0 — use `SourceConnector` + `DataNode` + `Ledger.add()/connect()`
+- chore: PyPI metadata — Development Status classifier to Beta; add `mcp`, `model-context-protocol`, `ai-governance`, `eu-ai-act`, `nist-ai-rmf`, `sr-26-2` keywords
+- docs: add SECURITY.md (private vulnerability reporting)
+- chore: stale-reference sweep — MCP tool count 6→8 in docstrings and CLAUDE.md, `v0.3.0` markers out of SDK docstrings, "Task 11" comments out of rest/app.py; fold two stale Unreleased changelog blocks into the releases that shipped them (v0.7.3, v0.4.8)
+
## v0.7.7
- feat: `SnowflakeLedgerBackend` accepts a `connection_factory` and self-heals on auth-token expiry (Snowflake errno 390114) — on a detected expiry it obtains a fresh connection, swaps it in, and retries the same statement exactly once. Composes with `client_session_keep_alive` heartbeats as the backstop for residual expiries. Backward compatible: with only `connection`, behavior is unchanged. (#24)
@@ -19,9 +31,6 @@
- Add `metadata: dict` field to `ModelRef`. Thread through `register()` and `register_group()`. Replaces the unintended per-link broadcast of `register_group(metadata=...)` to member links; metadata now lives on the composite ModelRef itself. Backward compatible: existing data loads with `metadata={}`.
- Add optional `model_types` parameter to `composite_summary()` so callers can include custom composite-shaped types (e.g., `ml_model`, `heuristic`) beyond the default `"composite"`. Backward compatible.
-
-## Unreleased
-
- feat: `RecordOutput.model_hash` — the `/record` response now carries the server's canonical model hash so HTTP clients can reconcile their local `ModelRef` with authoritative server state
- fix: `HttpLedgerBackend.save_model` adopts the server's canonical hash (reassigns `model.model_hash` on the incoming `ModelRef` and caches only the server hash) so follow-up hash-based flows like `Ledger.tag()` round-trip correctly even from a fresh backend instance
- feat: `POST /tag` and `GET /tags/{model_name}` REST endpoints — create, move, and list tags over HTTP
@@ -46,14 +55,11 @@
- feat: `github_connector()` — discover models from config files in GitHub repos
- feat: Connector factories return `SourceConnector` instances for composability
-## Unreleased
-
-- fix: deduplicate `ModelNotFoundError` — use canonical class from `core.exceptions`
-- test: add coverage for `'value' AS model_name` extraction pattern
-
## v0.4.8
- fix: exclude volatile timestamps from content hash dedup
+- fix: deduplicate `ModelNotFoundError` — use canonical class from `core.exceptions`
+- test: add coverage for `'value' AS model_name` extraction pattern
## v0.4.7
diff --git a/CLAUDE.md b/CLAUDE.md
index 14003d7..e0bb286 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -24,8 +24,8 @@ Open-source model inventory and governance framework. Apache-2.0.
- `src/model_ledger/backends/http.py` — HttpLedgerBackend (MCP → REST API pass-through)
- `src/model_ledger/backends/json_files.py` — JsonFileLedgerBackend (git-friendly)
- `src/model_ledger/sdk/ledger.py` — Ledger SDK with last_seen, change_detected/change_occurred
-- `src/model_ledger/tools/` — 6 agent tool functions (record, query, investigate, trace, changelog, discover)
-- `src/model_ledger/mcp/server.py` — MCP server (FastMCP, 6 tools + 3 resources)
+- `src/model_ledger/tools/` — 8 agent tool functions (record, query, investigate, trace, changelog, discover, tag, list_tags)
+- `src/model_ledger/mcp/server.py` — MCP server (FastMCP, 8 tools + 3 resources)
- `src/model_ledger/rest/app.py` — REST API (FastAPI, create_app() factory)
### v0.4.x (DataNode graph)
@@ -40,7 +40,7 @@ Open-source model inventory and governance framework. Apache-2.0.
- `src/model_ledger/adapters/cron.py` — Cron expression translation
### v0.3.0 (event-log paradigm)
-- `src/model_ledger/scanner/` — Scanner protocol, ModelCandidate, InventoryScanner, ScannerRegistry
+- Scanner protocol removed (deprecated v0.4.0, removed post-v0.7.7) — use SourceConnector + DataNode + Ledger.add()/connect()
### v0.2.0 (legacy — retained for reference)
- `src/model_ledger/core/models.py` — Model, ModelVersion, ComponentNode
diff --git a/README.md b/README.md
index d8bbac3..0a029e4 100644
--- a/README.md
+++ b/README.md
@@ -2,9 +2,11 @@
**git for models** — know what models you have deployed, where they run, what they depend on, and what changed.
+[](https://github.com/block/model-ledger/actions/workflows/ci.yml)
[](LICENSE)
[](https://python.org)
[](https://pypi.org/project/model-ledger/)
+[](https://pypistats.org/packages/model-ledger)
[](https://block.github.io/model-ledger/)
📖 **[Documentation](https://block.github.io/model-ledger/)** ·
@@ -21,6 +23,9 @@ Unlike registries tied to a single platform (MLflow, SageMaker, W&B), it spans a
them — as one connected graph — and it's built to be driven by AI agents through a
native MCP server.
+Benchmarked at production scale: full inventory reconstruction over a ledger of 28.8k
+models and 212k events runs in under a second ([CHANGELOG, v0.7.4](CHANGELOG.md)).
+
## Install
```bash
@@ -75,21 +80,57 @@ runs in CI:
- **[Agents (MCP)](https://block.github.io/model-ledger/guides/agents/)** — the eight-tool agent surface, with a worked transcript
- **[Connectors](https://block.github.io/model-ledger/guides/connectors/)** — discover from SQL, REST, GitHub, or your own platform
- **[Backends](https://block.github.io/model-ledger/guides/backends/)** — in-memory, SQLite, JSON, Snowflake, or remote HTTP
-- **[Governance](https://block.github.io/model-ledger/governance/)** — how the primitives map to SR 26‑2, the EU AI Act, and NIST
+- **[Governance](https://block.github.io/model-ledger/governance/)** — how the primitives map to SR 11‑7/SR 26‑2, the EU AI Act, and NIST AI RMF
- **[API reference](https://block.github.io/model-ledger/reference/)** — generated from the source
+## Architecture
+
+```mermaid
+flowchart LR
+ subgraph Sources
+ C1[SQL / REST / GitHub / Prefect
connectors]
+ end
+ subgraph Core
+ L[Ledger
append-only event log,
point-in-time reconstruction]
+ G[Dependency graph]
+ V[Compliance profiles
SR 11-7/SR 26-2 · EU AI Act · NIST AI RMF]
+ end
+ subgraph Surfaces
+ S1[Python SDK]
+ S2[CLI]
+ S3[REST API]
+ S4[MCP server · 8 tools]
+ end
+ B1[(in-memory · SQLite · JSON ·
Snowflake · remote HTTP)]
+ C1 --> L
+ L --> G
+ L --> V
+ L --- B1
+ S1 --> L
+ S2 --> L
+ S3 --> L
+ S4 --> L
+```
+
## For organizations
-The OSS core handles discovery, graph building, change tracking, storage, and the agent
-protocol. Your internal package provides the thin layer on top — connector configs,
-custom connectors for internal platforms, authentication, and compliance profiles. Thin
-config and credentials, not reimplemented logic.
+The OSS core handles discovery, graph building, change tracking, storage, the agent
+protocol, and compliance validation — the SR 11‑7/SR 26‑2, EU AI Act Annex IV, and
+NIST AI RMF profiles ship in `model_ledger.validate`. Your internal package provides
+only the thin layer on top: connector configs, custom connectors for internal
+platforms, and credentials. Thin config, not reimplemented logic.
## Contributing
See [CONTRIBUTING.md](https://github.com/block/model-ledger/blob/main/CONTRIBUTING.md).
All commits require DCO sign-off.
+## Security
+
+See [SECURITY.md](SECURITY.md) for how to report vulnerabilities privately.
+
## License
Apache-2.0. See [LICENSE](https://github.com/block/model-ledger/blob/main/LICENSE).
+
+Created and maintained by [Vignesh Narayanaswamy](https://github.com/vigneshnarayanaswamy) at Block.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..f787be7
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,29 @@
+# Security Policy
+
+## Reporting a vulnerability
+
+Please do not report security vulnerabilities through public GitHub issues.
+
+Instead, report them privately via the repository's **Security tab → Report a
+vulnerability** ([GitHub's private vulnerability reporting](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability)).
+
+For assistance or escalation, contact the
+[Block Open Source Governance Committee](mailto:open-source-governance@block.xyz).
+
+## Scope notes
+
+model-ledger stores model inventory metadata. A few boundaries worth knowing when
+assessing impact:
+
+- The ledger trusts its callers: `record()` accepts arbitrary payloads, and the REST
+ API does not ship authentication — deployments are expected to run it behind their
+ own auth layer (see the [backends guide](https://block.github.io/model-ledger/guides/backends/)).
+- Snapshot hashes provide content addressing and tamper evidence for snapshot
+ payloads, not a cryptographic chain over the full event history — see
+ [guarantees](https://block.github.io/model-ledger/concepts/guarantees/) for the
+ precise integrity model.
+
+## Supported versions
+
+Security fixes land on `main` and ship in the next release; older releases are not
+patched retroactively.
diff --git a/docs/concepts/composite.md b/docs/concepts/composite.md
index 05b2af8..cdc1639 100644
--- a/docs/concepts/composite.md
+++ b/docs/concepts/composite.md
@@ -20,7 +20,7 @@ validation. Composites are the layer no plain registry or catalog models.
```python
from model_ledger import Ledger
-ledger = Ledger.from_sqlite("./inventory.db")
+ledger = Ledger.from_sqlite("./ledger.db")
group = ledger.register_group(
name="Credit Scorecard",
diff --git a/docs/concepts/snapshot.md b/docs/concepts/snapshot.md
index c52e79e..237c3e3 100644
--- a/docs/concepts/snapshot.md
+++ b/docs/concepts/snapshot.md
@@ -20,7 +20,7 @@ A model splits into two things:
```python
from model_ledger import Ledger
-ledger = Ledger.from_sqlite("./inventory.db")
+ledger = Ledger.from_sqlite("./ledger.db")
ref = ledger.register(
name="fraud_scoring", owner="risk-team",
diff --git a/docs/governance.md b/docs/governance.md
index eb29577..4494a8a 100644
--- a/docs/governance.md
+++ b/docs/governance.md
@@ -29,7 +29,7 @@ true then?" is always reconstructable.**
```python
from model_ledger import Ledger
-ledger = Ledger.from_sqlite("./inventory.db")
+ledger = Ledger.from_sqlite("./ledger.db")
# Identity + risk tier — the minimum a regulator needs
ledger.register(
diff --git a/docs/guides/agents.md b/docs/guides/agents.md
index d20ba5f..20f0444 100644
--- a/docs/guides/agents.md
+++ b/docs/guides/agents.md
@@ -17,7 +17,7 @@ pip install "model-ledger[mcp]"
# Claude Code (one time). Drop --demo to start empty; add a backend to persist.
claude mcp add model-ledger -- model-ledger mcp --demo
-claude mcp add model-ledger -- model-ledger mcp --backend sqlite --path ./inventory.db
+claude mcp add model-ledger -- model-ledger mcp --backend sqlite --path ./ledger.db
```
The server speaks stdio and works with any MCP client (Claude Desktop, Goose, Cursor).
diff --git a/docs/guides/backends.md b/docs/guides/backends.md
index 6e57586..387606b 100644
--- a/docs/guides/backends.md
+++ b/docs/guides/backends.md
@@ -22,7 +22,7 @@ from model_ledger.backends.json_files import JsonFileLedgerBackend
from model_ledger.backends.http import HttpLedgerBackend
Ledger() # in-memory
-Ledger.from_sqlite("./inventory.db") # SQLite
+Ledger.from_sqlite("./ledger.db") # SQLite
Ledger(JsonFileLedgerBackend("./inventory")) # JSON files
Ledger.from_snowflake(conn, schema="DB.MODEL_LEDGER") # Snowflake
Ledger(HttpLedgerBackend("https://model-ledger:8000")) # remote REST
@@ -50,7 +50,7 @@ inventory/
The CLI launches either agent or HTTP surfaces over any backend:
```bash
-model-ledger serve --backend sqlite --path ./inventory.db --port 8000
+model-ledger serve --backend sqlite --path ./ledger.db --port 8000
model-ledger mcp --backend snowflake --schema DB.MODEL_LEDGER
```
diff --git a/docs/guides/connectors.md b/docs/guides/connectors.md
index 352f170..cc3cb40 100644
--- a/docs/guides/connectors.md
+++ b/docs/guides/connectors.md
@@ -14,7 +14,7 @@ factory connectors ship in core; anything else is a small protocol implementatio
```python
from model_ledger import Ledger, sql_connector
-ledger = Ledger.from_sqlite("./inventory.db")
+ledger = Ledger.from_sqlite("./ledger.db")
# Simple: read a registry table
models = sql_connector(
diff --git a/docs/index.md b/docs/index.md
index 756895b..0f3ef20 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -35,6 +35,9 @@ dependency graph** automatically, and **records every change as an immutable
event**. Unlike registries tied to one platform (MLflow, SageMaker, W&B), it spans
all of them — and it's built to be driven by AI agents through a native MCP server.
+Born at Block, where it runs in production inventorying ML models, heuristic rules,
+and pipelines across multiple platforms for a second-line model risk program.
+
[Get started in 60 seconds :octicons-arrow-right-24:](quickstart.md){ .md-button .md-button--primary }
[Why a ledger, not a registry? :octicons-arrow-right-24:](#why-a-ledger-not-a-registry){ .md-button }
@@ -104,7 +107,7 @@ dependency edge — no hand-wiring.
```python
from model_ledger import Ledger, DataNode
-ledger = Ledger.from_sqlite("./inventory.db")
+ledger = Ledger.from_sqlite("./ledger.db")
ledger.add([
DataNode("segmentation", platform="etl", outputs=["customer_segments"]),
@@ -129,15 +132,15 @@ graph LR
## One operation, every surface
-The SDK, the REST API, and the MCP tools are the **same six verbs** — `discover`,
-`record`, `investigate`, `query`, `trace`, `changelog` (plus `tag`/`list_tags`).
+The SDK, the REST API, and the MCP tools are the **same eight verbs** — `discover`,
+`record`, `investigate`, `query`, `trace`, `changelog`, `tag`, `list_tags`.
Registering a model looks like this everywhere:
=== "Python"
```python
from model_ledger import Ledger
- ledger = Ledger.from_sqlite("./inventory.db")
+ ledger = Ledger.from_sqlite("./ledger.db")
ledger.register(
name="fraud_scoring", owner="risk-team",
diff --git a/docs/quickstart.md b/docs/quickstart.md
index 6368909..c887381 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -82,7 +82,7 @@ A `DataNode` gives you the graph. [`register()`](reference/index.md) gives a mod
```python
from model_ledger import Ledger
-ledger = Ledger.from_sqlite("./inventory.db")
+ledger = Ledger.from_sqlite("./ledger.db")
ledger.register(
name="fraud_scoring",
@@ -96,10 +96,13 @@ ledger.register(
ledger.record("fraud_scoring", event="retrained", actor="ml-pipeline",
payload={"accuracy": 0.94, "features_added": ["velocity_24h"]})
-for snap in ledger.history("fraud_scoring"):
+history = ledger.history("fraud_scoring") # newest first
+for snap in history:
print(snap.timestamp, snap.event_type)
-# ... registered
# ... retrained
+# ... registered
+
+assert [s.event_type for s in history] == ["retrained", "registered"]
```
Every call appends an immutable [Snapshot](concepts/snapshot.md). Nothing is
@@ -114,7 +117,7 @@ from model_ledger import Ledger
from model_ledger.backends.json_files import JsonFileLedgerBackend
Ledger() # in-memory — tests & demos
-Ledger.from_sqlite("./inventory.db") # zero-infra, single file
+Ledger.from_sqlite("./ledger.db") # zero-infra, single file
Ledger(JsonFileLedgerBackend("./inventory")) # git-friendly JSON files
Ledger.from_snowflake(conn, schema="DB.MODEL_LEDGER") # production
```
diff --git a/docs/recipes/discover-sql.md b/docs/recipes/discover-sql.md
index 1b8c7af..c4bb23c 100644
--- a/docs/recipes/discover-sql.md
+++ b/docs/recipes/discover-sql.md
@@ -17,7 +17,7 @@ so re-running on a schedule only records genuine changes.
import sqlite3
from model_ledger import Ledger, sql_connector
-ledger = Ledger.from_sqlite("./inventory.db")
+ledger = Ledger.from_sqlite("./ledger.db")
source = sqlite3.connect("./ml_platform.db")
models = sql_connector(
diff --git a/docs/recipes/point-in-time.md b/docs/recipes/point-in-time.md
index da87743..558ef6f 100644
--- a/docs/recipes/point-in-time.md
+++ b/docs/recipes/point-in-time.md
@@ -16,7 +16,7 @@ the inventory at any date is just a replay of the log up to that moment.
from datetime import datetime, timezone, timedelta
from model_ledger import Ledger
-ledger = Ledger.from_sqlite("./inventory.db")
+ledger = Ledger.from_sqlite("./ledger.db")
ledger.register(name="fraud_scoring", owner="risk-team",
model_type="ml_model", tier="high",
diff --git a/pyproject.toml b/pyproject.toml
index 8b395ad..77bad9d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -9,9 +9,22 @@ authors = [
{ name = "Vignesh Narayanaswamy", email = "vigneshn@block.xyz" },
{ name = "Block", email = "opensource@block.xyz" },
]
-keywords = ["mlops", "model-governance", "model-inventory", "model-risk", "sr-11-7", "compliance"]
+keywords = [
+ "mlops",
+ "model-governance",
+ "model-inventory",
+ "model-risk",
+ "sr-11-7",
+ "sr-26-2",
+ "compliance",
+ "mcp",
+ "model-context-protocol",
+ "ai-governance",
+ "eu-ai-act",
+ "nist-ai-rmf",
+]
classifiers = [
- "Development Status :: 3 - Alpha",
+ "Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Intended Audience :: Financial and Insurance Industry",
"License :: OSI Approved :: Apache Software License",
diff --git a/src/model_ledger/__init__.py b/src/model_ledger/__init__.py
index 0b9d2d3..a874def 100644
--- a/src/model_ledger/__init__.py
+++ b/src/model_ledger/__init__.py
@@ -30,7 +30,6 @@
from model_ledger.core.models import ComponentNode, Model, ModelVersion
from model_ledger.graph.models import DataNode, DataPort
from model_ledger.graph.protocol import SourceConnector
-from model_ledger.scanner.protocol import ModelCandidate, Scanner
from model_ledger.sdk.inventory import Inventory
from model_ledger.sdk.ledger import Ledger
@@ -69,8 +68,6 @@
"ModelRef",
"Snapshot",
"Tag",
- "ModelCandidate",
- "Scanner",
# v0.4.0 — graph
"DataNode",
"DataPort",
diff --git a/src/model_ledger/cli/__init__.py b/src/model_ledger/cli/__init__.py
index a5b1a12..824decb 100644
--- a/src/model_ledger/cli/__init__.py
+++ b/src/model_ledger/cli/__init__.py
@@ -1,3 +1,18 @@
-from model_ledger.cli.app import app
+try:
+ from model_ledger.cli.app import app
+except ModuleNotFoundError as _exc: # pragma: no cover — exercised only on bare installs
+ if _exc.name not in {"typer", "rich"}:
+ raise
+
+ import sys
+
+ def app() -> None: # type: ignore[misc]
+ """Fallback entry point when the CLI extra is not installed."""
+ sys.stderr.write(
+ "The model-ledger CLI requires the [cli] extra:\n\n"
+ ' pip install "model-ledger[cli]"\n'
+ )
+ sys.exit(1)
+
__all__ = ["app"]
diff --git a/src/model_ledger/cli/app.py b/src/model_ledger/cli/app.py
index 24befee..5e67480 100644
--- a/src/model_ledger/cli/app.py
+++ b/src/model_ledger/cli/app.py
@@ -111,7 +111,39 @@ def _default_db() -> str:
return os.environ.get("MODEL_LEDGER_DB", "inventory.db")
+def _guard_ledger_db(db: str) -> None:
+ """Exit with guidance when `db` is a Ledger event-log database.
+
+ The inventory commands read the legacy Inventory format (models/versions
+ tables). Pointing them at a Ledger database (models/snapshots tables) —
+ e.g. the `ledger.db` file from the quickstart — used to surface as a raw
+ sqlite "no such table" traceback.
+ """
+ import sqlite3
+ from pathlib import Path
+
+ if not Path(db).is_file():
+ return
+ try:
+ with sqlite3.connect(db) as conn:
+ names = {
+ row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
+ }
+ except sqlite3.Error:
+ return
+ if "snapshots" in names and "versions" not in names:
+ console.print(
+ f"[red]Error:[/red] '{db}' is a model-ledger event-log (Ledger) database, "
+ "but this command reads the legacy Inventory format.\n"
+ "Work with a Ledger database via the Python SDK "
+ "([cyan]Ledger.from_sqlite(...)[/cyan]) or serve it to agents with "
+ f"[cyan]model-ledger mcp --backend sqlite --path {db}[/cyan]."
+ )
+ raise typer.Exit(code=1)
+
+
def _get_inventory(db: str) -> Inventory:
+ _guard_ledger_db(db)
return Inventory(db_path=db)
@@ -235,7 +267,11 @@ def validate_cmd(
from model_ledger.validate.engine import validate
- result = validate(model, ver, profile=profile)
+ try:
+ result = validate(model, ver, profile=profile)
+ except ValueError as e:
+ console.print(f"[red]Error:[/red] {e}")
+ raise typer.Exit(code=1) from None
if format == "json":
data = {
diff --git a/src/model_ledger/mcp/server.py b/src/model_ledger/mcp/server.py
index d6d92c3..beb557a 100644
--- a/src/model_ledger/mcp/server.py
+++ b/src/model_ledger/mcp/server.py
@@ -1,4 +1,4 @@
-"""FastMCP server wrapping model-ledger's 6 tools and 3 resources.
+"""FastMCP server wrapping model-ledger's 8 tools and 3 resources.
Usage:
>>> from model_ledger.mcp.server import create_server
diff --git a/src/model_ledger/rest/app.py b/src/model_ledger/rest/app.py
index 58d5779..a301e1f 100644
--- a/src/model_ledger/rest/app.py
+++ b/src/model_ledger/rest/app.py
@@ -62,7 +62,7 @@ def create_app(
Args:
backend: Optional ledger backend. Defaults to in-memory.
- demo: If True, pre-loads demo inventory data (requires Task 11).
+ demo: If True, pre-loads demo inventory data.
Returns:
A configured FastAPI application.
@@ -75,7 +75,7 @@ def create_app(
load_demo_inventory(ledger)
except ImportError:
- pass # demo dataset not yet available (Task 11)
+ pass # demo dataset module not installed
app = FastAPI(
title="Model Ledger API",
diff --git a/src/model_ledger/scanner/__init__.py b/src/model_ledger/scanner/__init__.py
deleted file mode 100644
index 4dd6de9..0000000
--- a/src/model_ledger/scanner/__init__.py
+++ /dev/null
@@ -1,23 +0,0 @@
-"""Scanner protocol — DEPRECATED in v0.4.0.
-
-Use SourceConnector + DataNode + Ledger.add()/connect() instead.
-Scanner and InventoryScanner still work but will be removed in v0.5.0.
-"""
-
-from model_ledger.scanner.connection import DBConnection
-from model_ledger.scanner.orchestrator import InventoryScanner
-from model_ledger.scanner.protocol import (
- EnrichableScanner,
- ModelCandidate,
- Scanner,
-)
-from model_ledger.scanner.report import ScanReport
-
-__all__ = [
- "DBConnection",
- "EnrichableScanner",
- "InventoryScanner",
- "ModelCandidate",
- "ScanReport",
- "Scanner",
-]
diff --git a/src/model_ledger/scanner/connection.py b/src/model_ledger/scanner/connection.py
deleted file mode 100644
index 0373357..0000000
--- a/src/model_ledger/scanner/connection.py
+++ /dev/null
@@ -1,20 +0,0 @@
-"""DBConnection protocol — thin database abstraction for scanners."""
-
-from __future__ import annotations
-
-from typing import Any, Protocol, runtime_checkable
-
-
-@runtime_checkable
-class DBConnection(Protocol):
- """Minimal database connection interface.
-
- Any database client that can execute SQL and return rows as dicts
- satisfies this protocol: Postgres, MySQL, BigQuery, Snowflake, SQLite.
- """
-
- def execute(
- self,
- query: str,
- params: dict[str, Any] | None = None,
- ) -> list[dict[str, Any]]: ...
diff --git a/src/model_ledger/scanner/orchestrator.py b/src/model_ledger/scanner/orchestrator.py
deleted file mode 100644
index 5f1277c..0000000
--- a/src/model_ledger/scanner/orchestrator.py
+++ /dev/null
@@ -1,163 +0,0 @@
-"""InventoryScanner — orchestrates multiple scanners, deduplicates, registers."""
-
-from __future__ import annotations
-
-from collections.abc import Callable
-from datetime import datetime, timezone
-
-from model_ledger.core.ledger_models import ModelRef
-from model_ledger.scanner.protocol import EnrichableScanner, ModelCandidate, Scanner
-from model_ledger.scanner.report import ScanReport
-from model_ledger.sdk.ledger import Ledger, ModelNotFoundError
-
-
-def _now() -> datetime:
- return datetime.now(timezone.utc)
-
-
-class InventoryScanner:
- def __init__(
- self,
- ledger: Ledger,
- scanners: list[Scanner],
- filter_fn: Callable[[ModelCandidate], bool] | None = None,
- ) -> None:
- self._ledger = ledger
- self._scanners = {s.name: s for s in scanners}
- self._filter_fn = filter_fn
-
- def discover_all(self) -> list[ScanReport]:
- reports = []
- for scanner in self._scanners.values():
- report = self._run_scanner(scanner)
- reports.append(report)
- return reports
-
- def scan_platform(self, platform: str) -> ScanReport:
- scanner = self._scanners.get(platform)
- if scanner is None:
- raise ValueError(f"Unknown platform: {platform}")
- return self._run_scanner(scanner)
-
- def _get_last_scan_time(self, scanner_name: str) -> datetime | None:
- for model in self._ledger.list():
- snaps: list = self._ledger.history(model) # type: ignore[assignment]
- for s in snaps:
- if s.source == scanner_name:
- ts: datetime = s.timestamp
- return ts
- return None
-
- def _run_scanner(self, scanner: Scanner) -> ScanReport:
- # Check has_changed before running full scan
- last_scan = self._get_last_scan_time(scanner.name)
- if last_scan is not None and not scanner.has_changed(last_scan):
- return ScanReport(
- platform=scanner.name,
- scan_run_id=f"{scanner.name}:{_now().isoformat()}",
- total_found=0,
- new_models=0,
- updated_models=0,
- not_found_models=0,
- candidates=[],
- )
-
- candidates = scanner.scan()
- if self._filter_fn:
- candidates = [c for c in candidates if self._filter_fn(c)]
-
- scan_run_id = f"{scanner.name}:{_now().isoformat()}"
- new_count = 0
- updated_count = 0
-
- found_names = {c.name for c in candidates}
-
- for candidate in candidates:
- result = self._register_candidate(candidate, scan_run_id)
- if result == "new":
- new_count += 1
- elif result == "updated":
- updated_count += 1
-
- # Enrich if scanner supports it
- if isinstance(scanner, EnrichableScanner):
- enrichment = scanner.enrich(candidate)
- if enrichment:
- self._ledger.record(
- candidate.name,
- event="enriched",
- source=scanner.name,
- payload={**enrichment, "scan_run_id": scan_run_id},
- actor=f"scanner:{scanner.name}",
- )
-
- # Record not_found for models previously discovered on this platform
- not_found_count = 0
- for model in self._ledger.list():
- if model.name in found_names:
- continue
- if self._was_discovered_by(model, scanner.name):
- self._ledger.record(
- model,
- event="not_found",
- source=scanner.name,
- payload={"scan_run_id": scan_run_id},
- actor=f"scanner:{scanner.name}",
- )
- not_found_count += 1
-
- return ScanReport(
- platform=scanner.name,
- scan_run_id=scan_run_id,
- total_found=len(candidates),
- new_models=new_count,
- updated_models=updated_count,
- not_found_models=not_found_count,
- candidates=candidates,
- )
-
- def _was_discovered_by(self, model: ModelRef, scanner_name: str) -> bool:
- snaps = self._ledger.history(model) or []
- return any(
- s.source == scanner_name and s.event_type in ("discovered", "scan_confirmed")
- for s in snaps
- )
-
- def _register_candidate(self, candidate: ModelCandidate, scan_run_id: str) -> str:
- try:
- existing = self._ledger.get(candidate.name)
- except ModelNotFoundError:
- existing = None
-
- payload = {
- "platform_id": candidate.platform_id,
- "scan_run_id": scan_run_id,
- **candidate.metadata,
- }
-
- if existing is None:
- self._ledger.register(
- name=candidate.name,
- owner=candidate.owner or "unknown",
- model_type=candidate.model_type,
- tier="unclassified",
- purpose=f"Discovered on {candidate.platform}",
- actor=f"scanner:{candidate.platform}",
- )
- self._ledger.record(
- candidate.name,
- event="discovered",
- source=candidate.platform,
- payload=payload,
- actor=f"scanner:{candidate.platform}",
- )
- return "new"
- else:
- self._ledger.record(
- existing,
- event="scan_confirmed",
- source=candidate.platform,
- payload=payload,
- actor=f"scanner:{candidate.platform}",
- )
- return "updated"
diff --git a/src/model_ledger/scanner/protocol.py b/src/model_ledger/scanner/protocol.py
deleted file mode 100644
index e1bbf6f..0000000
--- a/src/model_ledger/scanner/protocol.py
+++ /dev/null
@@ -1,39 +0,0 @@
-"""Scanner protocol — the interface all platform scanners implement."""
-
-from __future__ import annotations
-
-from datetime import datetime
-from typing import Any, Protocol, runtime_checkable
-
-from pydantic import BaseModel, Field
-
-
-class ModelCandidate(BaseModel):
- """Raw discovery result from a scanner — not yet a ModelRef."""
-
- name: str
- owner: str | None = None
- model_type: str
- platform: str
- platform_id: str | None = None
- parent_name: str | None = None
- external_ids: dict[str, str] = Field(default_factory=dict)
- metadata: dict[str, Any] = Field(default_factory=dict)
-
-
-@runtime_checkable
-class Scanner(Protocol):
- """Discovers models/rules on a deployment platform (Phase A)."""
-
- name: str
- platform_type: str
-
- def scan(self) -> list[ModelCandidate]: ...
- def has_changed(self, last_scan: datetime) -> bool: ...
-
-
-@runtime_checkable
-class EnrichableScanner(Scanner, Protocol):
- """Scanner that can also fetch richer metadata (Phase B)."""
-
- def enrich(self, candidate: ModelCandidate) -> dict: ...
diff --git a/src/model_ledger/scanner/registry.py b/src/model_ledger/scanner/registry.py
deleted file mode 100644
index 0b90544..0000000
--- a/src/model_ledger/scanner/registry.py
+++ /dev/null
@@ -1,59 +0,0 @@
-"""Scanner registry with lazy entry point discovery."""
-
-from __future__ import annotations
-
-from model_ledger.scanner.protocol import Scanner
-
-ENTRY_POINT_GROUP = "model_ledger.scanners"
-
-_registry: ScannerRegistry | None = None
-
-
-class ScannerRegistry:
- def __init__(self) -> None:
- self._scanners: dict[str, Scanner] = {}
- self._discovered = False
-
- def register(self, scanner: Scanner) -> None:
- self._scanners[scanner.name] = scanner
-
- def get(self, name: str) -> Scanner:
- self._ensure_discovered()
- if name not in self._scanners:
- raise KeyError(f"No scanner registered with name: {name}")
- return self._scanners[name]
-
- def list_scanners(self) -> list[Scanner]:
- self._ensure_discovered()
- return list(self._scanners.values())
-
- def _ensure_discovered(self) -> None:
- if self._discovered:
- return
- self._discovered = True
- try:
- from importlib.metadata import entry_points
-
- eps = entry_points(group=ENTRY_POINT_GROUP)
- for ep in sorted(eps, key=lambda e: e.name):
- try:
- cls = ep.load()
- instance = cls()
- if instance.name not in self._scanners:
- self._scanners[instance.name] = instance
- except Exception:
- pass
- except Exception:
- pass
-
-
-def get_registry() -> ScannerRegistry:
- global _registry
- if _registry is None:
- _registry = ScannerRegistry()
- return _registry
-
-
-def reset_registry() -> None:
- global _registry
- _registry = None
diff --git a/src/model_ledger/scanner/report.py b/src/model_ledger/scanner/report.py
deleted file mode 100644
index bf5fb68..0000000
--- a/src/model_ledger/scanner/report.py
+++ /dev/null
@@ -1,26 +0,0 @@
-"""ScanReport — summary of a scanner run."""
-
-from __future__ import annotations
-
-from datetime import datetime, timezone
-
-from pydantic import BaseModel, Field
-
-from model_ledger.scanner.protocol import ModelCandidate
-
-
-def _now() -> datetime:
- return datetime.now(timezone.utc)
-
-
-class ScanReport(BaseModel):
- """Summary of what a scanner found."""
-
- platform: str
- scan_run_id: str = ""
- timestamp: datetime = Field(default_factory=_now)
- total_found: int
- new_models: int
- updated_models: int
- not_found_models: int
- candidates: list[ModelCandidate]
diff --git a/src/model_ledger/sdk/ledger.py b/src/model_ledger/sdk/ledger.py
index a5f8f91..aab4b14 100644
--- a/src/model_ledger/sdk/ledger.py
+++ b/src/model_ledger/sdk/ledger.py
@@ -1,4 +1,4 @@
-"""Ledger SDK — tool-shaped API for v0.3.0 event-log paradigm."""
+"""Ledger SDK — tool-shaped API for the event-log paradigm."""
from __future__ import annotations
@@ -65,7 +65,7 @@ def _normalize_status(raw: object) -> str | None:
class Ledger:
- """The main entry point for model-ledger v0.3.0.
+ """The main entry point for model-ledger.
Every public method is designed to work as an agent tool call:
clear inputs, JSON-serializable outputs, no side effects beyond the ledger.
diff --git a/tests/test_cli/test_app.py b/tests/test_cli/test_app.py
index 126b7aa..7ad5454 100644
--- a/tests/test_cli/test_app.py
+++ b/tests/test_cli/test_app.py
@@ -82,3 +82,34 @@ def test_list_json_format(tmp_path):
data = json.loads(result.output)
assert len(data) == 1
assert data[0]["name"] == "test-model"
+
+
+def test_list_on_ledger_db_exits_with_guidance(tmp_path):
+ """A Ledger event-log database gets a guidance message, not an sqlite traceback."""
+ from model_ledger import Ledger
+
+ db = str(tmp_path / "ledger.db")
+ ledger = Ledger.from_sqlite(db)
+ ledger.register(
+ name="fraud_scoring",
+ owner="risk-team",
+ model_type="ml_model",
+ tier="high",
+ purpose="testing",
+ )
+ result = runner.invoke(app, ["list", "--db", db])
+ assert result.exit_code == 1
+ assert "event-log" in result.output
+ assert "model-ledger mcp" in result.output
+
+
+def test_validate_unknown_profile_exits_cleanly(tmp_path):
+ """An unknown profile name exits with the available profiles, not a ValueError."""
+ db = str(tmp_path / "test.db")
+ inv = Inventory(db_path=db)
+ inv.register_model(name="test-model", owner="tester", tier="low", intended_purpose="testing")
+ with inv.new_version("test-model"):
+ pass
+ result = runner.invoke(app, ["validate", "test-model", "--db", db, "--profile", "nope"])
+ assert result.exit_code == 1
+ assert "Unknown profile" in result.output
diff --git a/tests/test_scanner/__init__.py b/tests/test_scanner/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/tests/test_scanner/test_connection.py b/tests/test_scanner/test_connection.py
deleted file mode 100644
index 68b26cb..0000000
--- a/tests/test_scanner/test_connection.py
+++ /dev/null
@@ -1,33 +0,0 @@
-"""Tests for DBConnection protocol."""
-
-from model_ledger.scanner.connection import DBConnection
-
-
-class FakeDBConnection:
- def __init__(self, results: list[dict]):
- self._results = results
- self.last_query: str | None = None
- self.last_params: dict | None = None
-
- def execute(self, query: str, params: dict | None = None) -> list[dict]:
- self.last_query = query
- self.last_params = params
- return self._results
-
-
-class TestDBConnection:
- def test_implements_protocol(self):
- conn = FakeDBConnection([])
- assert isinstance(conn, DBConnection)
-
- def test_execute_returns_rows(self):
- rows = [{"id": 1, "name": "model-a"}, {"id": 2, "name": "model-b"}]
- conn = FakeDBConnection(rows)
- result = conn.execute("SELECT * FROM models")
- assert len(result) == 2
- assert result[0]["name"] == "model-a"
-
- def test_execute_with_params(self):
- conn = FakeDBConnection([{"id": 1}])
- conn.execute("SELECT * FROM models WHERE id = :id", {"id": 1})
- assert conn.last_params == {"id": 1}
diff --git a/tests/test_scanner/test_orchestrator.py b/tests/test_scanner/test_orchestrator.py
deleted file mode 100644
index b63b593..0000000
--- a/tests/test_scanner/test_orchestrator.py
+++ /dev/null
@@ -1,482 +0,0 @@
-"""Tests for InventoryScanner — orchestrates scanners, deduplicates, registers."""
-
-from datetime import datetime
-
-import pytest
-
-from model_ledger.backends.ledger_memory import InMemoryLedgerBackend
-from model_ledger.scanner.orchestrator import InventoryScanner
-from model_ledger.scanner.protocol import ModelCandidate
-from model_ledger.sdk.ledger import Ledger
-
-
-class FakeScanner:
- def __init__(self, name: str, candidates: list[ModelCandidate]):
- self.name = name
- self.platform_type = "test"
- self._candidates = candidates
-
- def scan(self) -> list[ModelCandidate]:
- return self._candidates
-
- def has_changed(self, last_scan: datetime) -> bool:
- return True
-
-
-class FakeChangelessScanner:
- def __init__(self, name: str, candidates: list[ModelCandidate]):
- self.name = name
- self.platform_type = "test"
- self._candidates = candidates
-
- def scan(self) -> list[ModelCandidate]:
- return self._candidates
-
- def has_changed(self, last_scan: datetime) -> bool:
- return False
-
-
-class FakeEnrichableScanner:
- def __init__(self, name: str, candidates: list[ModelCandidate]):
- self.name = name
- self.platform_type = "test"
- self._candidates = candidates
-
- def scan(self) -> list[ModelCandidate]:
- return self._candidates
-
- def has_changed(self, last_scan: datetime) -> bool:
- return True
-
- def enrich(self, candidate: ModelCandidate) -> dict:
- return {"enriched": True, "features": ["f1", "f2"]}
-
-
-@pytest.fixture
-def ledger():
- return Ledger(backend=InMemoryLedgerBackend())
-
-
-class TestDiscoverAll:
- def test_discovers_and_registers(self, ledger):
- scanner = FakeScanner(
- "platform-a",
- [
- ModelCandidate(
- name="model-1",
- owner="team-a",
- model_type="ml_model",
- platform="platform-a",
- platform_id="id-1",
- metadata={"algo": "xgb"},
- ),
- ModelCandidate(
- name="model-2",
- owner="team-b",
- model_type="heuristic",
- platform="platform-a",
- platform_id="id-2",
- metadata={},
- ),
- ],
- )
- inv = InventoryScanner(ledger, [scanner])
- reports = inv.discover_all()
- assert len(reports) == 1
- assert reports[0].total_found == 2
- assert reports[0].new_models == 2
- assert len(ledger.list()) == 2
-
- def test_idempotent_scan(self, ledger):
- candidates = [
- ModelCandidate(
- name="model-1",
- owner="team-a",
- model_type="ml_model",
- platform="p",
- metadata={},
- ),
- ]
- scanner = FakeScanner("p", candidates)
- inv = InventoryScanner(ledger, [scanner])
- inv.discover_all()
- reports = inv.discover_all()
- assert reports[0].new_models == 0
- assert len(ledger.list()) == 1
-
- def test_multiple_scanners(self, ledger):
- s1 = FakeScanner(
- "ml_platform",
- [
- ModelCandidate(
- name="ml-1",
- owner="t",
- model_type="ml_model",
- platform="ml_platform",
- metadata={},
- ),
- ],
- )
- s2 = FakeScanner(
- "etl_engine",
- [
- ModelCandidate(
- name="rule-1",
- owner="t",
- model_type="heuristic",
- platform="etl_engine",
- metadata={},
- ),
- ],
- )
- inv = InventoryScanner(ledger, [s1, s2])
- reports = inv.discover_all()
- assert len(reports) == 2
- assert len(ledger.list()) == 2
-
- def test_dedup_same_name_same_owner(self, ledger):
- s1 = FakeScanner(
- "ml_platform",
- [
- ModelCandidate(
- name="shared-model",
- owner="team-a",
- model_type="ml_model",
- platform="ml_platform",
- metadata={"source": "ml_platform"},
- ),
- ],
- )
- s2 = FakeScanner(
- "etl_engine",
- [
- ModelCandidate(
- name="shared-model",
- owner="team-a",
- model_type="ml_model",
- platform="etl_engine",
- metadata={"source": "etl_engine"},
- ),
- ],
- )
- inv = InventoryScanner(ledger, [s1, s2])
- inv.discover_all()
- assert len(ledger.list()) == 1
- snaps = ledger.history("shared-model")
- # registered + discovered from ml_platform + scan_confirmed from etl_engine
- assert len(snaps) >= 2
-
-
-class TestScanPlatform:
- def test_scan_single_platform(self, ledger):
- s1 = FakeScanner(
- "ml_platform",
- [
- ModelCandidate(
- name="ml-1",
- owner="t",
- model_type="ml",
- platform="ml_platform",
- metadata={},
- ),
- ],
- )
- s2 = FakeScanner(
- "etl_engine",
- [
- ModelCandidate(
- name="rule-1",
- owner="t",
- model_type="heuristic",
- platform="etl_engine",
- metadata={},
- ),
- ],
- )
- inv = InventoryScanner(ledger, [s1, s2])
- report = inv.scan_platform("ml_platform")
- assert report.total_found == 1
- assert len(ledger.list()) == 1
-
- def test_scan_unknown_platform_raises(self, ledger):
- inv = InventoryScanner(ledger, [])
- with pytest.raises(ValueError, match="Unknown platform"):
- inv.scan_platform("nonexistent")
-
-
-class TestFilterFn:
- def test_filter_excludes_candidates(self, ledger):
- scanner = FakeScanner(
- "etl_engine",
- [
- ModelCandidate(
- name="risk-job",
- owner="t",
- model_type="heuristic",
- platform="etl_engine",
- metadata={"subject_area": "Risk"},
- ),
- ModelCandidate(
- name="hr-job",
- owner="t",
- model_type="heuristic",
- platform="etl_engine",
- metadata={"subject_area": "HR"},
- ),
- ],
- )
-
- def risk_only(c: ModelCandidate) -> bool:
- return c.metadata.get("subject_area") == "Risk"
-
- inv = InventoryScanner(ledger, [scanner], filter_fn=risk_only)
- reports = inv.discover_all()
- assert reports[0].total_found == 1
- assert len(ledger.list()) == 1
- assert ledger.get("risk-job").name == "risk-job"
-
- def test_no_filter_registers_all(self, ledger):
- scanner = FakeScanner(
- "p",
- [
- ModelCandidate(
- name="a",
- owner="t",
- model_type="ml",
- platform="p",
- metadata={},
- ),
- ModelCandidate(
- name="b",
- owner="t",
- model_type="ml",
- platform="p",
- metadata={},
- ),
- ],
- )
- inv = InventoryScanner(ledger, [scanner])
- inv.discover_all()
- assert len(ledger.list()) == 2
-
-
-class TestScanRunId:
- def test_scan_run_id_in_report(self, ledger):
- scanner = FakeScanner(
- "ml_platform",
- [
- ModelCandidate(
- name="m1",
- owner="t",
- model_type="ml",
- platform="ml_platform",
- metadata={},
- ),
- ],
- )
- inv = InventoryScanner(ledger, [scanner])
- reports = inv.discover_all()
- assert reports[0].scan_run_id is not None
- assert reports[0].scan_run_id.startswith("ml_platform:")
-
- def test_scan_run_id_in_snapshot_payloads(self, ledger):
- scanner = FakeScanner(
- "ml_platform",
- [
- ModelCandidate(
- name="m1",
- owner="t",
- model_type="ml",
- platform="ml_platform",
- metadata={},
- ),
- ],
- )
- inv = InventoryScanner(ledger, [scanner])
- reports = inv.discover_all()
- scan_run_id = reports[0].scan_run_id
-
- snaps = ledger.history("m1")
- discovered = [s for s in snaps if s.event_type == "discovered"]
- assert discovered[0].payload["scan_run_id"] == scan_run_id
-
-
-class TestNotFoundTracking:
- def test_not_found_recorded_for_missing_model(self, ledger):
- scanner = FakeScanner(
- "ml_platform",
- [
- ModelCandidate(
- name="model-a",
- owner="t",
- model_type="ml",
- platform="ml_platform",
- metadata={},
- ),
- ],
- )
- inv = InventoryScanner(ledger, [scanner])
- inv.discover_all()
-
- # Second scan: model-a is gone
- scanner._candidates = []
- reports = inv.discover_all()
- assert reports[0].not_found_models == 1
-
- snaps = ledger.history("model-a")
- not_found = [s for s in snaps if s.event_type == "not_found"]
- assert len(not_found) == 1
-
- def test_not_found_only_for_same_platform(self, ledger):
- s1 = FakeScanner(
- "ml_platform",
- [
- ModelCandidate(
- name="model-a",
- owner="t",
- model_type="ml",
- platform="ml_platform",
- metadata={},
- ),
- ],
- )
- s2 = FakeScanner(
- "etl_engine",
- [
- ModelCandidate(
- name="rule-b",
- owner="t",
- model_type="heuristic",
- platform="etl_engine",
- metadata={},
- ),
- ],
- )
- inv = InventoryScanner(ledger, [s1, s2])
- inv.discover_all()
-
- # model-a disappears from ml_platform — rule-b should NOT get not_found
- s1._candidates = []
- reports = inv.discover_all()
- platform_report = [r for r in reports if r.platform == "ml_platform"][0]
- assert platform_report.not_found_models == 1
-
- snaps = ledger.history("rule-b")
- not_found = [s for s in snaps if s.event_type == "not_found"]
- assert len(not_found) == 0
-
- def test_rediscovered_model_gets_confirmed(self, ledger):
- scanner = FakeScanner(
- "ml_platform",
- [
- ModelCandidate(
- name="model-a",
- owner="t",
- model_type="ml",
- platform="ml_platform",
- metadata={},
- ),
- ],
- )
- inv = InventoryScanner(ledger, [scanner])
- inv.discover_all()
-
- # Disappear
- scanner._candidates = []
- inv.discover_all()
-
- # Reappear
- scanner._candidates = [
- ModelCandidate(
- name="model-a",
- owner="t",
- model_type="ml",
- platform="ml_platform",
- metadata={},
- ),
- ]
- inv.discover_all()
-
- snaps = ledger.history("model-a")
- events = [s.event_type for s in snaps]
- assert "not_found" in events
- assert "scan_confirmed" in events
-
-
-class TestHasChanged:
- def test_skips_scanner_when_not_changed(self, ledger):
- # First scan to establish last_scan time
- first_scanner = FakeScanner(
- "ml_platform",
- [
- ModelCandidate(
- name="existing",
- owner="t",
- model_type="ml",
- platform="ml_platform",
- metadata={},
- ),
- ],
- )
- inv1 = InventoryScanner(ledger, [first_scanner])
- inv1.discover_all()
- assert len(ledger.list()) == 1
-
- # Second scan with changeless scanner — should be skipped
- changeless = FakeChangelessScanner(
- "ml_platform",
- [
- ModelCandidate(
- name="new-model",
- owner="t",
- model_type="ml",
- platform="ml_platform",
- metadata={},
- ),
- ],
- )
- inv2 = InventoryScanner(ledger, [changeless])
- reports = inv2.discover_all()
- assert reports[0].total_found == 0
- # new-model should NOT have been registered
- assert len(ledger.list()) == 1
-
- def test_runs_scanner_when_changed(self, ledger):
- scanner = FakeScanner(
- "ml_platform",
- [
- ModelCandidate(
- name="m1",
- owner="t",
- model_type="ml",
- platform="ml_platform",
- metadata={},
- ),
- ],
- )
- inv = InventoryScanner(ledger, [scanner])
- reports = inv.discover_all()
- assert reports[0].total_found == 1
-
-
-class TestEnrichment:
- def test_enrichable_scanner_creates_enriched_snapshot(self, ledger):
- scanner = FakeEnrichableScanner(
- "ml_platform",
- [
- ModelCandidate(
- name="m1",
- owner="t",
- model_type="ml",
- platform="ml_platform",
- metadata={},
- ),
- ],
- )
- inv = InventoryScanner(ledger, [scanner])
- inv.discover_all()
-
- snaps = ledger.history("m1")
- enriched = [s for s in snaps if s.event_type == "enriched"]
- assert len(enriched) == 1
- assert enriched[0].payload["features"] == ["f1", "f2"]
diff --git a/tests/test_scanner/test_protocol.py b/tests/test_scanner/test_protocol.py
deleted file mode 100644
index a00c48c..0000000
--- a/tests/test_scanner/test_protocol.py
+++ /dev/null
@@ -1,157 +0,0 @@
-"""Tests for Scanner protocol and ModelCandidate."""
-
-from datetime import datetime
-
-from model_ledger.scanner.protocol import (
- EnrichableScanner,
- ModelCandidate,
- Scanner,
-)
-from model_ledger.scanner.report import ScanReport
-
-
-class TestModelCandidate:
- def test_create(self):
- c = ModelCandidate(
- name="fraud-rule-1",
- owner="risk-team",
- model_type="heuristic",
- platform="etl_engine",
- platform_id="job-123",
- metadata={"schedule": "daily"},
- )
- assert c.name == "fraud-rule-1"
- assert c.platform == "etl_engine"
-
- def test_owner_optional(self):
- c = ModelCandidate(
- name="unknown-rule",
- model_type="heuristic",
- platform="etl_engine",
- metadata={},
- )
- assert c.owner is None
-
- def test_parent_name(self):
- c = ModelCandidate(
- name="ruleset-1",
- owner="risk-team",
- model_type="ruleset",
- platform="rules-engine",
- parent_name="TD-85",
- )
- assert c.parent_name == "TD-85"
-
- def test_parent_name_defaults_none(self):
- c = ModelCandidate(
- name="model-1",
- model_type="ml",
- platform="ml_platform",
- metadata={},
- )
- assert c.parent_name is None
-
- def test_external_ids(self):
- c = ModelCandidate(
- name="arr-v3",
- model_type="ml_model",
- platform="ml_platform",
- external_ids={"ml_platform": "arr_v3", "rules_engine": "TD-85"},
- metadata={},
- )
- assert c.external_ids["ml_platform"] == "arr_v3"
-
- def test_external_ids_defaults_empty(self):
- c = ModelCandidate(
- name="model-1",
- model_type="ml",
- platform="ml_platform",
- metadata={},
- )
- assert c.external_ids == {}
-
-
-class FakeScanner:
- name = "fake"
- platform_type = "test"
-
- def __init__(self, candidates: list[ModelCandidate]):
- self._candidates = candidates
- self._changed = True
-
- def scan(self) -> list[ModelCandidate]:
- return self._candidates
-
- def has_changed(self, last_scan: datetime) -> bool:
- return self._changed
-
-
-class TestScannerProtocol:
- def test_implements_protocol(self):
- scanner = FakeScanner([])
- assert isinstance(scanner, Scanner)
-
- def test_scan_returns_candidates(self):
- candidates = [
- ModelCandidate(
- name="m1",
- model_type="ml",
- platform="fake",
- metadata={},
- ),
- ModelCandidate(
- name="m2",
- model_type="ml",
- platform="fake",
- metadata={},
- ),
- ]
- scanner = FakeScanner(candidates)
- result = scanner.scan()
- assert len(result) == 2
-
-
-class FakeEnrichableScanner:
- name = "enrichable"
- platform_type = "test"
-
- def scan(self) -> list[ModelCandidate]:
- return []
-
- def has_changed(self, last_scan: datetime) -> bool:
- return True
-
- def enrich(self, candidate: ModelCandidate) -> dict:
- return {"enriched": True, "name": candidate.name}
-
-
-class TestEnrichableScanner:
- def test_implements_protocol(self):
- scanner = FakeEnrichableScanner()
- assert isinstance(scanner, EnrichableScanner)
-
- def test_enrich_returns_dict(self):
- scanner = FakeEnrichableScanner()
- c = ModelCandidate(
- name="test",
- model_type="ml",
- platform="x",
- metadata={},
- )
- result = scanner.enrich(c)
- assert result["enriched"] is True
-
-
-class TestScanReport:
- def test_create(self):
- report = ScanReport(
- platform="ml_platform",
- total_found=10,
- new_models=3,
- updated_models=1,
- not_found_models=0,
- candidates=[],
- )
- assert report.total_found == 10
- assert report.new_models == 3
- assert report.timestamp is not None
diff --git a/tests/test_scanner/test_registry.py b/tests/test_scanner/test_registry.py
deleted file mode 100644
index f7d3ca1..0000000
--- a/tests/test_scanner/test_registry.py
+++ /dev/null
@@ -1,67 +0,0 @@
-"""Tests for ScannerRegistry — scanner discovery via entry_points."""
-
-from datetime import datetime
-
-import pytest
-
-from model_ledger.scanner.protocol import ModelCandidate, Scanner
-from model_ledger.scanner.registry import ScannerRegistry, get_registry, reset_registry
-
-
-class FakeScanner:
- def __init__(self, name: str):
- self.name = name
- self.platform_type = "test"
-
- def scan(self) -> list[ModelCandidate]:
- return []
-
- def has_changed(self, last_scan: datetime) -> bool:
- return True
-
-
-class TestScannerRegistry:
- def test_register_and_get(self):
- reg = ScannerRegistry()
- scanner = FakeScanner("ml_platform")
- reg.register(scanner)
- assert reg.get("ml_platform").name == "ml_platform"
-
- def test_get_unknown_raises(self):
- reg = ScannerRegistry()
- with pytest.raises(KeyError):
- reg.get("nonexistent")
-
- def test_list_scanners(self):
- reg = ScannerRegistry()
- reg.register(FakeScanner("a"))
- reg.register(FakeScanner("b"))
- names = [s.name for s in reg.list_scanners()]
- assert "a" in names
- assert "b" in names
-
- def test_register_deduplicates_by_name(self):
- reg = ScannerRegistry()
- reg.register(FakeScanner("ml_platform"))
- reg.register(FakeScanner("ml_platform"))
- assert len(reg.list_scanners()) == 1
-
- def test_implements_scanner_protocol(self):
- scanner = FakeScanner("test")
- assert isinstance(scanner, Scanner)
-
-
-class TestGlobalRegistry:
- def setup_method(self):
- reset_registry()
-
- def test_get_registry_returns_same_instance(self):
- r1 = get_registry()
- r2 = get_registry()
- assert r1 is r2
-
- def test_reset_registry(self):
- r1 = get_registry()
- reset_registry()
- r2 = get_registry()
- assert r1 is not r2