# The default chat model — licence sign-off record, source pin and size ceiling.
#
# PersonalClaw's zero-config promise is that a fresh install reaches a
# working chat turn with NO provider bound, NO API key and NO Ollama reachable. Keeping that
# promise means this project puts a model weight on the user's machine under its own name, and
# that is a licence act whether the bytes travel in the wheel or arrive over the wire.
#
# THE WEIGHT IS NOT IN THE WHEEL, on purpose: the wheel ships without it and the model is
# fetched on first run. The weight is 138 MiB — over PyPI's
# 100 MiB per-file limit, and a ~147 MiB wheel for every install is a cost most users would pay
# for a model they replace on day one. So it is downloaded ONCE, at the user's explicit
# go-ahead, into `$PERSONALCLAW_HOME/models/bundled-chat/`, and verified against the digest
# below before anything will run it.
#
# WHAT THAT COSTS, STATED PLAINLY: the FIRST chat on a fresh install needs network. Everything
# after it does not. No surface may claim offline-capable first chat, because it is not one.
#
# THIS FILE IS THE RECORD, AND IT LIVES WHERE IT SHIPS. The bundled-chat app reads it from its
# own directory at runtime, so it is a real file here, beside `provider.py`, and the docs that
# describe the model link to it — not the reverse. It used to be a symlink into
# `docs/architecture/`. The container image copies only `src/`, so the link dangled there, no
# record was installed, and the image could neither offer nor fetch the model (2026-09-25).
# `tests/test_src_tree_is_self_contained.py` now reds on any symlink that leaves `src/`.
#
# Three rails read it:
#
#   * the LICENCE ALLOWLIST rail — `personalclaw.bundled_model.licence_decision`. An explicit
#     allowlist of permitted licence identifiers, DEFAULT-DENY for anything unlisted, and a
#     refusal that NAMES the licence it refused. It does not "skip" a model it cannot clear.
#   * the DIGEST rail — `personalclaw.bundled_model.verify_download`. Bytes that arrive over the
#     network are an UNTRUSTED input in a way a wheel member never was, so this is the security
#     control on the path, not a corruption checksum. A mismatch is refused loudly, the bytes
#     are discarded, and the refusal says retrying is not the fix (the source pins an immutable
#     revision, so a mismatch means the pin is wrong or something rewrote the transfer).
#   * the RELEASE gates, which read it out of the BUILT ARTIFACT rather than out of this tree.
#     `scripts/verify_wheel.py` installs the wheel, reads the record the installed app reads
#     (through `scripts/installed_bundled_model_probe.py`), boots the install and requires a
#     non-null `chat_download_offer`; `tools/docker_single_container_smoke.py` asks the built
#     image the same two questions. Both are wired into `.github/workflows/release.yml`. The
#     wheel gate also runs `personalclaw.bundled_model.gate_wheel`, which asserts the OPPOSITE
#     of what an earlier revision did: the wheel must carry NO weight-shaped member and must
#     stay under a size ceiling. A weight creeping back into `package-data` would otherwise
#     first surface as PyPI rejecting the upload of an already-tagged release.
#
# `tests/test_bundled_model_gate.py` holds every rail's teeth, including the known-false
# licence controls (Gemma Terms, Llama community, LFM, CC-BY-NC, OpenRAIL, research-only) that
# MUST be refused, and `scripts/zero_config_first_turn_drive.py` drives the end-to-end promise —
# fetch, resolve AND complete a real turn — on a throwaway home.
#
# ── THE KEYS ─────────────────────────────────────────────────────────────────────────────────
#
# All SEVEN are required. A partial record is REFUSED, not ignored: read as "no model" it would
# let bytes land with no recorded terms, no pin and no ceiling.
#
#   model_id           the exact upstream model id, as published (`<org>/<name>`).
#   licence            the SPDX identifier, exactly. `Apache-2.0` and `MIT` are the only
#                      permitted values, by sign-off. There is NO fuzzy matching: prose such
#                      as "Apache License 2.0" is refused, because a matcher loose enough to
#                      accept prose is loose enough to accept "Gemma Terms (Apache-2.0-style)".
#   licence_url        a link to the licence text for THIS model, as published.
#   artifact           where the weight LANDS, relative to `$PERSONALCLAW_HOME`. Not a wheel
#                      member — it stopped being one when the weight left the wheel, and a
#                      record still naming `personalclaw/...` would describe a path nothing
#                      writes.
#   sha256             the hex sha256 of those bytes. This is what chains the sign-off to what
#                      the user actually receives: a download that hashes to anything else is
#                      discarded, so the record cannot come to describe a different model.
#   size_bytes         the EXACT size of those bytes, as a plain integer. Separate from the
#                      ceiling below and doing a different job: this is the number a user is
#                      shown before agreeing to the download and the number a progress bar
#                      divides by, so a bar driven off the ceiling would sit at 92% when the
#                      file finished. It is also the cheap truncation check — a transfer that
#                      ends short is caught by arithmetic before the digest has to be computed.
#   size_budget_bytes  the documented ceiling, in BYTES, as a plain integer (`_` separators
#                      allowed). It bounds the DOWNLOAD — what stops a moved upstream file from
#                      pulling gigabytes onto someone's disk. Bytes and not "138 MiB" on
#                      purpose: a unit-bearing ceiling is where MB-vs-MiB ambiguity enters the
#                      one number a gate enforces.
#   source_url         where the bytes come from, pinned to an immutable revision (a commit
#                      sha, never a branch). The downloader reads THIS field and `sha256` and
#                      carries no copy of either, so there is nothing to drift.
#
# Format: one `key: value` per line. `#` starts a comment. Unknown keys are refused.
#
# ── THE SIGNED-OFF BUNDLE ────────────────────────────────────────────────────────────────────

model_id: unsloth/SmolLM2-135M-Instruct-GGUF
licence: Apache-2.0
licence_url: https://huggingface.co/unsloth/SmolLM2-135M-Instruct-GGUF/blob/main/README.md
artifact: models/bundled-chat/SmolLM2-135M-Instruct-Q8_0.gguf
sha256: c4a3dd037301b6ecea31d6da37f5cd793ead920dd5ddfe6d589294628d6ce66a
size_bytes: 144_811_072
size_budget_bytes: 157_286_400
source_url: https://huggingface.co/unsloth/SmolLM2-135M-Instruct-GGUF/resolve/9e6855bc4be717fca1ef21360a1db4b29d5c559a/SmolLM2-135M-Instruct-Q8_0.gguf

# ── WHY THIS MODEL ───────────────────────────────────────────────────────────────────────────
#
# LICENCE (only a permissive licence ships; verified live 2026-09-23 against the Hugging Face
# model API):
#
#   * `unsloth/SmolLM2-135M-Instruct-GGUF`  — `license: apache-2.0`, `base_model:
#     HuggingFaceTB/SmolLM2-135M-Instruct`. This is the repository whose BYTES ship, so this is
#     the licence that governs the redistribution, and it is what the `licence` key records.
#   * `HuggingFaceTB/SmolLM2-135M-Instruct` — `license: apache-2.0`. The base model, recorded
#     here so the chain is visible rather than assumed: both ends are Apache-2.0.
#   * Apache-2.0 §4 requires the licence text to travel with a redistribution, so the text is
#     shipped beside the weight as `weights/MODEL_LICENSE`, with attribution in
#     `weights/MODEL_NOTICE`.
#
# REFUSED, and why — the near-misses this pick had to clear:
#
#   * `meta-llama/Llama-3.2-1B-Instruct` — "Llama 3.2 Community License": attribution and
#     name-prefix requirements, a 700M-MAU threshold, an acceptable-use policy. Not OSI.
#   * `LiquidAI/LFM2-350M` — "LFM Open License v1.0". "Open" in the name is not OSI.
#   * Gemma (any) — Google's Gemma Terms of Use carry use restrictions. This is the licence
#     rule's named known-false control, and `tests/test_bundled_model_gate.py` asserts the rail
#     refuses it.
#
# SIZE — the number this budget is set from, measured file by file on the source repository:
#
#   Q2_K      88_201_792 B    84.1 MiB   incoherent at 135M
#   Q3_K_M    93_510_208 B    89.2 MiB   incoherent at 135M
#   Q4_K_M   105_454_144 B   100.6 MiB
#   Q5_K_M   112_103_488 B   106.9 MiB
#   Q8_0     144_811_072 B   138.1 MiB   <- SHIPPED
#   F16      270_885_952 B   258.3 MiB
#
#   Q8_0 is the pick for two reasons. (1) At 135M parameters Q8_0 is effectively lossless
#   against F16, so it is the BEST quality available in this size class — a k-quant would trade
#   the only thing a floor model has. (2) Its dequantization is a block of 32 int8 values
#   behind one fp16 scale: two vectorized reads and a broadcast multiply. A Q4_K/Q6_K
#   superblock decoder is ~60 lines of bit-twiddling whose failure mode is silently wrong
#   arithmetic — fluent nonsense, not an error — and it would not even change which side of
#   PyPI's per-file limit the artifact lands on (Q4_K_M is 100.6 MiB, still over 100 MiB).
#
#   The ceiling is 157_286_400 bytes (150.0 MiB), i.e. 11.9 MiB of headroom over the 138.1 MiB
#   that actually downloads. Deliberately tight: it absorbs a re-quant or a re-export of the
#   SAME size class and REFUSES a jump to the next one (SmolLM2-360M-Q8 is 368.5 MiB,
#   Qwen2.5-0.5B-Q4_K_M is 468.6 MiB). It bounds a download, so the thing it protects is a
#   user's disk and patience, not a wheel.
#
# WHAT EACH DISTRIBUTION SURFACE ACTUALLY DOES — one mechanism everywhere, stated per surface
# because "bundled" would otherwise mean four things:
#
#   * PyPI wheel + sdist — carry NO weight. The wheel stays ~9.2 MiB (0.1.3, measured on PyPI).
#     `verify_wheel.py` assertion 7 REFUSES a wheel that grew one, and no PyPI file-size-limit
#     increase is needed or wanted.
#   * Container image (`ghcr.io/personalclaw/personalclaw-gateway`) — also carries no weight.
#     EMBEDDING IT HERE WAS CONSIDERED AND DECLINED: an image has no per-file limit and an
#     offline-capable container is a genuinely better story, but it would make the answer to
#     "where does the model come from" different per install channel, which is the split that is
#     harder to explain. Anyone who needs an offline image pre-seeds the path
#     below with one `COPY` — see `docs/architecture/provider-boundary.md` — which is a
#     documented line rather than a second artifact pipeline this project has to build, test
#     and keep in step.
#   * Desktop bundle (`personalclaw-backend.spec`) — same: no weight, same first-run fetch.
#     `scripts/backend_bundle_manifest.py` derives the frozen payload from
#     `[tool.setuptools.package-data]`, which has no `*.gguf` glob, so this follows
#     automatically rather than by a second decision.
#   * Every install, once running — fetches to `$PERSONALCLAW_HOME/models/bundled-chat/` on
#     the user's explicit go-ahead, verifies the digest, and never fetches again. A second
#     gateway on the same home reuses the file; two starting at once are serialised by a lock
#     so neither corrupts the other's partial transfer.
#
# WHY A FETCH AND NOT A BUILD-TIME VENDOR. The weight is 138 MiB: over PyPI's per-file limit,
# and 16x the wheel. The cost is real and is stated in the CHANGELOG and on the surfaces that
# offer it — the FIRST chat on a fresh install needs network. The egress is gated the way this
# repo gates egress everywhere else: not at boot, never unsolicited, only on an explicit user
# action, to a pinned host over https, verified against a recorded digest, bounded by a
# deadline and a size ceiling, and cancellable.
#
# ── HONESTY ──────────────────────────────────────────────────────────────────────────────────
#
# This model is 135M parameters. It greets, answers simple factual questions, and gets shaky
# fast; it has no tool use. Shipping it silently would let a user conclude PersonalClaw is bad
# at chat. So the floor says what it is at the point of use: the provider declares
# `FLOOR_NOTICE`, `GET /api/onboarding` reports `chat_is_bundled_floor`, and the chat surface
# renders it. The floor is also strictly last in resolution order — binding any real provider
# retires it with no migration and no config to clean up.
