Code / src/modelmap/atlas/mapcard.py

src/modelmap/atlas/mapcard.py 132 lines
# =============================================================================
#  Project   : modelmap
#  File      : src/modelmap/atlas/mapcard.py
#  Purpose   : Map card schema v0 — machine-readable provenance + confidence
#  Author    : Simon-Pierre Boucher
#  Contact   : contact@spboucher.ai
#  Website   : https://modelmap.io
#  Created   : 2026-08-12
#  Modified  : 2026-08-12
#  Platform  : macOS / Apple Silicon (arm64)
#  License   : All rights reserved (research code)
# =============================================================================
"""Map card v0 (gap G23, ranked #1 in Phase 4).

A map card is the datasheet of a published map. `tools/publish.py` refuses to
export any atlas entry whose card does not validate. Fields follow the Phase 2
conclusions: pinned intervention protocol, ablation-scheme coverage, featurizer
class, seed replication, per-dataset agreement — plus the charter's provenance
block (commit, config, model hash, hardware, dates).

The schema will evolve (v1+) with the first real maps; version is embedded so
old cards remain interpretable.
"""

from __future__ import annotations

import json
from dataclasses import asdict, dataclass, field
from pathlib import Path

SCHEMA_VERSION = "0.1"

CONFIDENCE_LEVELS = {
    0: "anecdotal",
    1: "correlational",
    2: "method-robust",
    3: "causal",
}

# Fields that must be non-empty for ANY publishable card.
REQUIRED_ALWAYS = (
    "map_id", "map_type", "model_id", "model_hash", "quantization",
    "commit", "config", "created", "hardware_manifest",
    "confidence_level", "regenerate_command",
)

# Additional requirements per confidence level (charter §10 taxonomy).
REQUIRED_LEVEL_1 = ("seeds", "prompt_sets", "controls")
REQUIRED_LEVEL_2 = REQUIRED_LEVEL_1 + ("methods_in_agreement",)
REQUIRED_LEVEL_3 = REQUIRED_LEVEL_2 + ("interventions",)


@dataclass
class MapCard:
    """v0 map card. All list/dict fields default empty; validate() enforces."""

    map_id: str = ""                     # atlas/<model>/<type>/<version>
    map_type: str = ""                   # probes | geometry | features | circuits | ...
    model_id: str = ""                   # e.g. Qwen/Qwen3-0.6B
    model_hash: str = ""                 # checkpoint content hash
    quantization: str = ""               # fp16 | q8 | q4 | q2 | mixed:<desc>
    commit: str = ""                     # modelmap git commit that generated the map
    config: str = ""                     # path or inline JSON of the run config
    created: str = ""                    # YYYY-MM-DD
    hardware_manifest: dict = field(default_factory=dict)
    confidence_level: int = 0
    regenerate_command: str = ""         # exact command that rebuilds the map

    seeds: list = field(default_factory=list)              # ≥3 for Level 1+
    prompt_sets: list = field(default_factory=list)        # ≥2 for Level 1+ (name+checksum)
    controls: list = field(default_factory=list)           # e.g. shuffled-labels, random-init
    methods_in_agreement: list = field(default_factory=list)  # ≥2 for Level 2+
    interventions: list = field(default_factory=list)      # ≥1 for Level 3

    replication_rate: float | None = None    # map-to-map agreement under reseeding
    per_dataset_agreement: float | None = None
    ablation_schemes: list = field(default_factory=list)   # curves published, e.g. zero/mean/resample
    featurizer_class: str = ""                # natural-basis | linear | orthogonal | DAS-optimized
    intervention_protocol: str = ""           # pinned corruption + metric (Phase 2 rule)
    negative_result: bool = False
    notes: str = ""

    author: str = "Simon-Pierre Boucher"
    contact: str = "contact@spboucher.ai"
    website: str = "https://modelmap.io"
    schema_version: str = SCHEMA_VERSION

    # ------------------------------------------------------------------ api
    def validate(self) -> list[str]:
        """Return a list of violations; empty list means publishable."""
        errs: list[str] = []
        for f in REQUIRED_ALWAYS:
            v = getattr(self, f)
            if v in ("", None) or v == {}:
                errs.append(f"missing required field: {f}")
        lvl = self.confidence_level
        if lvl not in CONFIDENCE_LEVELS:
            errs.append(f"confidence_level must be 0-3, got {lvl!r}")
            return errs
        extra = {1: REQUIRED_LEVEL_1, 2: REQUIRED_LEVEL_2, 3: REQUIRED_LEVEL_3}.get(lvl, ())
        for f in extra:
            if not getattr(self, f):
                errs.append(f"Level {lvl} requires non-empty: {f}")
        if lvl >= 1:
            if len(self.seeds) < 3:
                errs.append("Level 1+ requires >=3 seeds")
            if len(self.prompt_sets) < 2:
                errs.append("Level 1+ requires >=2 prompt sets")
            if self.replication_rate is None:
                errs.append("Level 1+ requires replication_rate")
        if lvl >= 2 and len(self.methods_in_agreement) < 2:
            errs.append("Level 2 requires >=2 independent methods in agreement")
        if lvl >= 3 and not self.featurizer_class:
            errs.append("Level 3 requires featurizer_class disclosure")
        return errs

    def to_json(self) -> str:
        return json.dumps(asdict(self), indent=2, ensure_ascii=False) + "\n"

    @classmethod
    def from_json(cls, text: str) -> MapCard:
        data = json.loads(text)
        known = {f for f in cls.__dataclass_fields__}  # tolerate future fields
        return cls(**{k: v for k, v in data.items() if k in known})

    def save(self, path: Path) -> None:
        path.write_text(self.to_json())

    @classmethod
    def load(cls, path: Path) -> MapCard:
        return cls.from_json(path.read_text())