diff --git a/CHANGELOG.md b/CHANGELOG.md index 78fc7d0..d4b7723 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## 0.3.0 — 2026-07-27 + +- Make the four-project listening core—AKOÚŌ 0.9.0, Earworm 0.6.0, + Akousmata 0.6.0, and Oída 0.9.0—the default install profile. GERM 0.2.5 is + now installed and configured only through an explicit `full` or `germ` + selection; the former `oida` selector remains a compatibility alias. +- Introduce `listening-stack/state/v2`, recording the canonical profile, exact + component set, core components, optional components, immutable revisions, + and the expanded accountable-listening contract matrix. Version 1 state + remains readable for lifecycle compatibility. +- Remove GERM directories, endpoints, provider settings, and lifecycle probes + from core-only installations. Full installations retain the bounded + loopback, path, model, and shared-memory safeguards. +- Pin the decision-first Oída gateway and route-outcome contract, AKOÚŌ context + v2, Earworm auditum v2, and Akousmata's accountable-memory contract. Extend + the doctor to verify the live route-outcome schema as well as host input, + listening events, and listening context. +- Document attributable passes, provenance and cuts, decision-only records, + coded silence, honest absence, evidence attribution, reciprocal ear swarms, + forgetting receipts, and non-resurrection across the installed system. + +## 0.2.0 — 2026-07-22 + +- Pin Oída 0.8.0, AKOÚŌ 0.8.0, Earworm 0.5.0, and Akousmata 0.5.0 by immutable + source revision while retaining the tested GERM 0.2.5 and model-runtime set. +- Record the accountable-listening compatibility matrix in completed installer + state: Oída gateway, host-perception and listening-event contracts; AKOÚŌ + listening context and claims; Earworm auditums; and Akousmata navigation. +- Extend the read-only doctor from process health to the actual integration + boundary. It verifies Oída's live manifest and three public schemas using + bounded, non-redirecting loopback requests. +- Document single semantic ownership, situated listening context, capability + versus authority, honest absence, preserved disagreement, append-only + revision, and the nonfinancial meaning of a tokenized auditum. +- Show the listening contracts in every Oída/full install plan and update the + standalone deterministic release artifact and tests for the 0.2 compatibility + set. + ## 0.1.2 — 2026-07-16 - Pin the tested compatibility set to Oída 0.6.5, GERM 0.2.5, AKOÚŌ 0.7.0, diff --git a/CITATION.cff b/CITATION.cff index 12198fa..f9d17a6 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -2,8 +2,8 @@ cff-version: 1.2.0 message: "If you use the Listening Stack Installer in research, cite this software and the installed applications and models separately." title: "The Listening Stack Installer" type: software -version: 0.1.2 -date-released: 2026-07-16 +version: 0.3.0 +date-released: 2026-07-27 authors: - family-names: "Isaza" given-names: "Eme" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bf20ef7..3052e9e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,8 @@ testable without downloading model weights. 4. **Interrupted-download recovery:** add deterministic fixtures for partial Hugging Face cache and network failure states. 5. **Integration contracts:** test Oída adapter installation against temporary - Hermes, Codex, Claude, OpenClaw, and OpenCode configuration homes. + Hermes, Codex, Claude, OpenClaw, and OpenCode configuration homes, and keep + live gateway/schema checks aligned with the pinned contract matrix. ## Development @@ -43,5 +44,8 @@ changes, model downloads, and service startup. state, home-directory paths, private hostnames, or network addresses. - Do not silently broaden a system mutation. New package-manager, config, or network actions need an explicit prompt and documentation. +- Keep one semantic owner per listening concept. Installer checks may verify a + component contract, but must not redefine AKOÚŌ claims, Earworm provenance, + or Oída runtime authority. By contributing, you agree that your contribution is licensed under Apache-2.0. diff --git a/README.md b/README.md index 9eadaac..8b84b08 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,24 @@ > **Public alpha · Open research release · Local-first · Open-source · Under active development** -The Listening Stack Installer prepares [Oída](https://github.com/sonicfieldlabs/oida) -and [GERM](https://github.com/sonicfieldlabs/germ), their local runtimes, and -the models an operator explicitly selects. It checks the host, explains disk -and memory requirements, keeps weights outside Git, and leaves both local -gateways ready to start. - -**Oída hears. GERM cultivates. Akousmata remembers. AKOÚŌ structures. Earworm -routes.** +The Listening Stack Installer prepares the accountable listening core: +[AKOÚŌ](https://github.com/sonicfieldlabs/akouo), +[Earworm](https://github.com/sonicfieldlabs/earworm), +[Akousmata](https://github.com/sonicfieldlabs/akousmata), and +[Oída](https://github.com/sonicfieldlabs/oida). [GERM](https://github.com/sonicfieldlabs/germ) +is available as an explicit optional installation. The assistant checks the +host, explains disk and memory requirements, keeps weights outside Git, and +prepares only the services and models an operator selected. + +**Oída listens. AKOÚŌ structures claims. Earworm addresses auditums. Akousmata +renders and audits memory. GERM optionally cultivates.** This repository is an installer and operator assistant. Oída, GERM, AKOÚŌ, Earworm, and Akousmata remain independent repositories with their own histories, licenses, and releases. The assistant does not duplicate their application code. -Current installer release: `0.1.2`. +Current installer release: `0.3.0`. ## Quick Start @@ -50,34 +53,68 @@ chmod +x listening-stack.pyz ## What the Assistant Can Install -- **Full stack:** Oída, GERM, the supporting Oída libraries, selected models, - and one local Stable Audio 3 provider. -- **Oída only:** Oída and the AKOÚŌ, Earworm, and Akousmata source dependencies - required by its current `uv` workspace. +- **Core profile (default):** AKOÚŌ, Earworm, Akousmata, Oída, and any selected + MOSS-Audio models. It neither installs nor configures GERM. +- **Full profile:** the core plus an explicitly selected GERM installation and, + when requested, one local Stable Audio 3 provider. - **GERM only:** GERM with its mock path or a selected Stable Audio 3 runtime. -- **Model-free paths:** Oída's deterministic/stub route and GERM's mock route, - with no weights or hosted account. +- **Model-free paths:** the core's deterministic/stub route and, only in a + GERM-containing profile, GERM's mock route, with no weights or hosted account. - **Optional agent adapters:** Hermes, Codex, Claude, OpenClaw, and OpenCode, installed through Oída only after they are selected. Sources are cloned into a dedicated installation directory. The installer verifies every existing origin and refuses to update a dirty or unexpected -checkout. Release `0.1.2` installs one immutable compatibility set and records +checkout. Release `0.3.0` installs one immutable compatibility set and records the exact commits it installed in local state: | Component | Tested release | | --- | --- | -| Oída | 0.6.5 | +| Oída | 0.9.0 | | GERM | 0.2.5 | -| AKOÚŌ | 0.7.0 | -| Earworm | 0.4.0 | -| Akousmata | 0.4.0 | +| AKOÚŌ | 0.9.0 | +| Earworm | 0.6.0 | +| Akousmata | 0.6.0 | The official MOSS-Audio source is pinned to the revision tested with Oída -0.6.5. Stable Audio 3 source is pinned to the same revision locked by GERM +0.9.0. Stable Audio 3 source is pinned to the same revision locked by GERM 0.2.5. Rerunning this installer therefore reproduces the compatibility set; it does not silently advance a checkout to a newer moving branch. +## Accountable Listening Contract + +Release 0.3.0 installs listening as an explicit, inspectable chain: + +1. AKOÚŌ owns routing and claim discipline under `akouo/v0.9`, including + situated `akouo/listening-context/v2`, attributable listening passes, + provenance and cuts, explicit route decisions, and declared ensembles. +2. Oída owns runtime perception and the decision-first gateway. A successful + hearing emits `oida/listening-event/v0.3` through `oida/gateway/v0.5`; a + refusal before perception emits `oida/route-outcome/v0.1` instead of + fabricating an empty hearing. +3. Earworm owns addressable, append-only `earworm/auditum/v2` lineage, + disagreement, decision-only records, action receipts, revision, and + forgetting receipts. “Tokenized” here means structured, addressable, and + versioned—never a financial token. +4. Akousmata renders, queries, and structurally audits those records without + becoming a second owner of claims or provenance. + +Capability and authority stay separate. A host may declare what its apparatus +can perceive, but Oída recomputes the effective context and defaults operational +authority to observe-only. Prompts, transcripts, and contextual descriptions +remain attributed text rather than being relabeled as heard evidence. +Unsupported measurements remain absent rather than being inferred from model +prose. A pause, refusal, withholding, or forgetting decision is recorded as a +decision, not confused with honest absence or an undetermined claim. Distinct +routes remain distinct listenings; multiple listeners become an ear swarm only +when they remain attributable and demonstrably redirect one another. + +The installer records this semantic compatibility matrix, the selected profile, +and its exact component set in `listening-stack/state/v2` state. +When Oída is running, `listening-stack doctor` reads the live gateway manifest +and all four public schemas to verify the contract at the actual integration +boundary. See [Accountable listening architecture](docs/accountable-listening.md). + ## Model Choices | Model | Used by | Approx. download | Planning RAM | Access | @@ -111,7 +148,7 @@ MOSS-Audio code and released model checkpoints are Apache-2.0. Oída is develope and tested first against the 4B Instruct and Thinking checkpoints while remaining model-agnostic at its gateway boundary. -Oída 0.6.5 requires Safetensors for its embedded model loader and pins its +Oída 0.9.0 requires Safetensors for its embedded model loader and pins its compatible Torch, TorchAudio, TorchCodec, and Transformers releases. The installer downloads MOSS checkpoints by immutable Hugging Face commit and the doctor verifies both model configuration and Safetensors weights. @@ -169,13 +206,23 @@ listening-stack stop germ Run a reproducible non-interactive install: +```bash +listening-stack install \ + --component core \ + --models recommended \ + --integration codex \ + --root "$HOME/SonicField/ListeningStack" \ + --yes +``` + +Add GERM only by selecting it explicitly: + ```bash listening-stack install \ --component full \ --models recommended \ --provider auto \ --accept-model-terms \ - --integration codex \ --root "$HOME/SonicField/ListeningStack" \ --yes ``` @@ -190,18 +237,25 @@ for presets, individual model keys, dry runs, and automation. After `listening-stack start`: - Oída gateway and agent: `http://127.0.0.1:8765` -- GERM dashboard: `http://127.0.0.1:5178/dashboard` +- GERM dashboard, only when installed: `http://127.0.0.1:5178/dashboard` Both bind to loopback. The generated environment file contains paths and non-secret settings only. Hugging Face credentials stay in the Hugging Face CLI's own local credential store and are never copied into this repository or the installer state. -Oída and GERM share one explicit `AKOUSMATA_PATH` under the installation root. -GERM's allowed hosts, input roots, and model roots are bounded to the generated -loopback and install-root paths. The bounded input set includes GERM output, -Oída's handoff audio, and the shared Akousmata store; cloud image analysis -remains disabled unless an operator enables it separately. +The core uses one explicit `AKOUSMATA_PATH` under the installation root. When +GERM is selected, Oída and GERM share that path; GERM's allowed hosts, input +roots, and model roots are bounded to generated loopback and install-root +paths. A core-only install records no GERM URL, creates no GERM data directory, +and does not probe or start GERM. + +Oída's public schema boundary is available at: + +- `http://127.0.0.1:8765/gateway/schema/host-perception` +- `http://127.0.0.1:8765/gateway/schema/listening-event` +- `http://127.0.0.1:8765/gateway/schema/listening-context` +- `http://127.0.0.1:8765/gateway/schema/route-outcome` ## Agent Integrations @@ -225,6 +279,8 @@ the stack's local services. - The bootstrap verifies the release checksum before execution. - Component and model-runtime source checkouts use immutable revisions. +- The recorded semantic contracts are verified against Oída's live manifest and + schemas rather than inferred from package versions alone. - No model, token, recording, generated sound, log, machine path, or installer state is committed to this repository. - Model terms are displayed before gated downloads. @@ -241,10 +297,11 @@ a shared machine. - Downloads are large and can be interrupted by network or access restrictions. - Model availability, upstream terms, and hardware compatibility can change. -- The doctor can confirm files, imports, origins, and gateway health; it cannot +- The doctor can confirm files, imports, origins, gateway health, and listening + contracts; it cannot certify model output quality or legal fitness for a particular use. - Stable Audio 3's optimized paths differ across CPU, CUDA, and Apple Silicon. -- Oída and GERM interfaces remain under active development before 1.0. +- Oída and the optional GERM interface remain under active development before 1.0. - Stable Audio 3's Python loader resolves gated model revisions through its upstream Hugging Face contract; the exact resolved cache revision is recorded in installer state when available. @@ -263,8 +320,8 @@ bash -n install.sh listening-stack Use a dry run to inspect installation commands without changing the machine: ```bash -./listening-stack install --component full --models recommended \ - --accept-model-terms --yes --dry-run +./listening-stack install --component core --models recommended --yes --dry-run +./listening-stack install --component full --models none --yes --dry-run ``` See [CONTRIBUTING.md](CONTRIBUTING.md), [CHANGELOG.md](CHANGELOG.md), diff --git a/ROADMAP.md b/ROADMAP.md index 17c66f8..91dee86 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,13 +7,17 @@ The roadmap describes public-alpha direction and does not promise dates. - Signed release artifacts in addition to SHA-256 checksums. - More precise CUDA, Apple unified-memory, CPU, and free-disk preflight. - Resumable install summaries after interrupted provider or model downloads. -- Contract tests against released Oída and GERM installer surfaces. +- Released-artifact smoke tests that exercise Oída's accountable-listening + gateway and one model-free Oída-to-GERM handoff. - Screen-reader and low-vision terminal audits. - Compatibility-set migration previews with release notes before changing pins. +- Contract-diff previews that distinguish additive schema changes from semantic + ownership or authority changes. ## Toward Beta -- Stable installer-state migrations. +- Migration previews and explicit upgrade commands for future installer-state + contracts beyond the backward-readable version 2 format. - Opt-in version-channel selection beyond the default tested compatibility set. - Reproducible provider-lock metadata and model revision pinning. - Optional signed desktop launchers built from the same assistant core. diff --git a/docs/accountable-listening.md b/docs/accountable-listening.md new file mode 100644 index 0000000..67ea257 --- /dev/null +++ b/docs/accountable-listening.md @@ -0,0 +1,88 @@ +# Accountable Listening Architecture + +Listening Stack 0.3.0 treats listening as a chain of bounded contracts, not as +one undifferentiated model response. Each concept has one semantic owner and is +parsed once at the boundary that owns it. + +## Ownership + +| Boundary | Owner | Contract | Responsibility | +| --- | --- | --- | --- | +| Gateway | Oída | `oida/gateway/v0.5` | Decision-first runtime perception, routing, covenant application, and integration surfaces | +| Host input | Oída | `oida/host-perception/v0.4` | Attributed perception supplied by an audio-capable host | +| Listening event | Oída | `oida/listening-event/v0.3` | A hearing that actually occurred, with context, passes, provenance, apparatus, decisions, and disagreement | +| Route outcome | Oída | `oida/route-outcome/v0.1` | A complete refusal or other pre-perception stop without a fabricated hearing | +| Listening context | AKOÚŌ | `akouo/listening-context/v2` | Position, apertures, scales, sources, participants, authority, passes, decisions, and honest absence | +| Claims and routes | AKOÚŌ | `akouo/v0.9` | Listening modes, evidence classes, provenance, ensembles, confidence, and limits | +| Durable auditum | Earworm | `earworm/auditum/v2` | Addressable hearing or decision lineage, disagreement, action and forgetting receipts, and revision | +| Memory navigator | Akousmata | `akousmata/v0.6` | Rendering, querying, and structural audit without redefining claims | + +“Tokenized auditum” means structured, addressable, and versioned. It does not +mean cryptocurrency, a tradable asset, or a financial token. + +## The Runtime Invariants + +- A covenant states what the listener may do. +- A route decision states whether perception, memory, disclosure, retention, or + action proceeds. Its outcome is data even when no hearing follows. +- Position states where and in what relation listening occurred. +- Apparatus states what the listener can sense. +- Apertures state which evidence openings were actually available in this run. +- Claims state what the available evidence supports. +- Prompts, transcripts, and contextual descriptions remain attributed text; + their presence does not prove that a listener heard their contents. +- Action authority states what may be changed; capability alone never grants it. +- Honest absences name unavailable evidence rather than silently filling it in. +- Honest absence, epistemic undetermination, and coded silence are different: + one names missing evidence, one marks the limit of a claim, and one records a + decision such as pause, abstain, refuse, withhold, forget, or do not act. +- Distinct routes remain distinct listenings, and disagreement remains visible. +- Several listeners are plural listening, not automatically an ear swarm. An + ear swarm requires attributable passes and explicit influence edges showing + reciprocal reorientation while permissions and disagreement remain intact. +- Revision creates lineage. It does not mutate an earlier listening into a new + historical fact. +- Forgetting creates a receipt. A deleted record must not silently resurrect + through an index, cache, graph, or later import. + +For host-supplied perception, the host declaration is retained as attributed +input. Oída derives the effective listening context from what was actually +submitted, clamps authority to observe-only, and does not promote model +observations into measurements when no measurement aperture was open. + +## What the Installer Proves + +The install plan shows immutable releases and source revisions. Completed +`state.json` records both those commits and the accountable-listening contract +matrix under `listening-stack/state/v2`. Its profile and exact component list +make the four-project core distinguishable from the optional GERM layer. The +installer writes state only after source synchronization and import verification +succeed. + +When Oída is running, `listening-stack doctor` verifies: + +1. the process identifies as Oída on the configured loopback address; +2. `/gateway` reports Oída 0.9.0 and the expected component contracts; +3. the gateway advertises all required schema endpoints; +4. the host-perception schema requires `oida/host-perception/v0.4`; +5. the listening-event schema requires `oida/listening-event/v0.3`; +6. the listening-context schema requires `akouo/listening-context/v2`; +7. the route-outcome schema requires `oida/route-outcome/v0.1`. + +These checks happen at the live HTTP boundary. Package metadata is useful +evidence, but it is not a substitute for the contract that integrations +actually receive. + +## Progressive Disclosure + +The normal integration path is deliberately small: + +1. install or update the pinned core, adding GERM only when cultivation is part + of the intended apparatus; +2. start Oída; +3. run the doctor; +4. integrate a selected host through Oída; +5. inspect detailed component schemas only when extending the protocol. + +Component documentation remains canonical for field-level semantics. The +installer owns only compatibility, reproducibility, lifecycle, and verification. diff --git a/docs/installation.md b/docs/installation.md index 70048bf..2b5a6c5 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -2,9 +2,9 @@ ## Interactive Install -The normal entry point asks five bounded questions: +The normal entry point asks bounded questions: -1. Install Oída, GERM, or both. +1. Keep the default listening core, add optional GERM, or install GERM alone. 2. Choose a recommended, complete, empty, or custom model set. 3. Choose the Stable Audio 3 provider when GERM weights are selected. 4. Optionally select Oída host integrations. @@ -18,10 +18,10 @@ The default destination is `~/SonicField/ListeningStack`: ```text ListeningStack/ -├── src/ Oída, GERM, and required sibling repositories +├── src/ selected first-party repositories ├── vendor/ official MOSS-Audio and Stable Audio 3 source ├── models/ local weights and Hugging Face cache -├── data/ Oída/GERM data and the shared Akousmata store +├── data/ selected service data and the Akousmata store ├── logs/ managed service logs └── .listening-stack/ local tools, non-secret environment, and completed state ``` @@ -30,7 +30,7 @@ Every one of these paths stays outside the installer repository. ## Model Presets -For `--component oida`: +For the default `--component core`: - `recommended` or `4b`: MOSS-Audio 4B Instruct and Thinking. - `8b`: MOSS-Audio 8B Instruct and Thinking. @@ -44,14 +44,17 @@ For `--component germ`: - `all`: both Small checkpoints and Medium. - `none`: mock provider only. -For `--component full`, use `recommended`, `all`, or `none`. +For `--component full`, use `recommended`, `all`, or `none`. This is the +explicit core-plus-GERM profile. The earlier `--component oida` spelling remains +accepted as a compatibility alias for `core`, but new automation should use the +profile name. Individual keys can be comma-separated: ```bash listening-stack install \ - --component full \ - --models moss-4b-instruct,moss-4b-thinking,stable-small-sfx + --component core \ + --models moss-4b-instruct,moss-4b-thinking ``` Inspect valid keys with `listening-stack models`. @@ -71,13 +74,26 @@ remain under Hugging Face's credential handling and are not written to stack state or environment files. MOSS-Audio checkpoints are downloaded at the immutable revisions tested by -Oída 0.6.5. Stable Audio 3 currently resolves the selected gated model through +Oída 0.9.0. Stable Audio 3 currently resolves the selected gated model through its upstream loader; when the Hugging Face cache exposes the resolved `main` revision, the installer records it in `state.json`. ## Non-Interactive Install -Automation must make the component, model, and gated-terms choices explicit: +Automation gets the core profile when no component is supplied. Declare the +profile anyway when reproducibility matters. A core-only install needs no GERM +provider or gated-model acknowledgement: + +```bash +listening-stack install \ + --component core \ + --models recommended \ + --root /opt/sonicfield/listening-stack \ + --yes +``` + +Automation that selects GERM must make the model and gated-terms choices +explicit: ```bash listening-stack install \ @@ -103,7 +119,7 @@ Pin the executable release used by the curl bootstrap: ```bash curl -fsSL \ https://raw.githubusercontent.com/sonicfieldlabs/listening-stack/main/install.sh \ - | LISTENING_STACK_VERSION=v0.1.2 bash + | LISTENING_STACK_VERSION=v0.3.0 bash ``` Override the executable destination: @@ -121,13 +137,26 @@ clean installation checkout before fetching the immutable revisions in the installer's compatibility set. It records the exact commits and refuses a checkout that resolves to anything else. It never resets a dirty tree. -Listening Stack 0.1.2 pins Oída 0.6.5, GERM 0.2.5, AKOÚŌ 0.7.0, Earworm -0.4.0, and Akousmata 0.4.0. A later installer release may publish a newer -tested set; an existing 0.1.2 executable continues to reproduce this one. +Listening Stack 0.3.0 pins Oída 0.9.0, GERM 0.2.5, AKOÚŌ 0.9.0, Earworm +0.6.0, and Akousmata 0.6.0. It also records the exact accountable-listening +contracts in `listening-stack/state/v2`. The state names the canonical profile, +the exact component set, the four core components, and optional components. A +later installer release may publish a newer tested set; an existing 0.3.0 +executable continues to reproduce this one. Application version numbers remain owned by their repositories. Updating an installer checkout does not rewrite an Oída or GERM version. +Version 0.3 can read version 1 state for lifecycle compatibility. Rerunning the +installer writes version 2 state; it does not infer that an old `oida` selection +included GERM. + +After starting Oída, run `listening-stack doctor`. In addition to source and +model checks, it verifies the live gateway manifest plus host-perception, +listening-event, listening-context, and route-outcome schemas. This detects a +process that is healthy at `/health` but semantically incompatible at the +integration boundary. + ## Removing an Installation Stop managed services first: diff --git a/docs/models.md b/docs/models.md index 18b5815..23d8de2 100644 --- a/docs/models.md +++ b/docs/models.md @@ -1,8 +1,8 @@ # Models, Access, and Hardware -The Listening Stack is model-agnostic at its contracts. This installer begins -with two concrete local model families because they currently provide the most -direct tested paths for Oída and GERM. +The Listening Stack is model-agnostic at its contracts. The default core offers +one concrete local model family for Oída. A second family is available only when +the optional GERM component is selected. ## MOSS-Audio for Oída @@ -26,8 +26,8 @@ serves direct and transcription routes; the Thinking model serves deeper, music, and targeted re-listening routes. Oída consumes a final model response as bounded evidence and does not expose private reasoning traces. -Listening Stack 0.1.2 downloads each MOSS checkpoint by the immutable commit -audited for this release. Oída 0.6.5's embedded loader requires Safetensors and +Listening Stack 0.3.0 downloads each MOSS checkpoint by the immutable commit +audited for this release. Oída 0.9.0's embedded loader requires Safetensors and uses its locked Torch 2.10, TorchAudio 2.10, TorchCodec 0.10, and Transformers 5.14 runtime. The installer also prepares Oída's optional Music ID dependency; identification remains opt-in per listen and may contact the service used by @@ -37,7 +37,7 @@ Oída's current planning guidance is 16 GB minimum and 24 GB suggested for 4B, or 24 GB minimum and 48 GB suggested for 8B. Apple Silicon and CUDA are the practical accelerated routes. CPU execution is possible but can be slow. -## Stable Audio 3 for GERM +## Stable Audio 3 for Optional GERM GERM's cultivation graph is provider-agnostic, and its mock route works without a model. Its first real-model implementation is specifically designed around @@ -95,8 +95,9 @@ is advisory; model identity and source revision remain separate provenance. Disk estimates include all selected checkpoint bytes, a 15% margin for download and extraction behavior, and a fixed reserve for source and Python environments. -RAM guidance uses the largest model expected to be resident. It sums the -largest selected Oída and GERM models only for the concurrent-app estimate. +RAM guidance uses the largest model expected to be resident. Only the explicit +full profile sums the largest selected Oída and GERM models for the +concurrent-app estimate. Use: @@ -106,4 +107,6 @@ listening-stack doctor ``` The doctor reports installed RAM, free disk, provider compatibility, model -presence, repository origins, and local gateway health. +presence, repository origins, local gateway health, and Oída's live accountable- +listening contracts. It verifies structure and compatibility, not the truth or +quality of a model's sonic interpretation. diff --git a/docs/security.md b/docs/security.md index 39393f0..22d0be0 100644 --- a/docs/security.md +++ b/docs/security.md @@ -50,13 +50,21 @@ host integrations succeed, so the state file remains a completed-installation marker rather than an optimistic plan. Its JSON input size and structure are bounded before lifecycle commands use it. +The version 2 state contract also records the selected profile, exact component +set, and semantic compatibility set for accountable listening. When Oída is +running, the doctor reads only fixed gateway/schema +paths on the configured loopback origin, bounds response sizes, rejects +redirects and non-loopback URLs, and compares the live contracts with that set. +It does not submit recordings or listening content during this check. + ## Local Data The install root can contain: - downloaded model weights; - Oída listening events, captures, and memory; -- GERM source material, generated audio, sessions, and lineage; +- GERM source material, generated audio, sessions, and lineage when GERM was + explicitly installed; - service logs and process state; - cloned public source. @@ -68,16 +76,18 @@ The local gateways bind to `127.0.0.1` by default. Exposing them to a LAN, overlay network, reverse proxy, or public host is a separate operator decision and is not automated here. -The generated environment explicitly binds Oída and GERM to loopback, gives -both applications one install-root `AKOUSMATA_PATH`, bounds GERM's accepted -input roots to GERM output, Oída handoff audio, and the shared store, bounds -model roots to install-managed paths, and keeps cloud image analysis off. +The core environment binds Oída to loopback and gives it one install-root +`AKOUSMATA_PATH`. It contains no GERM endpoint. When GERM is explicitly +selected, the generated environment binds it to loopback, shares the same +store, bounds accepted input roots to GERM output, Oída handoff audio, and the +shared store, bounds model roots to install-managed paths, and keeps cloud image +analysis off. Lifecycle commands reject edited state that would make them bind or probe a non-loopback host. ## Process Control -Oída manages its own recorded gateway process. The assistant records GERM's PID +Oída manages its own recorded gateway process. When installed, the assistant records GERM's PID and process-start token and checks both with its command before sending a signal. It refuses to stop a reused PID or a process that does not look like the recorded Uvicorn GERM server. A health endpoint is reused only when its diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 24f9ae4..9a2fd26 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -43,9 +43,26 @@ listening-stack doctor ``` The installer sets explicit local MOSS paths, disables silent Hub lookup, and -downloads the immutable revisions tested with Oída 0.6.5. Rerun model download +downloads the immutable revisions tested with Oída 0.9.0. Rerun model download if a checkpoint is incomplete. +## Oída is healthy but a contract or schema check fails + +The doctor checks more than `/health`. It reads `/gateway` and the four public +accountable-listening schemas from the same loopback process. A failure usually +means the running daemon predates the pinned source checkout or was started by a +different environment. + +```bash +listening-stack stop oida +listening-stack install --component core --models none --yes +listening-stack start oida +listening-stack doctor +``` + +Do not edit the recorded contract matrix to silence the check. The component +contracts and live schemas must agree at the actual integration boundary. + ## A repository is clean but reported as outdated Listening Stack releases use an immutable compatibility set. A checkout can @@ -56,6 +73,9 @@ and never resets local work. ## GERM reports its provider unavailable +First confirm that the completed state uses the `full` or `germ` profile. The +default `core` profile deliberately has no GERM checkout, endpoint, or process. + Check provider and model diagnostics: ```bash diff --git a/pyproject.toml b/pyproject.toml index dba7b36..8e4ef11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "sonicfield-listening-stack" -version = "0.1.2" -description = "Interactive installer and local runtime assistant for Oída and GERM." +version = "0.3.0" +description = "Reproducible installer and runtime verifier for the accountable listening core and optional GERM." readme = "README.md" requires-python = ">=3.9" license = "Apache-2.0" diff --git a/src/listening_stack/__init__.py b/src/listening_stack/__init__.py index 5e1d262..b340980 100644 --- a/src/listening_stack/__init__.py +++ b/src/listening_stack/__init__.py @@ -1,3 +1,3 @@ """Sonic Field Listening Stack installation assistant.""" -__version__ = "0.1.2" +__version__ = "0.3.0" diff --git a/src/listening_stack/catalog.py b/src/listening_stack/catalog.py index c09c386..9d5aec7 100644 --- a/src/listening_stack/catalog.py +++ b/src/listening_stack/catalog.py @@ -59,9 +59,9 @@ def size_gb(self) -> float: key="oida", name="Oída", url="https://github.com/sonicfieldlabs/oida.git", - ref="v0.6.5", - version="0.6.5", - revision="4cd403d48dc3b4e571b05868ed668efa50631074", + ref="v0.9.0", + version="0.9.0", + revision="e4d47881bcc2eb247a76839575f33018d110a78a", ), "germ": Repository( key="germ", @@ -75,29 +75,43 @@ def size_gb(self) -> float: key="akouo", name="AKOÚŌ", url="https://github.com/sonicfieldlabs/akouo.git", - ref="v0.7.0", - version="0.7.0", - revision="1b3ae90ec37f712af4bf8be97f5ace8330d1329e", + ref="v0.9.0", + version="0.9.0", + revision="466c4345ca57dee2636be7a5c3c7ec2615b1bf86", ), "earworm": Repository( key="earworm", name="Earworm", url="https://github.com/sonicfieldlabs/earworm.git", - ref="v0.4.0", - version="0.4.0", - revision="b3a76c1e9b5b6fbd08cf69302f4bfb6a6c29bd37", + ref="v0.6.0", + version="0.6.0", + revision="4aac663ab9a81cdf8d8c2f5c93f4cc84587c1572", ), "akousmata": Repository( key="akousmata", name="Akousmata", url="https://github.com/sonicfieldlabs/akousmata.git", - ref="v0.4.0", - version="0.4.0", - revision="295d6534be437ade9ea0ba3216384cb50839705a", + ref="v0.6.0", + version="0.6.0", + revision="7a0200ccfb98449dd545e8a8c0f7d3a36627f5ab", ), } -OIDA_SOURCE_KEYS: Tuple[str, ...] = ("earworm", "akouo", "akousmata", "oida") +# One semantic owner per contract. The installer records this matrix in local +# state and the doctor verifies it at Oída's live gateway/schema boundary. +ACCOUNTABLE_LISTENING_CONTRACTS: Mapping[str, str] = { + "gateway": "oida/gateway/v0.5", + "host_perception": "oida/host-perception/v0.4", + "listening_event": "oida/listening-event/v0.3", + "route_outcome": "oida/route-outcome/v0.1", + "listening_context": "akouo/listening-context/v2", + "akouo": "akouo/v0.9", + "earworm": "earworm/v0.6", + "auditum": "earworm/auditum/v2", + "akousmata": "akousmata/v0.6", +} + +CORE_SOURCE_KEYS: Tuple[str, ...] = ("earworm", "akouo", "akousmata", "oida") GERM_SOURCE_KEYS: Tuple[str, ...] = ("germ",) MOSS_AUDIO_REPOSITORY = Repository( @@ -229,6 +243,18 @@ def size_gb(self) -> float: } MODEL_PRESETS: Mapping[str, Mapping[str, Tuple[str, ...]]] = { + "core": { + "recommended": ("moss-4b-instruct", "moss-4b-thinking"), + "4b": ("moss-4b-instruct", "moss-4b-thinking"), + "8b": ("moss-8b-instruct", "moss-8b-thinking"), + "all": ( + "moss-4b-instruct", + "moss-4b-thinking", + "moss-8b-instruct", + "moss-8b-thinking", + ), + "none": (), + }, "oida": { "recommended": ("moss-4b-instruct", "moss-4b-thinking"), "4b": ("moss-4b-instruct", "moss-4b-thinking"), @@ -260,17 +286,41 @@ def size_gb(self) -> float: }, } -RUNTIME_RESERVE_GB: Mapping[str, int] = {"oida": 7, "germ": 9, "full": 14} +RUNTIME_RESERVE_GB: Mapping[str, int] = { + "core": 7, + "oida": 7, + "germ": 9, + "full": 14, +} + + +def normalize_profile(profile: str) -> str: + """Return the canonical install profile, retaining the old Oída alias.""" + if profile == "oida": + return "core" + if profile in {"core", "germ", "full"}: + return profile + raise ValueError("profile must be core, germ, or full") + + +def profile_includes(profile: str, application: str) -> bool: + canonical = normalize_profile(profile) + if application == "oida": + return canonical in {"core", "full"} + if application == "germ": + return canonical in {"germ", "full"} + raise ValueError("application must be oida or germ") def source_keys(component: str) -> Tuple[str, ...]: - if component == "oida": - return OIDA_SOURCE_KEYS - if component == "germ": + profile = normalize_profile(component) + if profile == "core": + return CORE_SOURCE_KEYS + if profile == "germ": return GERM_SOURCE_KEYS - if component == "full": - return OIDA_SOURCE_KEYS + GERM_SOURCE_KEYS - raise ValueError("component must be oida, germ, or full") + if profile == "full": + return CORE_SOURCE_KEYS + GERM_SOURCE_KEYS + raise AssertionError("unreachable install profile") def selected_models( @@ -298,7 +348,7 @@ def model_disk_gb(models: Iterable[Model]) -> float: def planned_disk_gb(component: str, models: Sequence[Model]) -> float: # Keep 15% download/extraction headroom in addition to source and environments. - return RUNTIME_RESERVE_GB[component] + model_disk_gb(models) * 1.15 + return RUNTIME_RESERVE_GB[normalize_profile(component)] + model_disk_gb(models) * 1.15 def memory_guidance(models: Sequence[Model]) -> Tuple[int, int]: diff --git a/src/listening_stack/cli.py b/src/listening_stack/cli.py index 8592774..cf8fcd1 100644 --- a/src/listening_stack/cli.py +++ b/src/listening_stack/cli.py @@ -11,13 +11,16 @@ from . import __version__ from .catalog import ( + ACCOUNTABLE_LISTENING_CONTRACTS, MODELS, MODEL_PRESETS, REPOSITORIES, Model, format_gb, memory_guidance, + normalize_profile, planned_disk_gb, + profile_includes, preset_models, refresh_model_sizes, selected_models, @@ -37,13 +40,17 @@ def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="listening-stack", - description="Install and operate Oída, GERM, and their local model runtimes.", + description="Install and operate the accountable listening core, with GERM as an option.", ) parser.add_argument("--version", action="version", version=__version__) subparsers = parser.add_subparsers(dest="command") install = subparsers.add_parser("install", help="Run the installation assistant.") - install.add_argument("--component", choices=["full", "oida", "germ"]) + install.add_argument( + "--component", + choices=["core", "full", "germ", "oida"], + help="Install profile. `oida` is retained as an alias for `core`.", + ) model_selection = install.add_mutually_exclusive_group() model_selection.add_argument( "--models", @@ -160,19 +167,21 @@ def _install(args: argparse.Namespace) -> None: _banner() interactive = not args.yes and sys.stdin.isatty() if args.component: - component = args.component + component = normalize_profile(args.component) + if args.component == "oida": + print("Using the `core` profile (`oida` is a compatibility alias).") elif interactive: component = _choose_one( "What would you like to install?", [ - ("full", "Oída + GERM (recommended)"), - ("oida", "Oída only"), + ("core", "Listening core: AKOÚŌ + Earworm + Akousmata + Oída (recommended)"), + ("full", "Listening core + optional GERM"), ("germ", "GERM only"), ], default=1, ) else: - component = "full" + component = "core" if args.no_models: model_keys: List[str] = [] @@ -205,7 +214,7 @@ def _install(args: argparse.Namespace) -> None: integrations = _flatten(args.integration) if "all" in integrations: integrations = list(ALLOWED_INTEGRATIONS) - if component in {"oida", "full"} and not integrations and interactive: + if profile_includes(component, "oida") and not integrations and interactive: integrations = _choose_many( "Optional Oída agent integrations (press Enter for none)", [ @@ -286,9 +295,9 @@ def _install(args: argparse.Namespace) -> None: started = start_runtime(root) print(json.dumps(started, indent=2, ensure_ascii=False)) print("\nListening Stack installation complete.") - if component in {"oida", "full"}: + if profile_includes(component, "oida"): print(" Oída: http://127.0.0.1:8765") - if component in {"germ", "full"}: + if profile_includes(component, "germ"): print(" GERM: http://127.0.0.1:5178/dashboard") print("\nNext steps:") print(" listening-stack doctor --root %s" % _shell_path(root)) @@ -302,7 +311,7 @@ def _install(args: argparse.Namespace) -> None: def _interactive_models(component: str) -> List[str]: - if component == "oida": + if component == "core": selection = _choose_one( "Which Oída models should be downloaded?", [ @@ -345,7 +354,7 @@ def _interactive_models(component: str) -> List[str]: allowed = [ model for model in MODELS.values() - if component == "full" or model.application == component + if profile_includes(component, model.application) ] return _choose_many( "Choose checkpoints", @@ -378,7 +387,11 @@ def _print_plan( print("\nInstall plan") print( " Applications: %s" - % {"full": "Oída + GERM", "oida": "Oída", "germ": "GERM"}[component] + % { + "core": "Listening core (AKOÚŌ + Earworm + Akousmata + Oída)", + "full": "Listening core + optional GERM", + "germ": "GERM only", + }[component] ) print(" Directory: %s" % root) print(" Sources:") @@ -386,6 +399,17 @@ def _print_plan( repository = REPOSITORIES[key] release = "v%s" % repository.version if repository.version else repository.ref print(" - %s %s (%s)" % (repository.name, release, repository.revision[:12])) + if profile_includes(component, "oida"): + print(" Listening contracts:") + for key in ( + "gateway", + "host_perception", + "listening_event", + "route_outcome", + "listening_context", + "auditum", + ): + print(" - %s" % ACCOUNTABLE_LISTENING_CONTRACTS[key]) if models: print(" Models:") for model in models: @@ -514,7 +538,8 @@ def _doctor(args: argparse.Namespace) -> None: def _integrate(args: argparse.Namespace) -> None: root = args.root.expanduser().resolve() state = load_state(root) - if state.get("component") == "germ": + profile = normalize_profile(str(state.get("profile") or state.get("component"))) + if not profile_includes(profile, "oida"): raise ValueError("This installation does not include Oída") targets = ( list(ALLOWED_INTEGRATIONS) @@ -607,7 +632,8 @@ def _emit(value: object, as_json: bool) -> None: def _banner() -> None: print("The Listening Stack") print( - "Oída hears. GERM cultivates. Akousmata remembers. AKOÚŌ structures. Earworm routes." + "Oída listens. AKOÚŌ structures claims. Earworm addresses auditums. " + "Akousmata renders and audits memory. GERM is an optional cultivation layer." ) print( "\nThis assistant installs local public-alpha software. It will show every model, license boundary, and host change before proceeding." diff --git a/src/listening_stack/doctor.py b/src/listening_stack/doctor.py index 8970b56..a42eb81 100644 --- a/src/listening_stack/doctor.py +++ b/src/listening_stack/doctor.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import asdict, dataclass +import json import os from pathlib import Path import platform @@ -11,9 +12,21 @@ import subprocess import sys from typing import Dict, List, Mapping +from urllib.error import HTTPError, URLError +from urllib.parse import urlsplit +from urllib.request import Request, urlopen from . import __version__ -from .catalog import ALL_REPOSITORIES, MODELS, memory_guidance, source_keys +from .catalog import ( + ACCOUNTABLE_LISTENING_CONTRACTS, + ALL_REPOSITORIES, + MODELS, + REPOSITORIES, + memory_guidance, + normalize_profile, + profile_includes, + source_keys, +) from .installer import _normalise_git_url, environment_path, load_state, state_path from .runtime import status as runtime_status from .system import executable, is_apple_silicon, total_ram_gb @@ -27,6 +40,15 @@ class Check: remedy: str = "" +MAX_GATEWAY_CONTRACT_BYTES = 2 * 1024 * 1024 +OIDA_SCHEMA_PATHS = { + "host_perception": "/gateway/schema/host-perception", + "listening_event": "/gateway/schema/listening-event", + "listening_context": "/gateway/schema/listening-context", + "route_outcome": "/gateway/schema/route-outcome", +} + + def run_doctor(root: Path) -> Dict[str, object]: checks: List[Check] = [] for command in ("git", "curl", "uv", "ffmpeg"): @@ -87,6 +109,33 @@ def run_doctor(root: Path) -> Dict[str, object]: ) ) + component = normalize_profile(str(state.get("profile") or state.get("component"))) + checks.append( + Check( + "state:profile", + "pass", + "%s (%s)" % (component, ", ".join(source_keys(component))), + ) + ) + if profile_includes(component, "oida"): + recorded_contracts = state.get("contracts") + contract_set_matches = ( + isinstance(recorded_contracts, dict) + and recorded_contracts == dict(ACCOUNTABLE_LISTENING_CONTRACTS) + ) + checks.append( + Check( + "state:contracts", + "pass" if contract_set_matches else "warn", + "accountable-listening compatibility set recorded" + if contract_set_matches + else "missing or stale accountable-listening compatibility set", + "Rerun `listening-stack install` with this installer to record the current contracts." + if not contract_set_matches + else "", + ) + ) + raw_models = state.get("models", []) model_keys = ( [str(key) for key in raw_models] if isinstance(raw_models, list) else [] @@ -116,7 +165,6 @@ def run_doctor(root: Path) -> Dict[str, object]: ) ) - component = str(state.get("component", "")) commits = state.get("commits", {}) if isinstance(state.get("commits"), dict) else {} for key in source_keys(component): path = root / "src" / key @@ -202,8 +250,27 @@ def run_doctor(root: Path) -> Dict[str, object]: else "", ) ) - if component in {"germ", "full"} and isinstance(environment, dict): + if profile_includes(component, "germ") and isinstance(environment, dict): checks.append(_check_germ_boundary(root, environment)) + elif isinstance(environment, dict): + unexpected_germ = sorted( + key + for key in environment + if str(key).startswith("GERM_") or key == "OIDA_GERM_URL" + ) + checks.append( + Check( + "security:core-boundary", + "pass" if not unexpected_germ else "warn", + "no GERM endpoint or provider configuration" + if not unexpected_germ + else "core state contains optional GERM settings: %s" + % ", ".join(unexpected_germ), + "Rerun the core installer to remove optional-component settings." + if unexpected_germ + else "", + ) + ) try: running = runtime_status(root) @@ -227,9 +294,141 @@ def run_doctor(root: Path) -> Dict[str, object]: else "", ) ) + if application == "oida" and is_running: + checks.extend( + _check_oida_accountability_contracts(str(info.get("url") or "")) + ) return _result(checks, running) +def _check_oida_accountability_contracts(base_url: str) -> List[Check]: + """Verify the installed listening stack at Oída's live boundary.""" + try: + manifest = _fetch_local_json(base_url, "/gateway") + except (HTTPError, URLError, TimeoutError, ValueError, OSError) as exc: + return [ + Check( + "contract:oida-gateway", + "fail", + "could not read the live gateway contract: %s" % exc, + "Restart Oída from this installation and rerun the doctor.", + ) + ] + + components = manifest.get("components") + component_contracts = components if isinstance(components, dict) else {} + actual = { + "gateway": manifest.get("contract"), + "akouo": _nested_value(component_contracts, "akouo", "contract"), + "earworm": _nested_value(component_contracts, "earworm", "contract"), + "akousmata": _nested_value(component_contracts, "akousmata", "contract"), + } + expected = { + key: ACCOUNTABLE_LISTENING_CONTRACTS[key] + for key in ("gateway", "akouo", "earworm", "akousmata") + } + mismatches = [ + "%s=%s (expected %s)" % (key, actual[key] or "missing", expected[key]) + for key in expected + if actual[key] != expected[key] + ] + expected_version = REPOSITORIES["oida"].version + if manifest.get("version") != expected_version: + mismatches.append( + "oida=%s (expected %s)" + % (manifest.get("version") or "missing", expected_version) + ) + + advertised = manifest.get("schemas") + if not isinstance(advertised, dict): + mismatches.append("schemas=missing") + else: + for key, path in OIDA_SCHEMA_PATHS.items(): + if advertised.get(key) != path: + mismatches.append( + "schema.%s=%s (expected %s)" + % (key, advertised.get(key) or "missing", path) + ) + + checks = [ + Check( + "contract:oida-gateway", + "pass" if not mismatches else "fail", + "Oída %s exposes %s with AKOÚŌ, Earworm, and Akousmata ownership intact" + % (expected_version, expected["gateway"]) + if not mismatches + else "; ".join(mismatches), + "Rerun the installer and restart Oída to restore the pinned compatibility set." + if mismatches + else "", + ) + ] + for key, path in OIDA_SCHEMA_PATHS.items(): + expected_contract = ACCOUNTABLE_LISTENING_CONTRACTS[key] + try: + schema = _fetch_local_json(base_url, path) + actual_contract = _nested_value(schema, "properties", "contract", "const") + matches = actual_contract == expected_contract + checks.append( + Check( + "schema:%s" % key.replace("_", "-"), + "pass" if matches else "fail", + "%s at %s" % (actual_contract or "contract missing", path), + "Rerun the installer and restart Oída so the live schema matches %s." + % expected_contract + if not matches + else "", + ) + ) + except (HTTPError, URLError, TimeoutError, ValueError, OSError) as exc: + checks.append( + Check( + "schema:%s" % key.replace("_", "-"), + "fail", + "could not read %s: %s" % (path, exc), + "Rerun the installer and restart Oída so this schema is available.", + ) + ) + return checks + + +def _fetch_local_json(base_url: str, path: str) -> Dict[str, object]: + parsed = urlsplit(base_url) + if ( + parsed.scheme != "http" + or parsed.hostname not in {"127.0.0.1", "localhost", "::1"} + or parsed.username + or parsed.password + ): + raise ValueError("gateway URL must be an unauthenticated loopback HTTP URL") + if not path.startswith("/") or path.startswith("//"): + raise ValueError("gateway path must be absolute") + url = base_url.rstrip("/") + path + request = Request( + url, + headers={"User-Agent": "sonicfield-listening-stack/%s" % __version__}, + ) + with urlopen(request, timeout=2.0) as response: + if response.geturl() != url: + raise ValueError("refusing redirected gateway contract response") + raw = response.read(MAX_GATEWAY_CONTRACT_BYTES + 1) + if len(raw) > MAX_GATEWAY_CONTRACT_BYTES: + raise ValueError("gateway contract response is too large") + value = json.loads(raw) + if not isinstance(value, dict): + raise ValueError("gateway contract response is not an object") + return value + + +def _nested_value(value: object, *keys: str) -> object: + current = value + for key in keys: + if not isinstance(current, dict): + return None + current = current.get(key) + return current + + def _check_private_file(name: str, path: Path) -> Check: if path.is_symlink(): return Check( @@ -256,9 +455,10 @@ def _check_private_file(name: str, path: Path) -> Check: def _check_germ_boundary(root: Path, environment: Mapping[str, object]) -> Check: expected_inputs = { str(root / "data" / "germ"), - str(root / "data" / "audio"), str(root / "data" / "akousmata"), } + if environment.get("GERM_OIDA_URL"): + expected_inputs.add(str(root / "data" / "audio")) expected_models = { str(root / "vendor" / "stable-audio-3"), str(root / "models"), diff --git a/src/listening_stack/installer.py b/src/listening_stack/installer.py index 59a50b5..a0b9108 100644 --- a/src/listening_stack/installer.py +++ b/src/listening_stack/installer.py @@ -1,4 +1,4 @@ -"""Installation orchestration for Oída, GERM, and their selected models.""" +"""Installation orchestration for the listening core and optional GERM.""" from __future__ import annotations @@ -16,6 +16,7 @@ from . import __version__ from .catalog import ( + ACCOUNTABLE_LISTENING_CONTRACTS, ALL_REPOSITORIES, MODELS, MOSS_AUDIO_REPOSITORY, @@ -23,6 +24,8 @@ STABLE_AUDIO_REPOSITORY, Model, Repository, + normalize_profile, + profile_includes, source_keys, ) from .system import Runner, executable, is_apple_silicon @@ -32,6 +35,7 @@ STATE_FILENAME = "state.json" ENV_FILENAME = "stack.env" MAX_STATE_BYTES = 2 * 1024 * 1024 +STATE_CONTRACT = "listening-stack/state/v2" UV_VERSION = "0.11.29" UV_INSTALLER_SHA256 = "504a79fd2ed0dcd47e7f04f0792cfd0871f62e24a7fe40fa8ae0f563a369f2bd" HF_CLI_VERSION = "1.23.0" @@ -70,10 +74,43 @@ def load_state(root: Path) -> Dict[str, object]: raise ValueError("Listening Stack state is unexpectedly large at %s" % path) with path.open("r", encoding="utf-8") as handle: value = json.load(handle) - if not isinstance(value, dict) or value.get("schema_version") != 1: + if not isinstance(value, dict) or value.get("schema_version") not in {1, 2}: raise ValueError("Unsupported or invalid Listening Stack state at %s" % path) - if value.get("component") not in {"oida", "germ", "full"}: - raise ValueError("Listening Stack state has an invalid component at %s" % path) + profile = str(value.get("profile") or value.get("component") or "") + try: + canonical_profile = normalize_profile(profile) + except ValueError as exc: + raise ValueError( + "Listening Stack state has an invalid profile at %s" % path + ) from exc + if value.get("schema_version") == 2: + if ( + value.get("contract") != STATE_CONTRACT + or value.get("profile") != canonical_profile + or value.get("component") != canonical_profile + ): + raise ValueError("Listening Stack state has an invalid contract at %s" % path) + components = value.get("components") + if not isinstance(components, list) or components != list( + source_keys(canonical_profile) + ): + raise ValueError( + "Listening Stack state has an invalid component set at %s" % path + ) + expected_optional = ["germ"] if canonical_profile == "full" else [] + if value.get("core_components") != list(source_keys("core")) or value.get( + "optional_components" + ) != expected_optional: + raise ValueError( + "Listening Stack state has invalid profile metadata at %s" % path + ) + if not isinstance(value.get("contracts"), dict) or not isinstance( + value.get("repositories"), dict + ): + raise ValueError( + "Listening Stack state has no valid compatibility metadata at %s" + % path + ) if not isinstance(value.get("environment"), dict): raise ValueError("Listening Stack state has no valid environment at %s" % path) if not isinstance(value.get("commits"), dict): @@ -84,6 +121,7 @@ def load_state(root: Path) -> Dict[str, object]: class Installer: def __init__(self, selection: Selection, runner: Runner) -> None: self.selection = selection + self.profile = normalize_profile(selection.component) self.runner = runner self.root = selection.root.expanduser().resolve() self.src_root = self.root / "src" @@ -149,8 +187,7 @@ def install(self) -> Dict[str, object]: def _validate_selection(self) -> None: self._validate_root() - if self.selection.component not in {"oida", "germ", "full"}: - raise ValueError("component must be oida, germ, or full") + normalize_profile(self.selection.component) if self.selection.provider not in {"auto", "mlx", "python", "mock"}: raise ValueError("provider must be auto, mlx, python, or mock") unknown = [key for key in self.selection.model_keys if key not in MODELS] @@ -158,11 +195,11 @@ def _validate_selection(self) -> None: raise ValueError("Unknown models: " + ", ".join(unknown)) if len(set(self.selection.model_keys)) != len(self.selection.model_keys): raise ValueError("Model selections must not contain duplicates") - allowed_apps = ( - {"oida", "germ"} - if self.selection.component == "full" - else {self.selection.component} - ) + allowed_apps = { + application + for application in ("oida", "germ") + if profile_includes(self.profile, application) + } wrong = [ model.key for model in self.models if model.application not in allowed_apps ] @@ -179,7 +216,7 @@ def _validate_selection(self) -> None: raise ValueError("Unknown integrations: " + ", ".join(invalid_integrations)) if len(set(self.selection.integrations)) != len(self.selection.integrations): raise ValueError("Integration selections must not contain duplicates") - if self.selection.integrations and self.selection.component == "germ": + if self.selection.integrations and not profile_includes(self.profile, "oida"): raise ValueError( "Agent integrations are supplied by Oída; install Oída or the full stack first" ) @@ -236,23 +273,24 @@ def _validate_root(self) -> None: ) def _make_directories(self) -> None: - paths = ( + paths = [ self.src_root, self.vendor_root, self.models_root, self.hf_home, self.models_root / "moss-audio", self.root / "data", - self.root / "data" / "oida", - self.root / "data" / "audio", - self.root / "data" / "germ", self.root / "data" / "akousmata", self.root / "logs", self.root / "run", self.root / STATE_DIRECTORY, self.root / STATE_DIRECTORY / "bin", self.root / STATE_DIRECTORY / "tools", - ) + ] + if profile_includes(self.profile, "oida"): + paths.extend((self.root / "data" / "oida", self.root / "data" / "audio")) + if profile_includes(self.profile, "germ"): + paths.append(self.root / "data" / "germ") for path in paths: if path.is_symlink(): raise RuntimeError( @@ -360,7 +398,7 @@ def _ensure_ffmpeg(self) -> None: self.runner.note(" Install ffmpeg before using model-backed audio decoding.") def _source_keys(self) -> Tuple[str, ...]: - return source_keys(self.selection.component) + return source_keys(self.profile) def _install_sources(self) -> None: for key in self._source_keys(): @@ -441,7 +479,7 @@ def _install_repository(self, spec: Repository, destination: Path) -> str: def _sync_applications(self) -> None: model_apps = {model.application for model in self.models} - if self.selection.component in {"oida", "full"}: + if profile_includes(self.profile, "oida"): command = [ self.uv, "sync", @@ -454,7 +492,7 @@ def _sync_applications(self) -> None: if "oida" in model_apps: command.extend(["--extra", "moss"]) self.runner.run(command, cwd=self.src_root / "oida") - if self.selection.component in {"germ", "full"}: + if profile_includes(self.profile, "germ"): command = [self.uv, "sync", "--locked", "--python", "3.12"] provider = self._resolved_provider() if "germ" in model_apps and provider == "python": @@ -592,28 +630,40 @@ def _environment(self) -> Dict[str, str]: environment: Dict[str, str] = { "HF_HOME": str(self.hf_home), "AKOUSMATA_PATH": str(akousmata_path), - "OIDA_DATA_DIR": str(self.root / "data" / "oida"), - "OIDA_AUDIO_DIR": str(oida_audio), - "OIDA_HOST": "127.0.0.1", - "OIDA_PORT": "8765", - "OIDA_SERVER_URL": "http://127.0.0.1:8765", - "OIDA_GERM_URL": "http://127.0.0.1:5178", - "OIDA_ALLOW_HF_HUB": "0", - "GERM_HOST": "127.0.0.1", - "GERM_PORT": "5178", - "GERM_ALLOWED_HOSTS": "localhost,127.0.0.1", - "GERM_OUTPUT_DIR": str(germ_output), - "GERM_ALLOWED_INPUT_ROOTS": ",".join( - (str(germ_output), str(oida_audio), str(akousmata_path)) - ), - "GERM_ALLOWED_MODEL_ROOTS": ",".join( - (str(stable_repo), str(self.models_root), str(germ_output)) - ), - "GERM_ENABLE_CLOUD_VISION": "0", - "GERM_OIDA_URL": "http://127.0.0.1:8765", - "GERM_OFFICIAL_REPO_DIR": str(stable_repo), - "GERM_MLX_REPO_DIR": str(stable_repo), } + if profile_includes(self.profile, "oida"): + environment.update( + { + "OIDA_DATA_DIR": str(self.root / "data" / "oida"), + "OIDA_AUDIO_DIR": str(oida_audio), + "OIDA_HOST": "127.0.0.1", + "OIDA_PORT": "8765", + "OIDA_SERVER_URL": "http://127.0.0.1:8765", + "OIDA_ALLOW_HF_HUB": "0", + } + ) + if profile_includes(self.profile, "germ"): + germ_input_roots = [str(germ_output), str(akousmata_path)] + if self.profile == "full": + germ_input_roots.append(str(oida_audio)) + environment.update( + { + "GERM_HOST": "127.0.0.1", + "GERM_PORT": "5178", + "GERM_ALLOWED_HOSTS": "localhost,127.0.0.1", + "GERM_OUTPUT_DIR": str(germ_output), + "GERM_ALLOWED_INPUT_ROOTS": ",".join(germ_input_roots), + "GERM_ALLOWED_MODEL_ROOTS": ",".join( + (str(stable_repo), str(self.models_root), str(germ_output)) + ), + "GERM_ENABLE_CLOUD_VISION": "0", + "GERM_OFFICIAL_REPO_DIR": str(stable_repo), + "GERM_MLX_REPO_DIR": str(stable_repo), + } + ) + if self.profile == "full": + environment["OIDA_GERM_URL"] = "http://127.0.0.1:5178" + environment["GERM_OIDA_URL"] = "http://127.0.0.1:8765" moss = [model for model in self.models if model.application == "oida"] if moss: instruct = _preferred_moss(moss, "instruct") @@ -629,18 +679,19 @@ def _environment(self) -> Dict[str, str]: "OIDA_MOSS_RESIDENT": "single", } ) - else: + elif profile_includes(self.profile, "oida"): environment.update( {"OIDA_ENGINE_PROFILE": "stub", "OIDA_REQUIRE_MODEL": "0"} ) - stable = [model for model in self.models if model.application == "germ"] - provider = self._resolved_provider() - environment["GERM_ACTIVE_PROVIDER"] = { - "mlx": "stable_audio_mlx", - "python": "stable_audio_python", - "mock": "mock", - }.get(provider, "mock") - environment["GERM_DEFAULT_MODEL"] = _preferred_stable_model(stable) + if profile_includes(self.profile, "germ"): + stable = [model for model in self.models if model.application == "germ"] + provider = self._resolved_provider() + environment["GERM_ACTIVE_PROVIDER"] = { + "mlx": "stable_audio_mlx", + "python": "stable_audio_python", + "mock": "mock", + }.get(provider, "mock") + environment["GERM_DEFAULT_MODEL"] = _preferred_stable_model(stable) return environment def _write_environment(self, environment: Mapping[str, str]) -> None: @@ -667,16 +718,22 @@ def _write_state(self, environment: Mapping[str, str]) -> Dict[str, object]: if key in ALL_REPOSITORIES } state: Dict[str, object] = { - "schema_version": 1, + "schema_version": 2, + "contract": STATE_CONTRACT, "installer_version": __version__, "installed_at": datetime.now(timezone.utc).isoformat(), "root": str(self.root), - "component": self.selection.component, + "profile": self.profile, + "component": self.profile, + "components": list(source_keys(self.profile)), + "core_components": list(source_keys("core")), + "optional_components": ["germ"] if self.profile == "full" else [], "models": list(self.selection.model_keys), "model_revisions": dict(sorted(self.model_revisions.items())), "provider": self._resolved_provider(), "integrations": list(self.selection.integrations), "commits": dict(self.commits), + "contracts": dict(ACCOUNTABLE_LISTENING_CONTRACTS), "repositories": repositories, "environment": dict(environment), } @@ -698,7 +755,7 @@ def _install_integrations(self, environment: Mapping[str, str]) -> None: ) def _verify_installation(self, environment: Mapping[str, str]) -> None: - if self.selection.component in {"oida", "full"}: + if profile_includes(self.profile, "oida"): self.runner.run( [ self.uv, @@ -710,7 +767,7 @@ def _verify_installation(self, environment: Mapping[str, str]) -> None: cwd=self.src_root / "oida", env=environment, ) - if self.selection.component in {"germ", "full"}: + if profile_includes(self.profile, "germ"): germ_models = any(model.application == "germ" for model in self.models) provider = self._resolved_provider() import_check = "from server.main import app" diff --git a/src/listening_stack/runtime.py b/src/listening_stack/runtime.py index 178b5c5..2a028f5 100644 --- a/src/listening_stack/runtime.py +++ b/src/listening_stack/runtime.py @@ -13,6 +13,7 @@ from urllib.request import Request, urlopen from . import __version__ +from .catalog import normalize_profile, profile_includes from .installer import STATE_DIRECTORY, load_state from .system import Runner, executable @@ -29,12 +30,12 @@ def runtime_path(root: Path) -> Path: def status(root: Path, target: str = "all") -> Dict[str, object]: _validate_target(target) state = load_state(root) - component = str(state["component"]) + component = normalize_profile(str(state.get("profile") or state["component"])) environment = _environment(state) result: Dict[str, object] = {"root": str(root), "component": component} - if target in {"all", "oida"} and component in {"oida", "full"}: + if target in {"all", "oida"} and profile_includes(component, "oida"): result["oida"] = _identified_status(_health_url(environment, "OIDA"), "oida") - if target in {"all", "germ"} and component in {"germ", "full"}: + if target in {"all", "germ"} and profile_includes(component, "germ"): result["germ"] = _identified_status(_health_url(environment, "GERM"), "germ") return result @@ -45,13 +46,13 @@ def start( _validate_target(target) runner = runner or Runner() state = load_state(root) - component = str(state["component"]) + component = normalize_profile(str(state.get("profile") or state["component"])) environment = _environment(state) uv = executable("uv") or "uv" oida_health = _health_url(environment, "OIDA") germ_health = _health_url(environment, "GERM") started: Dict[str, object] = {} - if target in {"all", "oida"} and component in {"oida", "full"}: + if target in {"all", "oida"} and profile_includes(component, "oida"): existing = _http_status(oida_health) if existing.get("running"): _require_identity(existing, "oida") @@ -71,7 +72,7 @@ def start( started["oida"] = _wait_for( oida_health, timeout=60, expected_identity="oida" ) - if target in {"all", "germ"} and component in {"germ", "full"}: + if target in {"all", "germ"} and profile_includes(component, "germ"): existing = _http_status(germ_health) if existing.get("running"): _require_identity(existing, "germ") @@ -137,12 +138,12 @@ def stop( _validate_target(target) runner = runner or Runner() state = load_state(root) - component = str(state["component"]) + component = normalize_profile(str(state.get("profile") or state["component"])) environment = _environment(state) uv = executable("uv") or "uv" oida_health = _health_url(environment, "OIDA") stopped: Dict[str, object] = {} - if target in {"all", "oida"} and component in {"oida", "full"}: + if target in {"all", "oida"} and profile_includes(component, "oida"): runner.run( [uv, "run", "oida", "stop", "--json"], cwd=root / "src" / "oida", @@ -150,7 +151,7 @@ def stop( check=False, ) stopped["oida"] = not bool(_http_status(oida_health).get("running")) - if target in {"all", "germ"} and component in {"germ", "full"}: + if target in {"all", "germ"} and profile_includes(component, "germ"): runtime = _read_runtime(root) pid = int(runtime.get("germ_pid", 0) or 0) if not pid: diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index c58c567..f8a04df 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -58,7 +58,7 @@ def fixture( "BOOTSTRAP_FIXTURES": str(fixtures), "HOME": str(home), "LISTENING_STACK_BIN_DIR": str(install_bin), - "LISTENING_STACK_VERSION": "v0.1.2", + "LISTENING_STACK_VERSION": "v0.3.0", "PATH": str(fake_bin) + os.pathsep + environment.get("PATH", ""), "TMPDIR": str(temp_dir), } @@ -119,7 +119,7 @@ def test_additional_checksum_line_is_rejected(self): def test_invalid_release_tag_is_rejected_before_download(self): with tempfile.TemporaryDirectory() as temporary: install_bin, environment = self.fixture(temporary) - environment["LISTENING_STACK_VERSION"] = "v0.1.2/../../unexpected" + environment["LISTENING_STACK_VERSION"] = "v0.3.0/../../unexpected" result = subprocess.run( ["bash", str(ROOT / "install.sh"), "models", "--json"], env=environment, diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 6b47833..201e887 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -7,6 +7,7 @@ sys.path.insert(0, str(ROOT / "src")) from listening_stack.catalog import ( # noqa: E402 + ACCOUNTABLE_LISTENING_CONTRACTS, MODELS, REPOSITORIES, memory_guidance, @@ -19,6 +20,11 @@ class CatalogTests(unittest.TestCase): + def test_recommended_core_set_excludes_germ(self): + models = selected_models(preset_models("core", "recommended")) + self.assertEqual({model.application for model in models}, {"oida"}) + self.assertNotIn("germ", source_keys("core")) + def test_recommended_full_set_covers_both_apps(self): models = selected_models(preset_models("full", "recommended")) self.assertEqual({model.application for model in models}, {"oida", "germ"}) @@ -41,15 +47,35 @@ def test_unknown_model_is_rejected(self): selected_models(["not-a-model"]) def test_release_compatibility_set_is_immutable(self): - self.assertEqual(REPOSITORIES["oida"].version, "0.6.5") + self.assertEqual(REPOSITORIES["oida"].version, "0.9.0") self.assertEqual(REPOSITORIES["germ"].version, "0.2.5") + self.assertEqual(REPOSITORIES["akouo"].version, "0.9.0") + self.assertEqual(REPOSITORIES["earworm"].version, "0.6.0") + self.assertEqual(REPOSITORIES["akousmata"].version, "0.6.0") for repository in REPOSITORIES.values(): self.assertEqual(len(repository.revision), 40) int(repository.revision, 16) + def test_accountable_listening_contracts_have_one_recorded_owner(self): + self.assertEqual( + ACCOUNTABLE_LISTENING_CONTRACTS, + { + "gateway": "oida/gateway/v0.5", + "host_perception": "oida/host-perception/v0.4", + "listening_event": "oida/listening-event/v0.3", + "route_outcome": "oida/route-outcome/v0.1", + "listening_context": "akouo/listening-context/v2", + "akouo": "akouo/v0.9", + "earworm": "earworm/v0.6", + "auditum": "earworm/auditum/v2", + "akousmata": "akousmata/v0.6", + }, + ) + def test_source_keys_are_component_specific(self): self.assertEqual(source_keys("germ"), ("germ",)) - self.assertIn("oida", source_keys("oida")) + self.assertEqual(source_keys("oida"), source_keys("core")) + self.assertIn("oida", source_keys("core")) self.assertIn("germ", source_keys("full")) with self.assertRaises(ValueError): source_keys("unknown") diff --git a/tests/test_cli.py b/tests/test_cli.py index 8b8fefa..e1eb696 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -38,8 +38,29 @@ def test_noninteractive_dry_run(self): ] ) self.assertIn("Dry run complete", output.getvalue()) - self.assertIn("Oída v0.6.5", output.getvalue()) + self.assertIn("Oída v0.9.0", output.getvalue()) self.assertIn("GERM v0.2.5", output.getvalue()) + self.assertIn("oida/gateway/v0.5", output.getvalue()) + self.assertIn("earworm/auditum/v2", output.getvalue()) + + def test_noninteractive_default_is_core_without_germ(self): + with tempfile.TemporaryDirectory() as temporary: + output = StringIO() + with redirect_stdout(output): + main( + [ + "install", + "--no-models", + "--root", + str(Path(temporary) / "stack"), + "--yes", + "--dry-run", + ] + ) + plan = output.getvalue() + self.assertIn("Listening core", plan) + self.assertIn("Oída v0.9.0", plan) + self.assertNotIn("GERM v0.2.5", plan) def test_models_and_no_models_are_mutually_exclusive(self): with self.assertRaises(SystemExit) as raised: diff --git a/tests/test_doctor.py b/tests/test_doctor.py index e21fe3b..be103bf 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -9,12 +9,18 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "src")) -from listening_stack.catalog import MODELS, REPOSITORIES # noqa: E402 +from listening_stack.catalog import ( # noqa: E402 + ACCOUNTABLE_LISTENING_CONTRACTS, + MODELS, + REPOSITORIES, +) from listening_stack.doctor import ( # noqa: E402 _check_germ_boundary, _check_model, + _check_oida_accountability_contracts, _check_private_file, _check_repository, + _fetch_local_json, ) @@ -38,6 +44,7 @@ def test_germ_boundary_accepts_only_the_installer_local_paths(self): ) ), "GERM_ENABLE_CLOUD_VISION": "0", + "GERM_OIDA_URL": "http://127.0.0.1:8765", } self.assertEqual(_check_germ_boundary(root, environment).status, "pass") environment["GERM_ALLOWED_INPUT_ROOTS"] += ",/tmp" @@ -101,7 +108,78 @@ def output(command, **_kwargs): (Path(temporary) / ".git").mkdir() check = _check_repository(Path(temporary), "oida", spec.revision) self.assertEqual(check.status, "pass") - self.assertIn("v0.6.5", check.detail) + self.assertIn("v0.9.0", check.detail) + + def test_oida_live_contracts_verify_manifest_and_four_schemas(self): + manifest = { + "version": "0.9.0", + "contract": "oida/gateway/v0.5", + "components": { + "akouo": {"contract": "akouo/v0.9"}, + "earworm": {"contract": "earworm/v0.6"}, + "akousmata": {"contract": "akousmata/v0.6"}, + }, + "schemas": { + "host_perception": "/gateway/schema/host-perception", + "listening_event": "/gateway/schema/listening-event", + "listening_context": "/gateway/schema/listening-context", + "route_outcome": "/gateway/schema/route-outcome", + }, + } + schemas = [ + {"properties": {"contract": {"const": contract}}} + for contract in ( + ACCOUNTABLE_LISTENING_CONTRACTS["host_perception"], + ACCOUNTABLE_LISTENING_CONTRACTS["listening_event"], + ACCOUNTABLE_LISTENING_CONTRACTS["listening_context"], + ACCOUNTABLE_LISTENING_CONTRACTS["route_outcome"], + ) + ] + with patch( + "listening_stack.doctor._fetch_local_json", + side_effect=[manifest, *schemas], + ) as fetch: + checks = _check_oida_accountability_contracts("http://127.0.0.1:8765") + self.assertEqual(fetch.call_count, 5) + self.assertEqual(len(checks), 5) + self.assertTrue(all(check.status == "pass" for check in checks)) + + def test_oida_live_contracts_fail_closed_on_semantic_drift(self): + manifest = { + "version": "0.9.0", + "contract": "oida/gateway/v0.5", + "components": { + "akouo": {"contract": "akouo/v0.9"}, + "earworm": {"contract": "earworm/v0.5"}, + "akousmata": {"contract": "akousmata/v0.6"}, + }, + "schemas": { + "host_perception": "/gateway/schema/host-perception", + "listening_event": "/gateway/schema/listening-event", + "listening_context": "/gateway/schema/listening-context", + "route_outcome": "/gateway/schema/route-outcome", + }, + } + schemas = [ + {"properties": {"contract": {"const": contract}}} + for contract in ( + ACCOUNTABLE_LISTENING_CONTRACTS["host_perception"], + "oida/listening-event/v0.1", + ACCOUNTABLE_LISTENING_CONTRACTS["listening_context"], + ACCOUNTABLE_LISTENING_CONTRACTS["route_outcome"], + ) + ] + with patch( + "listening_stack.doctor._fetch_local_json", + side_effect=[manifest, *schemas], + ): + checks = _check_oida_accountability_contracts("http://localhost:8765") + self.assertEqual(checks[0].status, "fail") + self.assertEqual(checks[2].status, "fail") + + def test_gateway_contract_fetch_rejects_non_loopback_urls(self): + with self.assertRaisesRegex(ValueError, "loopback"): + _fetch_local_json("https://example.com", "/gateway") if __name__ == "__main__": diff --git a/tests/test_installer.py b/tests/test_installer.py index b2e7171..0b2b024 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -1,4 +1,5 @@ from pathlib import Path +import json import subprocess import sys import tempfile @@ -12,6 +13,7 @@ from listening_stack.catalog import Repository # noqa: E402 from listening_stack.installer import ( # noqa: E402 HF_CLI_VERSION, + STATE_CONTRACT, Installer, Selection, load_state, @@ -23,7 +25,7 @@ class InstallerTests(unittest.TestCase): def selection(self, root, **changes): values = { - "component": "full", + "component": "core", "model_keys": [], "integrations": [], "provider": "auto", @@ -40,13 +42,43 @@ def test_dry_run_model_free_install_has_no_filesystem_effect(self): state = Installer( self.selection(root), Runner(dry_run=True, quiet=True) ).install() - self.assertEqual(state["component"], "full") - self.assertEqual(state["installer_version"], "0.1.2") + self.assertEqual(state["schema_version"], 2) + self.assertEqual(state["contract"], STATE_CONTRACT) + self.assertEqual(state["profile"], "core") + self.assertEqual(state["component"], "core") + self.assertEqual( + state["components"], ["earworm", "akouo", "akousmata", "oida"] + ) + self.assertEqual(state["optional_components"], []) + self.assertEqual(state["installer_version"], "0.3.0") + self.assertEqual(state["contracts"]["gateway"], "oida/gateway/v0.5") + self.assertEqual( + state["contracts"]["listening_context"], + "akouo/listening-context/v2", + ) self.assertEqual( state["environment"]["AKOUSMATA_PATH"], str(Path(state["root"]) / "data" / "akousmata"), ) + self.assertNotIn("GERM_ENABLE_CLOUD_VISION", state["environment"]) + self.assertNotIn("OIDA_GERM_URL", state["environment"]) + self.assertNotIn("germ", state["commits"]) + self.assertFalse(root.exists()) + + def test_full_profile_explicitly_includes_germ_boundaries(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / "install" + state = Installer( + self.selection(root, component="full"), + Runner(dry_run=True, quiet=True), + ).install() + self.assertEqual(state["profile"], "full") + self.assertEqual(state["optional_components"], ["germ"]) + self.assertIn("germ", state["components"]) self.assertEqual(state["environment"]["GERM_ENABLE_CLOUD_VISION"], "0") + self.assertEqual( + state["environment"]["OIDA_GERM_URL"], "http://127.0.0.1:5178" + ) self.assertEqual( set(state["environment"]["GERM_ALLOWED_INPUT_ROOTS"].split(",")), { @@ -65,6 +97,22 @@ def test_dry_run_model_free_install_has_no_filesystem_effect(self): ) self.assertFalse(root.exists()) + def test_germ_only_profile_does_not_configure_oida(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / "install" + state = Installer( + self.selection(root, component="germ"), + Runner(dry_run=True, quiet=True), + ).install() + environment = state["environment"] + self.assertEqual(state["components"], ["germ"]) + self.assertNotIn("OIDA_HOST", environment) + self.assertNotIn("GERM_OIDA_URL", environment) + self.assertNotIn( + str(Path(state["root"]) / "data" / "audio"), + environment["GERM_ALLOWED_INPUT_ROOTS"].split(","), + ) + def test_gated_weights_require_explicit_confirmation(self): with tempfile.TemporaryDirectory() as temporary: selection = self.selection( @@ -97,7 +145,7 @@ def test_duplicate_models_are_rejected(self): with tempfile.TemporaryDirectory() as temporary: selection = self.selection( Path(temporary) / "stack", - component="oida", + component="core", model_keys=["moss-4b-instruct", "moss-4b-instruct"], ) with self.assertRaisesRegex(ValueError, "duplicates"): @@ -123,6 +171,46 @@ def test_load_state_rejects_symlink(self): with self.assertRaisesRegex(ValueError, "Refusing symlinked"): load_state(root) + def test_load_state_accepts_legacy_component_state(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / "stack" + path = state_path(root) + path.parent.mkdir(parents=True) + path.write_text( + json.dumps( + { + "schema_version": 1, + "component": "oida", + "environment": {}, + "commits": {}, + } + ), + encoding="utf-8", + ) + self.assertEqual(load_state(root)["component"], "oida") + + def test_load_state_rejects_tampered_v2_profile_metadata(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / "stack" + path = state_path(root) + path.parent.mkdir(parents=True) + state = { + "schema_version": 2, + "contract": STATE_CONTRACT, + "profile": "core", + "component": "core", + "components": ["earworm", "akouo", "akousmata", "oida"], + "core_components": ["earworm", "akouo", "akousmata", "oida"], + "optional_components": ["germ"], + "environment": {}, + "commits": {}, + "contracts": {}, + "repositories": {}, + } + path.write_text(json.dumps(state), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "profile metadata"): + load_state(root) + def test_install_root_inside_git_repository_is_rejected(self): with tempfile.TemporaryDirectory() as temporary: project = Path(temporary) / "project" diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 4274aac..b8f45ba 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -23,7 +23,7 @@ class RuntimeTests(unittest.TestCase): def write_state(self, root: Path, **changes): state = { "schema_version": 1, - "installer_version": "0.1.2", + "installer_version": "0.2.0", "root": str(root), "component": "full", "models": [],