Metadata-Version: 2.4
Name: hank-model-authority
Version: 1.4.0
Summary: Hank model authority: the model-authority/2.x artifact, its loader and capability resolver
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: PyYAML<7,>=6.0
Requires-Dist: pydantic<3,>=2.10

# hank-model-authority

The model authority every product repository reads: which models exist (registry
entries with their execution identity and per-family request facts), which model
each capability uses (selection policy), and what each model costs (rate tables).
The distribution depends on PyYAML and pydantic only, so Risk, Investment and Edgar
take it without the gateway.

Mechanism — one artifact, admission, the host directory, lifecycle, skew — is
[`docs/reference/model-authority.md`](../../docs/reference/model-authority.md); the
data deploy is [`docs/runbooks/model-authority-deploy.md`](../../docs/runbooks/model-authority-deploy.md).

## Layout

| Module | Owns |
|---|---|
| `authority/model-authority.yaml`, `authority/rates/*.json` | the packaged artifact: one `model-authority/2.x` document (registry `models`, `selection.capabilities`, one `revision`) and per-provider prices |
| `schema.py` | the version rule (`SCHEMA_MAJOR`/`SCHEMA_MINOR`), `ModelAuthorityError` and its message, protocol families and each family's `compat:` key set |
| `loader.py` | `HANK_MODEL_AUTHORITY_DIR` resolution, the strict load, the directory digest |
| `current.py` | the process's one import-time load: `INITIAL_MODEL_REGISTRY`, `INITIAL_MODEL_SELECTION_POLICY`, `RATE_TABLES`, `HEALTH_FACT`, `health_fact()` |
| `registry.py`, `selection.py` | registry entries and selection policy |
| `capabilities.py` | capability IDs and the process that executes each (a release, not data) |
| `binding.py`, `bind.py` | the single resolver and `CapabilityBind` |
| `rates.py`, `thinking.py` | rate tables; effort vocabulary |
| `__main__.py` | `python -m model_authority check <dir>`: the ordinary load of a candidate directory |

The package root imports types and the loader, never `current`, so
`python -m model_authority check <dir>` can check a candidate directory while the
host directory is broken.

## Which directory a process loads

`HANK_MODEL_AUTHORITY_DIR` set: that directory, resolved once at start; missing or
unreadable fails the start. Unset: the packaged `authority/` directory. Every
loading unit sets it — `/etc/hank/model-authority` in production,
`~/.config/hank/model-authority` on this Mac — so the packaged copy is what tests
and unwired tools load. Every process reports what it admitted through
`health_fact()`: `{"revision", "schema", "sha256", "source", "loader", "admits"}`;
`source` is the resolved release path (`…/model-authority.d/<revision>-<sha12>/…`)
or `packaged:<path>`.

## Schema

This loader implements `model-authority/2.2` and admits 2.0 through 2.2 artifacts
(`health_fact()["admits"]` is `model-authority/2.0-2.2`): same major, minor at most
its own, every other field and literal strict; each refusal is one
`ModelAuthorityError` naming artifact, revision, schema, loader and admitted range.

Each entry's `compat:` key set is its protocol family's (`schema.py`): the profile
maps to `anthropic.messages`, `openai.chat_completions`, `openai.responses` or
`google.generate_content` (`PROTOCOL_FAMILY_BY_PROFILE`), and `COMPAT_MODEL_BY_FAMILY`
holds each family's frozen key set. 2.1 adds two `anthropic.messages` keys, each
derived when absent so 2.0 artifacts load unchanged, and each refused when it
contradicts the 2.0 key it refines:

| Key | Values | Absent | Must agree with |
|---|---|---|---|
| `thinking.off_value` | `disabled`, `between_tools`, null (cannot be turned off) | `disabled` if `can_disable`, else null | `can_disable` is true exactly when `off_value` is `disabled` |
| `tool_choice_forcing` | `any`, `thinking_off_only`, `never` | `thinking_off_only` if `forced_tool_choice_requires_thinking_off`, else `any` | that flag is false exactly when forcing is `any` |

The 2.0 keys stay required (a minor removes nothing), so an adapter that still
reads `can_disable` or `forced_tool_choice_requires_thinking_off` keeps working;
the gateway's Anthropic adapter reads both new keys. Sonnet 5.5 uses
`can_disable: false, off_value: between_tools`: the legacy bool still means
whether the literal `thinking.type: disabled` is legal.

### Tier selection (2.2)

`selection.tiers` maps arbitrary non-empty role names to a `default` assignment
and an optional `allowed_model_keys` ceiling. Assignments are one of:

- `{models: [key, ...], effort: high}`: ordered, non-empty execution candidates;
  this tier must declare a ceiling containing every candidate.
- `{alias: deep, effort?: high}`: use another tier's assignment, optionally
  replacing its effort. An absent ceiling inherits the target's ceiling over
  the **base policy** alias graph, before any override. Aliases must be acyclic
  and target an existing tier.
- `{kind: inherit_parent}`: copy the existing exact parent bind; no role ceiling.

Capability and channel defaults may be `{kind: tier, tier: default, effort?: high}`.
The static `project_tier` owner is shared by artifact admission and resolution:
first candidate qualified for the capability, in both the slot and referring
tier ceilings, and eligible for new selection. Effort is requested effort,
then slot-local effort, then resolved tier effort (outer alias effort wins).
The chosen candidate must support that exact effort; no clamp or next-candidate
retry occurs. Existing entitlement and credential checks follow selection, and
their failure never falls through to another candidate.

`resolve_capability_model` adds keyword-only `requested_role`, `role_map`,
`role_map_source` and `requested_role_source`. A map's entries replace tier
**defaults only**, omitted roles use policy, and aliases resolve within that
effective map. Redirecting an alias cannot widen the referring role's fixed
base ceiling. Map values may be `TierModelsDefault`, `TierAliasDefault`,
`TierInheritParentDefault`, or their literal mappings.

Existing explicit, saved and authenticated concrete-model inputs win first.
Then requested role wins over channel/default tier selection. Selection source
is, in order: inherited assignment → `parent_binding`; explicit-user requested
role → `explicit_user`; an overridden assignment anywhere in its alias chain →
`role_map_source` (`saved_preference` or `internal_policy`, required for a supplied
map); other requested role → `internal_policy`; channel tier → `channel_default`;
otherwise the existing capability-default
source (`capability_default` for the driver, `internal_policy` for other slots).
Role/map provenance does not extend concrete-choice permission flags or tools.
`CapabilityBind` stays 1.0 with its existing source literals.
Inherited role assignments still enforce both the requested role's fixed ceiling
and the slot ceiling against the parent's model. Without a snapshot, concrete
selections without their own effort use applicable base-policy tier effort, never map effort.

### Frozen selection snapshots

`capture_selection_snapshot(selection_policy, role_map=..., role_map_source=...)`
returns an immutable `SelectionSnapshot` containing every capability's
`CapabilityDefaults` (`default` plus `by_channel`, each a `CapabilityDefault`) and
every capture-time tier's alias-resolved `{models, effort}` or
`{kind: inherit_parent}` assignment.
`overridden` records roles whose effective alias chain used the winning map,
even when that map value equals the policy value. `source` is the winning map's
`saved_preference` or `internal_policy` literal exactly when `overridden` is
non-empty; otherwise it is null. Independent captures compare by value.

`snapshot.to_json()` returns this closed, versioned form:

```json
{"version": 1, "policy_revision": "revision", "assignments": {"default": {"models": ["model-key"], "effort": "high"}}, "capabilities": {"session.driver": {"default": {"kind": "tier", "tier": "default"}, "by_channel": {}}}, "overridden": ["default"], "source": "saved_preference"}
```

The example shows one slot; a snapshot holds every capability that existed at capture. Top-level
keys have the order shown; assignments, capabilities, channels and overridden
names are sorted, while model arrays preserve candidate order. `SelectionSnapshot.from_json`
round-trips the value; missing/unknown keys, wrong types, unresolved aliases,
unknown efforts and empty candidate arrays raise `TierProjectionError` with
`capability_selection_invalid`.

Pass `selection_snapshot=snapshot` to `resolve_capability_model` or `project_tier`
instead of `role_map` (supplying both raises `ValueError`). Slot defaults,
channel defaults, slot-local effort and tier assignments are frozen; a later slot
repoint cannot select a newly published tier or concrete model. The referring
tier's ceiling, slot allowance, permission flags, capability exposure, lifecycle,
entitlement and credentials remain current. A tier absent from the snapshot or
removed from current policy is `capability_role_unknown`; no currently eligible
candidate is `capability_role_unavailable`. A snapshot is validated on its own
contents, not against today's capability set, so one journaled before a release
that adds or retires a capability still loads and serves its captured slots; a
requested slot it did not capture is `capability_policy_missing`, and a frozen
model key the current registry no longer has is `capability_model_unavailable`.
The exact effort rules and source
precedence above still apply: only overridden roles use `snapshot.source`, while
the others retain today's selection-source literal. Concrete explicit, saved or
run-override selections without effort use the frozen applicable slot default's
effort when a snapshot is supplied. Omitting the snapshot (or passing `None`)
leaves resolution unchanged.


Artifacts declaring 2.0 or 2.1 cannot contain tiers or `kind: tier`. The packaged
artifact intentionally stays 2.1 until consumer loaders upgrade in W2; publishing
2.2 data before those upgrades would prevent those consumers from starting.

## Changing it

- **A data change** (a model, a default, a price, a compat value) edits
  `authority/` and ships by `scripts/deploy_model_authority.sh`, not by a release
  ([runbook](../../docs/runbooks/model-authority-deploy.md)). Declare the lowest
  schema minor that covers the fields the artifact uses.
- **A schema change** is a release: a new optional field or literal is a minor
  (`SCHEMA_MINOR`), a removal or redefinition a major. Deploy loaders before data
  that uses a new minor: a loader refuses an artifact whose minor is newer than its
  own, naming both.
- **A capability or its executing process** is a release (`capabilities.py`).

Check a candidate directory with the ordinary load:

```bash
python -m model_authority check <dir>   # prints the health fact, or exits 1 with the refusal
```

Tests: `.venv/bin/python3 -m pytest packages/model-authority/tests -q`
(`make test-area AREA=model_authority`).
