Metadata-Version: 2.4
Name: hank-model-authority
Version: 1.6.1
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.3` and admits 2.0 through 2.3 artifacts
(`health_fact()["admits"]` is `model-authority/2.0-2.3`): 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.

A reader that needs one capability's concrete no-override default (a startup
default entry, a dry-run label, a contract check) calls
`project_capability_default(registry, policy, capability_id)`; it returns the
`kind: model` default as written and projects `kind: tier` through
`project_tier`. Never read `.default.model_key` directly: it is `None` for tiers.

`resolve_capability_model` adds keyword-only `requested_role`, `role_map`,
`role_map_source` and `requested_role_source`. A map's entries replace tier
defaults and may replace fallback chains; 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.

### Fallback chains and presets (2.3)

A role policy may declare `fallback` alongside `default` and `allowed_model_keys`:

```yaml
deep:
  default: {models: ["anthropic.claude-opus-5-5"], effort: high}
  allowed_model_keys: ["anthropic.claude-opus-5-5", "codex.gpt-6-1-sol", "anthropic.claude-sonnet-5-5"]
  fallback:
    - {model: "codex.gpt-6-1-sol"}
    - {model: "anthropic.claude-sonnet-5-5"}
```

Entries are ordered `{model: key, effort?: exact}` or `{alias: role, effort?: exact}`.
An absent or empty list means no chain; an alias role with no list inherits its
base target's chain, while its own list (including `[]`) replaces inheritance.
Entry aliases capture the target role's **base-policy assignment**, filtered by
the target and containing role ceilings, never its fallback chain. Each alias is
one hop: `project_fallback_chain(registry, policy, capability_id, requested_role)`
chooses its first slot-qualified candidate inside the slot ceiling. The result
contains ordered `entries` (`FallbackModel`) and `omitted` entries with typed
reason codes; a slot unable to use an entry does not invalidate the artifact.

Admission checks entry keys, lifecycle, alias resolution, role ceilings and exact
effort. An omitted entry effort is checked against the containing role's default
effort at load but retained as `None`, meaning the bound effort at hop time.
An explicit entry effort is never clamped. Fallback admission refusals retain a
typed `ModelAuthorityError.code` (`capability_model_unavailable`,
`capability_model_not_allowed`, `capability_role_unknown`,
`capability_role_unavailable`, `capability_effort_invalid`,
`capability_effort_unsupported` or `capability_selection_invalid`).

`selection.presets` maps preset names to partial role maps. Entries take the
assignment shapes above plus optional `fallback`; ceilings remain base policy.
For example, `economy: {default: {models: ["anthropic.claude-sonnet-5-5",
"anthropic.claude-sonnet-5-5-sdk"], effort: high}}`. The admitted
`policy.presets` and its maps are read-only. Unknown roles, alias cycles,
unavailable keys, unsupported efforts and widened ceilings fail at load.
Use `role_map=policy.presets["economy"]` with projection or snapshot capture.
A preset entry that omits `fallback` keeps that role's policy chain; a supplied
list replaces it. Fallback aliases remain base-policy assignments even when
the preset changes their target's primary assignment.

The packaged artifact publishes a `kimi_dev` preset: `kimi_code.k3` first for
`deep`, `standard` and `fast`, with the non-frontier pool behind it.
`HANK_MODEL_PRESET` names a published preset the loader applies at load as the
BASE role map for bare selection calls (`role_map=None`, no snapshot) to
`capture_selection_snapshot`, `project_tier` and `project_fallback_chain`; an
explicit run, saved or firm role map still wins unchanged. A blank or
whitespace value is unset; an unknown name refuses the load. It rides
`load_configured_model_authority` exactly like `HANK_LOCAL_FALLBACK_ENABLED`,
propagates to autonomous children through the child-environment allowlist and
to isolated gateways via `scripts/local_gateway_isolated.py`, and appears as
`health_fact()["default_preset"]` (null when unset).

### 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, plus its resolved `fallback_chains`.
`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"}}, "fallback_chains": {"default": [{"models": ["fallback-key"], "effort": null}]}, "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, fallback chains, capabilities, channels and overridden
names are sorted, while candidate arrays and chain entries preserve 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`.
Older snapshot JSON without `fallback_chains` loads with empty chains for all
captured roles. Each frozen chain entry holds the resolved base candidates for
one hop and its optional exact effort. `project_fallback_chain` accepts the same
`role_map` or `selection_snapshot` inputs; frozen chains use current slot/role
ceilings and qualification, not a later policy's chain.

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`; artifacts
older than 2.3 cannot contain `fallback` or `selection.presets`. The packaged
artifact intentionally stays 2.1 until consumer loaders upgrade; publishing new
minor 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`).
