From f08283609ec5ce8eb30a2c975c28ff2e3eb9f934 Mon Sep 17 00:00:00 2001 From: Mira Voss Date: Sat, 3 Oct 2026 12:08:59 +0800 Subject: [PATCH 1/6] feat: add v2 texture source discovery Extend texture profiles with v2 semantic metadata, exact searchable filters, pagination, and legacy catalog verification.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 35 +- docs-site/src/commands.md | 14 + docs-site/src/machine-interface.md | 2 +- docs-site/src/profiles.md | 35 +- docs/roadmap.md | 73 ++-- skills/scorekit/SKILL.md | 21 +- skills/scorekit/reference.md | 4 +- src/main.rs | 194 +++++++++- src/mcp.rs | 61 ++- src/texture.rs | 591 ++++++++++++++++++++++++----- src/texture_check.rs | 68 +++- tests/cli.rs | 349 +++++++++++++++-- 12 files changed, 1196 insertions(+), 251 deletions(-) diff --git a/README.md b/README.md index d700c76..6ba6761 100644 --- a/README.md +++ b/README.md @@ -141,39 +141,46 @@ describes each one, so an agent can choose without opening the files: ```yaml # textures.yaml -schema_version: 1 +schema_version: 2 name: forest-field-recordings root: /Volumes/Samples sources: river: path: ambience/river.flac - description: Wide river bed, mid-distance, no bird calls - category: organic + family: organic tags: [water, flowing, continuous] - playback: { modes: [loop], default_mode: loop } - use_cases: [forest, travel] - provenance: { library: field-recordings@2024.1 } + playback: { modes: [loop], default_mode: loop, loopable: true } + audio: { duration_seconds: 18.2, intensity: 0.35, brightness: 0.2, tonality: atonal } + scenes: [forest, travel] forest_birds: path: wildlife/birds.wav - description: Single dawn chorus swell, ends on silence - category: organic + family: organic tags: [wildlife, chirping] playback: { modes: [one_shot], default_mode: one_shot } - use_cases: [forest, dawn] - provenance: { library: field-recordings@2024.1 } + audio: { duration_seconds: 7.4, intensity: 0.55, brightness: 0.72, tonality: tonal } + scenes: [forest, dawn] ``` Existing path-only bindings such as `river: ambience/river.flac` remain -build-compatible. They are intentionally unavailable to `texture inspect` and -`texture check` until migrated to the structured form above; scorekit never -invents discovery metadata for a legacy entry. +build-compatible and enumerable; their discovery result marks metadata as +unavailable rather than inventing it. `texture check` also verifies legacy +files exist, decode, and are audible. Schema v1 structured profiles remain +supported unchanged. ```bash -scorekit texture inspect textures.yaml --category organic --tag water +scorekit --json texture inspect textures.yaml --family organic --tag water \ + --scene forest --mode loop --min-duration 5 --max-intensity 0.6 \ + --limit 100 --offset 0 scorekit texture check textures.yaml scorekit build forest.yaml --texture-profile textures.yaml -o forest.ogg --stems ``` +Discovery filters are exact and conjunctive: no similarity ranking is used, +and a zero-result query returns `status: "no_match"` with exit 0. `--limit` +and `--offset` page large inventories; `total` is the full inventory count +and `matched` is the count satisfying all filters. `texture check` is the +decodability gate to run before relying on a selected source. + `texture inspect` filters exactly and conjunctively — no similarity ranking, so "nothing fits" is an answer you can trust rather than a plausible wrong pick. `texture check` proves every source exists, decodes, and is audible, diff --git a/docs-site/src/commands.md b/docs-site/src/commands.md index 1040cbf..6217cd1 100644 --- a/docs-site/src/commands.md +++ b/docs-site/src/commands.md @@ -21,6 +21,8 @@ All commands accept the global `--json` flag. Successful diagnostic commands wri | `profile check ` | Render probes through every mapped SFZ patch in a leaf renderer profile | | `orchestration check ` | Validate palette bindings and every referenced leaf profile/SFZ file | | `inspect-instruments ` | Resolve every track's instrument and report substitutions and gaps | +| `texture inspect ` | Enumerate or exactly filter texture sources before writing `textures[].source` | +| `texture check ` | Verify every source exists, decodes, and is audible | | `diff ` | Compare scene semantics | | `batch --out-dir ` | Build several scenes and write a JSON report | | `mcp` | Serve MCP (Model Context Protocol) over stdio; each tool wraps one CLI command | @@ -38,6 +40,18 @@ Scenes with `textures` require `build`/`batch --texture-profile `. This flag is independent of the musical renderer: it works alongside either an SF2 `--soundfont` or an sfizz `--orchestration` profile. +Texture profile schema v1 structured sources and path-only mappings remain +supported. Schema v2 adds `family`, `tags`, `playback`, optional curated +`audio` descriptors (`duration_seconds`, `intensity`, `brightness`, and +`tonality`), and `scenes`. `texture inspect ` supports exact +conjunctive filters (`--family`, repeated `--tag`, `--scene`, `--mode`, +audio-property bounds, and `--loopable`); `--limit`/`--offset` page results. +JSON includes the full inventory `total`, filter `matched` count, returned +page, resolved paths, and file-existence flags. No candidate is reported as +`status: "no_match"` with exit 0, never as an approximate match. Legacy +path-only entries remain enumerable with `metadata_available: false`; +`texture check` verifies file readability, decoding, and audible samples. + Standalone `midi` can encode melodic instruments with exact GM programs plus channel-10 `drums`/`tabla`. It rejects profile-only melodic identities before writing the output: the command has no renderer-profile input, and omitting a diff --git a/docs-site/src/machine-interface.md b/docs-site/src/machine-interface.md index 5509a34..3533769 100644 --- a/docs-site/src/machine-interface.md +++ b/docs-site/src/machine-interface.md @@ -83,7 +83,7 @@ For the common case you don't need to write the wrapper at all: scorekit mcp # MCP over stdio (newline-delimited JSON-RPC 2.0) ``` -`scorekit mcp` exposes `doctor`, `validate`, `schema`, `lint`, `build`, `inspect_instruments`, `orchestration_check`, `inspect_textures`, `texture_check`, and `diff` as MCP tools. It is a pure protocol adapter: each tool call re-invokes the scorekit binary with `--json`, and the structured stdout/stderr is passed through verbatim as the tool result (`isError: true` carries the exact error object shown above). No HTTP, no auth, no resident state — determinism is untouched, and MCP clients get the same contract as subprocess callers. The `build` and `inspect_instruments` tools take `orchestration` (the sfizz routing input) instead of the retired per-build `profile` argument. +`scorekit mcp` exposes `doctor`, `validate`, `schema`, `lint`, `build`, `inspect_instruments`, `orchestration_check`, `inspect_textures`, `texture_check`, and `diff` as MCP tools. Texture discovery accepts exact family/tag/scene/mode/audio filters and page limits; a query with no candidate returns `status: "no_match"` rather than a ranked substitute. It is a pure protocol adapter: each tool call re-invokes the scorekit binary with `--json`, and the structured stdout/stderr is passed through verbatim as the tool result (`isError: true` carries the exact error object shown above). No HTTP, no auth, no resident state — determinism is untouched, and MCP clients get the same contract as subprocess callers. The `build` and `inspect_instruments` tools take `orchestration` (the sfizz routing input) instead of the retired per-build `profile` argument. Example client registration (Claude Desktop / any MCP client): diff --git a/docs-site/src/profiles.md b/docs-site/src/profiles.md index 90461f7..65b12ad 100644 --- a/docs-site/src/profiles.md +++ b/docs-site/src/profiles.md @@ -255,10 +255,10 @@ scene uses must be mapped to a real file; failure leaves no normalized, arranged, output, stem, or metadata artifact behind. For compatibility, a legacy binding such as `river: ambience/river.flac` can -still be used by `build`. It cannot be enumerated or certified: -`texture inspect` and `texture check` reject profiles containing path-only -bindings until they are migrated to structured sources. This preserves old -renders without fabricating category, playback, or provenance facts. +still be used by `build`. It is also enumerable and certifiable: +`texture inspect` reports its key/path/existence and +`metadata_available: false`, while `texture check` verifies the file decodes +and is audible. No category, playback, or provenance facts are fabricated. ### Why every source carries metadata @@ -267,27 +267,29 @@ authoring agent reads before it writes `textures[].source`. Without it, two entries named `amb1` and `amb2` are indistinguishable without opening the files, and there is no way to conclude that nothing in the profile fits. -For every structured source, the descriptive fields are required, not -optional: optional metadata makes the discovery contract only as good as the -laziest entry. +Schema v1 structured sources require every descriptive field. Schema v2 +supports an explicit family, scenes, and optional curated audio descriptors; +the versioned schema makes both formats machine-readable: -- `description` — one line on what is actually audible. -- `category` — a **closed** vocabulary compiled into the binary, so it stays +- V1 `description` — one line on what is actually audible. +- V1 `category` — a **closed** vocabulary compiled into the binary, so it stays a stable filter axis instead of drifting into `ambience` / `ambient` / `ambiences`. Run `scorekit schema --texture-profile` to see the full list with per-value meaning: `ambience`, `foley`, `impact`, `transition`, `tonal`, `industrial`, `organic`, `sound_design`. -- `tags` and `use_cases` — open vocabularies (`[a-z][a-z0-9_-]{0,31}`, 1–16 +- V2 `family` — a validated exact token, alongside `tags` and `scenes` — + open vocabularies (`[a-z][a-z0-9_-]{0,31}`, 1–16 entries) for expressive room the closed enum deliberately lacks. - `playback.modes` — which of `loop` / `one_shot` the recording actually supports, plus a `default_mode` that must be one of them. This is enforced at build time: a scene that loops a one-shot is rejected before staging. - `provenance.library` — the versioned library identity the source came from. -Physics is deliberately **not** declared here. Duration, sample rate, peak -and checksum are measured by `scorekit texture check`, because a -hand-written duration is a fact nothing can verify and every re-export -silently invalidates. +V2 `audio.duration_seconds`, `intensity`, `brightness`, and `tonality` are +curated discovery descriptors, not measured guarantees. `texture check` +independently verifies readability and reports measured duration, sample +rate, peak, RMS, and source checksum; consumers should use those measurements +when they need physical values. ### Finding sources: `texture inspect` @@ -296,6 +298,8 @@ scorekit texture inspect textures.yaml # enumerate everythi scorekit texture inspect textures.yaml --category impact scorekit texture inspect textures.yaml --tag rain --tag soft scorekit texture inspect textures.yaml --mode loop --use-case forest +scorekit texture inspect textures.yaml --family industrial --scene factory \ + --min-duration 2 --max-intensity 0.7 --limit 100 --offset 0 scorekit --json texture inspect textures.yaml --source river ``` @@ -304,7 +308,8 @@ scorekit never ranks by similarity. It answers "which sources satisfy all of these constraints", never "which is closest" — so `"status": "no_match"` is a truthful answer rather than a plausible wrong pick, and it exits 0 the way `diff` does. A `--category` outside the closed vocabulary is a typo, not an -empty result set, and exits 2. +empty result set, and exits 2. `--limit`/`--offset` paginate; `total` is +inventory size and `matched` is the complete count after filtering. ### Certifying sources: `texture check` diff --git a/docs/roadmap.md b/docs/roadmap.md index 7df7c29..ccda48b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -45,7 +45,7 @@ Once finished, it is a **deterministic composition compiler**: - **Disco composition primitives**: several percussion tracks may share General MIDI channel 10 while remaining separate SMF tracks and stems; percussion clips expose standard GM tambourine/cowbell/bongo/conga/timbale/agogo/cabasa/maracas voices; additive exact-GM `clavinet` and `synth_brass` identities leave the 60-instrument core and world extension unchanged. These are portable score semantics, not style presets or renderer parameters. Evidence: `src/schema.rs`, `src/instrument.rs`, `src/composer.rs`, `tests/cli.rs`. - **Spatial performance fields**: per-track `pan`/`reverb`/`glide` compile to MIDI CC10/CC91/pitch-bend deterministically; `glide` is a melody-only tail portamento whose ramps stay loop-seam continuous; absent fields leave output byte-identical. Evidence: `src/composer.rs::glide_bends`, `src/midi.rs`, `tests/cli.rs`. - **Deterministic sound-texture layers**: `textures` schedules field recordings, ambience, and SFX as continuous loops or quarter-note one-shots; a separate `--texture-profile` maps portable source names to local audio files, FFmpeg owns decode/resample/channel conversion, and scorekit performs only sample-exact placement/gain/summation. Texture stems share the music window and loop seal, remain sample-aligned, and appear in `meta.json`; runtime world audio remains outside the compiler. Evidence: `src/texture.rs`, `src/audio.rs::arrange_texture`, `src/pipeline.rs::render_texture_tracks`, `tests/cli.rs`. -- **Texture source discovery and certification**: a discoverable texture profile is a discovery contract, not just a path table — every structured source carries required `description`/`category`/`tags`/`playback`/`use_cases`/`provenance`, so an agent can enumerate candidates, tell them apart, and conclude nothing fits before writing `textures[].source`. Legacy path-only bindings remain build-compatible but cannot be inspected or certified, and scorekit never fabricates their metadata. `scorekit texture inspect` filters exactly and conjunctively (never by similarity) and reports an honest `no_match`; `scorekit texture check` measures physics (existence, decodability, duration, peak/rms, source `sha256`) the profile deliberately never hand-writes; declared `playback.modes` are enforced at build time before staging. Evidence: `src/texture.rs`, `src/texture_check.rs`, `src/pipeline.rs::render_texture_tracks`, `tests/cli.rs`. +- **Texture source discovery and certification**: texture profiles support metadata-rich structured sources and legacy path strings. Schema v1 structured sources retain required `description`/`category`/`tags`/`playback`/`use_cases`/`provenance`; v2 adds `family`/`scenes` and optional curated audio descriptors. Flat mappings remain build-compatible and are discoverable/certifiable with metadata explicitly marked unknown. `scorekit texture inspect` filters exactly and conjunctively (never by similarity), supports pagination, and reports an honest `no_match`; `scorekit texture check` independently measures file existence, decodability, duration, peak/rms, and source `sha256`; declared `playback.modes` are enforced at build time before staging. Evidence: `src/texture.rs`, `src/texture_check.rs`, `src/pipeline.rs::render_texture_tracks`, `tests/cli.rs`. - **Rendering and export**: `scorekit render` (FluidSynth+SF2, SF2 magic-number precheck, catches fluidsynth's silent "exit 0 but error" failures), `scorekit export` (FFmpeg → OGG, libvorbis→vorbis fallback), `scorekit build` full pipeline; every file write goes through temp-file + atomic rename, and suite builds stage the complete output set before publishing it with rollback, so a failure leaves no partial artifact. Evidence: `src/tools.rs`, `src/pipeline.rs::publish_staged_suite`, `tests/cli.rs`. - **Machine-readable errors**: `--json` outputs structured errors (code/message/location/field/exit_code); exit-code convention is 1 io / 2 invalid input / 3 missing dependency / 4 external tool failed. Evidence: `src/error.rs`. - **Music grammar validation**: `scorekit lint scene.yaml --grammar grief.yaml` — an external grammar profile declares deterministic aesthetic constraints (tempo/rests/voice count/phrase length/cadence/harmony whitelist plus compiled percussion density/onsets and automation activity); rules may be scoped to named suite sections, deep rules are measured on the compiled IR, and violations report measured values; `scorekit schema --grammar` exports the profile's schema. Evidence: `src/grammar.rs`, `examples/grammars/grief.yaml`, `examples/grammars/heavy_dubstep.yaml`. @@ -299,41 +299,42 @@ that may not exist or falls back to whatever it saw in an example. Growing the corpus made this worse, not better: more sources meant a longer undifferentiated list. -- **A profile is a discovery contract**: every discoverable source is a structured - declaration carrying required `description`, `category`, `tags`, - `playback`, `use_cases`, and `provenance`. The metadata is mandatory - precisely because optional metadata makes the contract only as good as the - laziest entry. -- **Compatibility does not invent facts**: the original path-only source form - remains accepted by `build`, including profiles that omit `schema_version` - (treated as version 1). Such a profile is not a discovery contract: - `texture inspect` and `texture check` reject it until migrated. The exported - schema publishes both the legacy string and structured object forms, while - incomplete structured objects still fail with their exact field. -- **Closed axis, open room**: `category` is a closed enum compiled into the - binary (`ambience`, `foley`, `impact`, `transition`, `tonal`, - `industrial`, `organic`, `sound_design`), each published with its meaning - in `schema --texture-profile`. A free-form category would drift into - `ambience`/`ambient`/`ambiences` and reproduce the exact failure the flat - map had. `tags` and `use_cases` stay open (validated tokens) for the - expressive room a fixed enum cannot supply. -- **Declare intent, measure physics, never hand-write physics**: the YAML - declares what a source is *for*; `scorekit texture check` measures what it - *is* — existence, decodability, `duration_seconds`, `frames`, `peak_abs`, - `rms`, and a `sha256` of the source file (stable across FFmpeg versions, - so it identifies the recording rather than the decoder). Duration and - channel counts are deliberately absent from the schema: a hand-written - duration is a fact nothing can verify and every re-export silently - invalidates. `silent` is a first-class failure status, because a silent - recording survives every structural check and only surfaces as a missing - layer in the finished mix. +- **A profile is a discovery contract**: schema v1 structured sources retain + their required `description`, `category`, `tags`, `playback`, `use_cases`, + and `provenance`. Schema v2 sources declare `path`, `family`, `tags`, + `playback`, and `scenes`, with optional curated audio descriptors + (`duration_seconds`, `intensity`, `brightness`, `tonality`) and optional + description/provenance. The exported schema publishes both versions, + enforces token/range rules, and closes every source object against unknown + properties. +- **Compatibility does not invent facts**: string-valued path bindings remain + accepted by `build`, `texture inspect`, and `texture check`, including + profiles that omit `schema_version` (treated as version 1). Discovery + returns the key/path/existence and explicitly marks `metadata_available: + false`; `texture check` still verifies existence, decoding, and audible PCM. + Scene playback constraints cannot be inferred for those legacy entries. +- **Closed v1 axis, expressive v2 family**: v1 `category` retains the closed + enum (`ambience`, `foley`, `impact`, `transition`, `tonal`, `industrial`, + `organic`, `sound_design`) and its published explanations. V2 `family` is a + validated token; `tags` and `scenes` provide the open vocabulary needed to + express materially different intents. +- **Curated audio descriptors are not decoder measurements**: schema v2's + optional `audio` fields help agents distinguish candidates, with finite + positive duration and 0..=1 intensity/brightness validation. They are + descriptive profile data, not measured guarantees. `scorekit texture + check` independently reports file existence, decodability, measured + `duration_seconds`, `frames`, `peak_abs`, `rms`, and a `sha256` of source + bytes (stable across FFmpeg versions). `silent` remains a first-class + failure status. - **Exact filtering, never similarity ranking**: `scorekit texture inspect` - answers "which sources satisfy *all* of these constraints" with equality - and set membership only — repeated `--tag` intersects. There is no scoring - and no nearest-match, so `"status": "no_match"` is a truthful answer - instead of a plausible wrong pick, and it exits 0 the way `diff` does. An - unknown `--category` is a typo, not an empty result set, and exits 2. This - keeps the no-embedded-compositional-intelligence rule intact. + answers "which sources satisfy *all* of these constraints" with equality, + set membership, and inclusive numeric ranges — repeated `--tag` intersects. + Family/tag/scene/mode/audio filters combine conjunctively. `--limit` and + `--offset` page large inventories while `total` and `matched` remain + explicit. There is no scoring and no nearest-match, so `"status": + "no_match"` is a truthful answer instead of a plausible wrong pick, and it + exits 0 the way `diff` does. Unknown category/invalid filters are input + errors, not empty result sets. - **Declared constraints are enforced, not decorative**: `playback.modes` is checked against `textures[i].mode` during `build`, before the staging directory exists, so looping a one-shot bell is rejected with a field path @@ -617,7 +618,7 @@ Permissions note: scorekit is a local single-user CLI with no role/permission sy | Instrument resolution & fallback (inspect-instruments/--fallback-mode/--resolver, M12) | High (silent mis-substitution would corrupt musical intent across every backend; unresolved instruments abort file-writing builds) | ✅ (same-family fallback builds with WARN + scored/reasoned `instrument_resolution` in meta.json; flexible config reaches a related synth pad; SF2 scenes resolve all-exact; alias and canonical spellings compile to byte-identical MIDI; `inspect-instruments` reports exact/alias/fallback statuses and exits 0 when all resolved) | ✅ (brass with only strings available → exit 2 code `resolution` + missing report, never a strings substitute; strict mode rejects an otherwise-eligible candidate → exit 2 naming the refused candidate; a role-ambiguous generic clip receives no fabricated melody bonus; `inspect-instruments` exits 2 with the full report when instruments are missing, byte-identical across runs) | N/A (local CLI) | ✅ (resolution failures abort before staging: no partial WAV, stems, or meta.json — verified via exact directory listing) | `tests/cli.rs::build_sfizz_same_family_fallback_substitutes_and_reports` / `build_sfizz_unmapped_instrument_leaves_no_partial_output` / `build_sfizz_strict_mode_rejects_fallback_without_artifacts` / `build_sfizz_flexible_config_reaches_synth_fallback` / `generic_clip_does_not_gain_a_melody_role_fallback_bonus` / `inspect_instruments_reports_statuses_and_is_deterministic` / `inspect_instruments_all_resolved_exits_zero` / `alias_and_canonical_scene_produce_identical_midi` / `build_sf2_resolution_is_all_exact` / `src/resolver.rs::tests` (policy gates: strings opt-in, synth gate, excluded families, drum isolation, determinism, legacy switch) | | Exact world-instrument identities + Tabla pattern | High (a plausible substitute or default piano would silently falsify instrument identity; percussion-channel errors alter every render) | ✅ (all 11 names validate; Erhu+Tabla resolve exact through an SFZ profile and produce non-silent stems; exact SF2 shakuhachi/sitar/shamisen render without fallback; Tabla compiles a byte-identical 16-beat channel-10 theka with no program change) | ✅ (Tabla/drums pattern mismatches fail with a field path; profile-only Erhu under SF2 fails as unresolved, and standalone `midi` rejects it, instead of sounding as piano) | N/A (local CLI) | ✅ (Erhu failures leave no MIDI, WAV, stems, metadata, or staging residue) | `tests/cli.rs::world_instrument_vocabulary_and_tabla_pattern_validate` / `build_sfizz_world_instruments_renders_erhu_and_tabla_stems` / `build_sf2_exact_world_programs_render_without_fallback` / `tabla_midi_is_a_deterministic_16_beat_channel_10_theka` / `midi_rejects_profile_only_melodic_instrument_without_artifact` / `build_sf2_profile_only_world_instrument_fails_without_artifacts` | | Per-track orchestration profiles (`--orchestration`, `orchestration check`, M13) | High (multi-profile routing feeding the same in-process mixer M7 relies on; a wrong or leaking palette would silently corrupt which sample a track renders with) | ✅ (`orchestration check` + `schema --orchestration` load and validate relative leaf profiles and report `{name, default_palette, palettes}`; two explicit tracks on different palettes route the same `violin` instrument to two distinct certified patches, render audibly different stems, and still sum to the mix; `inspect-instruments --orchestration` reports each track's `id`/palette/leaf-profile/articulation/resolved SFZ; track `id`/`palette` and section `mute` (now ID-keyed) never change compiled MIDI bytes) | ✅ (missing/invalid `id`/`palette` syntax → exit 2 + field path before any build; an orchestration naming a missing leaf profile → exit 2 naming the exact `palettes..profile` field; a track requesting an undefined palette fails `build` before any file is staged; `inspect-instruments --fallback-mode strict` proves a track is rejected rather than silently borrowing a sibling palette's mapping, even when that mapping would otherwise qualify) | N/A (local CLI) | ✅ (unknown-palette and missing-leaf-profile failures leave no partial WAV/stems/meta.json — verified via exact directory listing; `batch` forwards `--orchestration` to every scene the same atomic way as `--soundfont`) | `tests/cli.rs::orchestration_schema_and_check_load_relative_leaf_profiles` / `orchestration_check_contextualizes_missing_leaf_profile` / `validate_requires_unique_stable_track_and_palette_keys` / `track_identity_and_palette_do_not_change_midi_bytes` / `build_sfizz_routes_same_instrument_to_distinct_palette_patches` / `build_sfizz_unknown_palette_fails_before_writing_artifacts` / `build_sfizz_missing_orchestration_is_input_error` / `inspect_instruments_reports_per_track_palette_patch_routes` / `inspect_instruments_never_falls_back_across_palettes` / `batch_sfizz_forwards_orchestration_to_each_build` / `mcp_exposes_orchestration_across_schema_check_build_and_inspect` | -| Texture source discovery & certification (`texture inspect`, `texture check`, M14) | High (the discovery contract agents author against; a wrong or unverifiable source silently ships as an absent or mis-cast layer, and `texture check` shells out to FFmpeg over untrusted recordings) | ✅ (the additive published schema accepts legacy path strings plus structured sources, with every discovery field required on the structured variant, `additionalProperties: false`, and the closed category vocabulary documented per value, while measured physics is deliberately absent; a 1200-source structured inventory enumerates completely and byte-identically across runs with full metadata per entry; filters are exact and conjunctive across category/tag/mode/use-case and repeated `--tag` intersects; `texture check` reports `sha256`/`duration_seconds`/`frames`/`peak_abs`/`rms` deterministically and carries declared intent alongside the measurement; both commands are reachable over MCP) | ✅ (an unsatisfiable query returns a truthful `no_match` at exit 0 rather than a nearest match, but an unknown `--category` is a typo → exit 2 naming the valid vocabulary; legacy path-only profiles remain build-compatible but `inspect`/`check` reject them instead of fabricating metadata; incomplete structured sources — missing description/tags/provenance, unknown category, non-token use case, `default_mode` outside `modes`, empty path — and unsupported `schema_version` fail with the exact field, while an omitted version defaults to 1; `texture check` reports `missing` and `silent` sources as failures with exit 2) | N/A (local CLI) | ✅ (`texture check` scratch renders honor `SCOREKIT_TMPDIR` and are swept on the failure path, verified by exact directory listing; a structured source's undeclared mode aborts `build` before the staging directory is created, so no output directory exists at all) | `tests/cli.rs::texture_profile_schema_publishes_required_discovery_metadata` / `texture_inspect_enumerates_large_inventory_deterministically` / `texture_inspect_filters_exactly_and_admits_no_match` / `legacy_texture_profile_builds_but_discovery_requires_metadata` / `texture_profile_rejects_incomplete_discovery_metadata` / `texture_check_measures_physics_of_declared_sources` / `texture_check_rejects_missing_and_silent_sources_without_residue` / `build_rejects_texture_mode_the_profile_does_not_declare` / `mcp_exposes_texture_inspect_and_check` / `src/texture.rs::tests` | +| Texture source discovery & certification (`texture inspect`, `texture check`, M14) | High (the discovery contract agents author against; a wrong or unverifiable source silently ships as an absent or mis-cast layer, and `texture check` shells out to FFmpeg over untrusted recordings) | ✅ (schema exports structured v1/v2 plus legacy strings; v2 exposes family/tags/scenes/playback/audio descriptors; 1200-source inventory enumerates deterministically; exact conjunctive filters and pagination include total/match counts; check reports measured file properties; CLI and MCP share the interface) | ✅ (`no_match` is explicit and exits 0; invalid audio ranges and unknown categories fail with exit 2; flat entries enumerate as metadata-unknown and still certify file presence/decodability; missing/silent/corrupt sources fail; unsupported schema versions and invalid playback declarations identify the field) | N/A (local CLI) | ✅ (`texture check` scratch files are swept on success/failure; invalid or missing build sources leave no output artifacts; undeclared playback mode aborts before staging) | `tests/cli.rs::texture_profile_schema_publishes_required_discovery_metadata` / `texture_inspect_enumerates_large_inventory_deterministically` / `texture_inspect_filters_exactly_and_admits_no_match` / `texture_v2_sources_are_searchable_paginated_and_certifiable` / `texture_v2_rejects_invalid_audio_and_playback_metadata` / `legacy_texture_profile_builds_and_discovery_marks_metadata_unknown` / `texture_profile_rejects_incomplete_discovery_metadata` / `texture_check_measures_physics_of_declared_sources` / `texture_check_rejects_missing_silent_and_undecodable_sources_without_residue` / `build_unknown_texture_source_suggests_a_profile_key` / `build_rejects_texture_mode_the_profile_does_not_declare` / `mcp_exposes_texture_inspect_and_check` / `src/texture.rs::tests` | | Exact event clips, step automation, and section-scoped structural lint (M15) | High (authored rhythm/controller state is byte-level score intent; an ignored SFZ control or unstable event order silently changes the drop) | ✅ (stable-ID pitched/percussion clips validate and compile to exact byte-identical MIDI independent of map declaration order; authored timing is isolated from swing/legato/humanize; section clip overrides equal standalone compilation; suite capability checks follow only clips selected by unmuted sections; CC1/CC11/CC74/pitch-bend use frozen same-tick ordering; semantic diff addresses event IDs; compiled percussion/onset/automation rules work per section; the shipped heavy-Dubstep scene/grammar pair validates, lints, and compiles deterministically) | ✅ (missing/wrong-kind references, extreme/out-of-range pitches, overlap/boundary/loop ambiguity, malformed automation values/initial state/seal, per-track expansions above 65,536 events in either scenes or sections, and unsupported SFZ controls on active clips fail with precise field paths; renderer mappings remain backward-compatible strings but must opt into each automated target) | N/A (local CLI) | ✅ (invalid clip/automation MIDI writes leave no partial artifact; an sfizz build whose effective patch lacks a required control aborts before staging and leaves no WAV/stems/meta residue) | `tests/cli.rs::clip_scene_validates_and_schema_exposes_stable_clip_maps` / `clip_events_compile_to_exact_deterministic_midi_independent_of_map_order` / `explicit_clip_timing_and_velocity_ignore_generative_performance_transforms` / `section_clip_override_compiles_as_the_equivalent_standalone_scene` / `invalid_clip_semantics_fail_with_precise_paths_and_no_midi_artifact` / `validate_rejects_clip_expansion_beyond_the_per_track_event_budget` / `validate_applies_clip_expansion_budget_to_suite_sections` / `clip_step_automation_emits_canonical_deterministic_midi_events` / `invalid_clip_automation_fails_before_writing_midi` / `sfizz_orchestration_requires_declared_clip_automation_controls` / `sfizz_suite_ignores_controls_from_an_inactive_base_clip` / `diff_matches_clip_events_by_stable_identity` / `lint_measures_section_clip_percussion_and_automation_rules` / `heavy_dubstep_reference_scene_is_valid_linted_and_deterministic` | | Five independent Disco identities and composition primitives (M16) | High (shared-channel routing, generated controller density, a false instrument identity, or copied profile would silently homogenize every Disco render) | ✅ (two percussion tracks validate, compile deterministically on channel 10 without program changes, and render as independent aligned stems whose sum reconstructs the mix; all auxiliary voices appear in schema and emit unique fixed GM keys; Clavinet/Synth Brass appear additively and emit programs 7/62; linear CC/bend curves use the fixed 60-tick grid while omitted interpolation remains byte-identical to explicit `step`; all five shipped scene/grammar pairs validate, lint, and explicitly select one of the unified `disco` orchestration's five palettes; the five additive renderer identities pass profile/orchestration checks, strict exact inspection, audible stem builds, and byte-identical repeat builds) | ✅ (conflicting percussion pan/reverb channel state and generated linear samples above 65,536 are rejected with exact fields before composition; malformed automation and unsupported patch controls retain the M15 structured failure paths; explicit reference palettes prevent non-Nu-Disco scenes from silently inheriting the `nu-disco` default; strict resolution refuses a missing identity rather than borrowing another profile) | N/A (local CLI) | ✅ (invalid automation leaves no MIDI artifact; Forge builds and scorekit stem builds use atomic staging/replacement and tested failure recovery) | `tests/cli.rs::disco_family_reference_scenes_are_valid_linted_and_palette_explicit` / `multiple_percussion_tracks_share_channel_ten_deterministically` / `validate_rejects_conflicting_channel_controls_across_percussion_tracks` / `build_multiple_percussion_stems_are_independent_aligned_and_sum_to_mix` / `disco_auxiliary_percussion_voices_are_public_and_emit_standard_gm_keys` / `src/schema.rs::tests::percussion_voice_midi_keys_are_unique` / `disco_instruments_are_public_and_emit_exact_gm_programs` / `clip_linear_automation_uses_a_fixed_grid_and_preserves_the_step_default` / `validate_counts_generated_linear_samples_against_the_event_budget` / `invalid_clip_automation_fails_before_writing_midi` | | MakeCode song export (`makecode`, M17) | Medium (file writes; a wrong byte would silently corrupt playback on-device, and a silent capability degradation would falsify authored intent) | ✅ (forest scene compiles to `.ts` + `.meta.json` with correct header/track/voice reporting; output is byte-identical to a golden file and across double runs; a suite emits one song per section honoring tempo overrides and loop flags; four real MakeCode-editor songs roundtrip decode→encode byte-exactly as unit golden vectors) | ✅ (glide/pitch bends → exit 2 + `tracks[i]`; `textures` → exit 2 + `textures`; sub-grid `humanize` timing → exit 2 naming the tick-per-beat cap and the fix) | N/A (local CLI) | ✅ (all failure paths verified to leave no partial `.ts` or `.meta.json` via exact directory listing; both artifacts go through temp-file + atomic rename) | `tests/cli.rs::makecode_happy_path_writes_song_and_manifest` / `makecode_matches_golden_source` / `makecode_is_deterministic_across_runs` / `makecode_suite_emits_one_song_per_section` / `makecode_glide_scene_fails_with_no_partial_artifacts` / `makecode_texture_scene_fails_with_no_partial_artifacts` / `makecode_humanized_scene_fails_with_grid_error` / `src/makecode.rs::tests::roundtrips_makecode_editor_songs_byte_exactly` | diff --git a/skills/scorekit/SKILL.md b/skills/scorekit/SKILL.md index 4865238..2c5b568 100644 --- a/skills/scorekit/SKILL.md +++ b/skills/scorekit/SKILL.md @@ -224,29 +224,32 @@ in a profile that also *describes* each source, so you can pick one without opening the files: ```yaml -schema_version: 1 +schema_version: 2 name: forest root: /path/to/recordings sources: river: path: river.flac - description: Wide river bed, mid-distance - category: organic # closed enum — see schema --texture-profile + family: organic tags: [water, flowing] # open vocabulary - playback: { modes: [loop], default_mode: loop } - use_cases: [forest] - provenance: { library: field-recordings@2024.1 } + playback: { modes: [loop], default_mode: loop, loopable: true } + audio: { duration_seconds: 18.2, intensity: 0.35, brightness: 0.2, tonality: atonal } + scenes: [forest] ``` -**Never guess a source name.** Enumerate and filter first: +**Never guess a source name.** Enumerate, filter, and page through exact +matches first. A legacy path-only entry is still enumerable, but explicitly +has no semantic metadata: ```bash -scorekit texture inspect textures.yaml --category organic --tag water +scorekit --json texture inspect textures.yaml --family organic --tag water \ + --scene forest --mode loop --limit 100 --offset 0 scorekit texture check textures.yaml # certify: exists, decodes, audible ``` Filters are exact and conjunctive (repeated `--tag` intersects); there is no -similarity ranking, so `no_match` means re-orchestrate or add a real source +similarity ranking. `no_match` is an explicit empty result (exit 0), so it +means re-orchestrate or add a real source — not "pick the closest one". `loop` repeats continuously; `one_shot` triggers at quarter-note beats, and a mode the profile does not declare in `playback.modes` is rejected at build time. Keep runtime/world-driven audio diff --git a/skills/scorekit/reference.md b/skills/scorekit/reference.md index 0e6bb89..7a553a0 100644 --- a/skills/scorekit/reference.md +++ b/skills/scorekit/reference.md @@ -21,8 +21,8 @@ Exit codes: `0` ok · `1` io · `2` invalid input / lint violations · `3` missi | `schema` | JSON Schema of scene DSL | `--grammar` → grammar profile; `--profile` → leaf renderer profile; `--orchestration` → orchestration profile; `--texture-profile` → texture-source profile; `--resolver` → instrument-resolver config | | `profile check ` | certify all explicit SFZ mappings and declared automation responses with real probe renders | `--sample-rate` 8000..=384000 (44100); a shared melodic/percussion patch must pass both probes; each declared CC1/CC11/CC74/pitch-bend target must produce deterministic, non-silent, measurably different PCM; global `--json` emits the full report | | `orchestration check ` | validate palette bindings and every referenced leaf renderer profile + SFZ file | global `--json` emits `{name, default_palette, palettes: [{name, profile, profile_path, mappings, patches}]}` | -| `texture inspect ` | enumerate/filter texture sources before writing `textures[].source` | `--category`, `--tag` (repeatable, AND), `--mode loop\|one_shot`, `--use-case`, `--source`; exact conjunctive matching only, never similarity ranking; exits 0 with `"status": "no_match"` when nothing satisfies the query, exit 2 on an unknown category; global `--json` emits the full report | -| `texture check ` | certify every declared source exists, decodes, and is audible | `--sample-rate` (44100); reports `sha256`/`duration_seconds`/`frames`/`peak_abs`/`rms` per source; exit 4 if undecodable, 2 for `missing`/`silent`; global `--json` emits the full report | +| `texture inspect ` | enumerate/filter texture sources before writing `textures[].source` | `--family` / `--category`, repeated `--tag` (AND), `--scene` / `--use-case`, `--mode loop\|one_shot`, audio bounds (`--min/max-duration`, `--min/max-intensity`, `--min/max-brightness`), `--tonality`, `--loopable`, `--source`; exact matches only; `--limit`/`--offset` paginate; exits 0 with `"status": "no_match"` when nothing satisfies the query; global `--json` emits full inventory and match counts | +| `texture check ` | certify every declared source exists, decodes, and is audible | Supports legacy path-only and structured v1/v2 sources; `--sample-rate` (44100); reports `sha256`/measured `duration_seconds`/`frames`/`peak_abs`/`rms` per source; exit 4 if undecodable, 2 for `missing`/`silent`; global `--json` emits the full report | | `lint --grammar ` | check scene against aesthetic grammar | — | | `midi -o ` | compile to SMF (format 1, PPQ 480) | `--passes` 1..=8 (1), `--solo `, `--section `; profile-only melodic identities are rejected before writing because no renderer profile is available | | `makecode -o ` | compile MakeCode `music.createSong` TypeScript + `.meta.json` | one song per suite section; chip/drum amplitudes follow `intensity` against a 1.2-scale mix budget; rejects `glide`/pitch bends, clip CC automation, `textures`, and sub-255-tick grids (`humanize`); `pan`/`reverb` dropped with WARN | diff --git a/src/main.rs b/src/main.rs index 1096d9c..1b4d4a8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -282,6 +282,9 @@ enum TextureCommand { /// Sound family; see `scorekit schema --texture-profile` for the list #[arg(long)] category: Option, + /// Exact sound family in a schema_version 2 profile + #[arg(long)] + family: Option, /// Required tag; repeat the flag to require several (AND) #[arg(long = "tag")] tags: Vec, @@ -291,6 +294,39 @@ enum TextureCommand { /// Required scene use case #[arg(long)] use_case: Option, + /// Required scene in a schema_version 2 profile + #[arg(long)] + scene: Option, + /// Inclusive lower duration bound, in seconds + #[arg(long)] + min_duration: Option, + /// Inclusive upper duration bound, in seconds + #[arg(long)] + max_duration: Option, + /// Inclusive lower intensity bound (0..=1) + #[arg(long)] + min_intensity: Option, + /// Inclusive upper intensity bound (0..=1) + #[arg(long)] + max_intensity: Option, + /// Inclusive lower brightness bound (0..=1) + #[arg(long)] + min_brightness: Option, + /// Inclusive upper brightness bound (0..=1) + #[arg(long)] + max_brightness: Option, + /// Exact tonal character + #[arg(long)] + tonality: Option, + /// Require an explicit seamless-loopability declaration + #[arg(long)] + loopable: Option, + /// Maximum number of results to return (default: all matching sources) + #[arg(long)] + limit: Option, + /// Number of matching sources to skip + #[arg(long, default_value_t = 0)] + offset: usize, }, /// Verify every declared source exists, decodes and is audible Check { @@ -579,14 +615,29 @@ fn raw_instrument_spellings(path: &Path, tracks: usize) -> Vec> { /// Translate CLI selection flags into an exact texture filter, rejecting /// vocabulary that cannot match anything so a typo never silently returns /// "no source exists". -fn build_texture_filter( - source: Option<&str>, - category: Option<&str>, - tags: &[String], - mode: Option<&str>, - use_case: Option<&str>, -) -> Result { - let category = category +struct TextureFilterArgs<'a> { + source: Option<&'a str>, + category: Option<&'a str>, + family: Option<&'a str>, + tags: &'a [String], + mode: Option<&'a str>, + use_case: Option<&'a str>, + scene: Option<&'a str>, + min_duration: Option, + max_duration: Option, + min_intensity: Option, + max_intensity: Option, + min_brightness: Option, + max_brightness: Option, + tonality: Option<&'a str>, + loopable: Option, + limit: Option, + offset: usize, +} + +fn build_texture_filter(args: TextureFilterArgs<'_>) -> Result { + let category = args + .category .map(|key| { texture::Category::parse(key).ok_or_else(|| { invalid_option( @@ -599,12 +650,95 @@ fn build_texture_filter( }) }) .transpose()?; + for (flag, bound) in [ + ("--min-duration", args.min_duration), + ("--max-duration", args.max_duration), + ("--min-intensity", args.min_intensity), + ("--max-intensity", args.max_intensity), + ("--min-brightness", args.min_brightness), + ("--max-brightness", args.max_brightness), + ] { + if bound.is_some_and(|value| !value.is_finite()) { + return Err(invalid_option(flag, "value must be finite".to_owned())); + } + } + for (flag, bound) in [ + ("--min-duration", args.min_duration), + ("--max-duration", args.max_duration), + ] { + if bound.is_some_and(|value| value <= 0.0) { + return Err(invalid_option( + flag, + "value must be greater than 0".to_owned(), + )); + } + } + for (flag, bound) in [ + ("--min-intensity", args.min_intensity), + ("--max-intensity", args.max_intensity), + ("--min-brightness", args.min_brightness), + ("--max-brightness", args.max_brightness), + ] { + if bound.is_some_and(|value| !(0.0..=1.0).contains(&value)) { + return Err(invalid_option(flag, "value must be in 0..=1".to_owned())); + } + } + for (min, max, name) in [ + (args.min_duration, args.max_duration, "duration"), + (args.min_intensity, args.max_intensity, "intensity"), + (args.min_brightness, args.max_brightness, "brightness"), + ] { + if min.zip(max).is_some_and(|(min, max)| min > max) { + return Err(invalid_option( + "--min/--max", + format!("minimum {name} must not exceed maximum"), + )); + } + } + for (flag, value) in [("--family", args.family), ("--tonality", args.tonality)] { + if value.is_some_and(|value| !texture::valid_filter_token(value)) { + return Err(invalid_option( + flag, + "must match [a-z][a-z0-9_-]{0,31}".to_owned(), + )); + } + } + if args + .tags + .iter() + .any(|value| !texture::valid_filter_token(value)) + { + return Err(invalid_option( + "--tag", + "values must match [a-z][a-z0-9_-]{0,31}".to_owned(), + )); + } + for (flag, value) in [("--scene", args.scene), ("--use-case", args.use_case)] { + if value.is_some_and(|value| !texture::valid_filter_token(value)) { + return Err(invalid_option( + flag, + "must match [a-z][a-z0-9_-]{0,31}".to_owned(), + )); + } + } Ok(texture::Filter { - source: source.map(str::to_owned), + source: args.source.map(str::to_owned), category, - tags: tags.to_vec(), - mode: mode.and_then(texture::parse_mode), - use_case: use_case.map(str::to_owned), + family: args.family.map(str::to_owned), + tags: args.tags.to_vec(), + mode: args.mode.and_then(texture::parse_mode), + use_case: args.use_case.map(str::to_owned), + scene: args.scene.map(str::to_owned), + min_duration: args.min_duration, + max_duration: args.max_duration, + min_intensity: args.min_intensity, + max_intensity: args.max_intensity, + min_brightness: args.min_brightness, + max_brightness: args.max_brightness, + tonality: args.tonality.map(str::to_owned), + loopable: args.loopable, + limit: args.limit, + offset: args.offset, }) } @@ -713,19 +847,43 @@ fn run(command: &Command, json: bool) -> Result { profile, source, category, + family, tags, mode, use_case, + scene, + min_duration, + max_duration, + min_intensity, + max_intensity, + min_brightness, + max_brightness, + tonality, + loopable, + limit, + offset, }, } => { let loaded = texture::load_profile(profile)?; - let filter = build_texture_filter( - source.as_deref(), - category.as_deref(), + let filter = build_texture_filter(TextureFilterArgs { + source: source.as_deref(), + category: category.as_deref(), + family: family.as_deref(), tags, - mode.as_deref(), - use_case.as_deref(), - )?; + mode: mode.as_deref(), + use_case: use_case.as_deref(), + scene: scene.as_deref(), + min_duration: *min_duration, + max_duration: *max_duration, + min_intensity: *min_intensity, + max_intensity: *max_intensity, + min_brightness: *min_brightness, + max_brightness: *max_brightness, + tonality: tonality.as_deref(), + loopable: *loopable, + limit: *limit, + offset: *offset, + })?; let report = texture::inspect(&loaded, &texture::profile_dir(profile), &filter)?; // A query nothing satisfies is an answer, not a failure: the // command exists so an agent can establish that no exact diff --git a/src/mcp.rs b/src/mcp.rs index 0923b1f..fd7f4a9 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -8,7 +8,7 @@ //! MCP layer is protocol adaptation only. use crate::error::{Error, Result}; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use std::io::{BufRead, Write}; use std::process::Command; @@ -133,10 +133,11 @@ fn tools() -> Vec { Tool { name: "inspect_textures", description: "Enumerate a texture profile's sources and filter them by exact declared \ - properties (category, tags, playback mode, scene use case). Filters are \ - conjunctive and exact — never a similarity ranking — so an empty result \ - (`status: \"no_match\"`) is a definitive answer that no suitable source \ - exists rather than an invitation to substitute an approximation.", + properties (family, tags, scenes, playback mode, and curated audio \ + descriptors). Filters are conjunctive and exact — never a similarity \ + ranking — so an empty result (`status: \"no_match\"`) is a definitive \ + answer that no suitable source exists rather than an invitation to \ + substitute an approximation.", schema: json!({ "type": "object", "properties": { @@ -147,6 +148,7 @@ fn tools() -> Vec { "enum": crate::texture::Category::keys(), "description": "Sound family" }, + "family": { "type": "string", "description": "Exact v2 source family" }, "tags": { "type": "array", "items": { "type": "string" }, @@ -157,7 +159,18 @@ fn tools() -> Vec { "enum": ["loop", "one_shot"], "description": "Scheduling mode the source must declare support for" }, - "use_case": { "type": "string", "description": "Scene use case the source must declare" } + "use_case": { "type": "string", "description": "Scene use case the source must declare" }, + "scene": { "type": "string", "description": "Exact scene name" }, + "min_duration": { "type": "number", "exclusiveMinimum": 0 }, + "max_duration": { "type": "number", "exclusiveMinimum": 0 }, + "min_intensity": { "type": "number", "minimum": 0, "maximum": 1 }, + "max_intensity": { "type": "number", "minimum": 0, "maximum": 1 }, + "min_brightness": { "type": "number", "minimum": 0, "maximum": 1 }, + "max_brightness": { "type": "number", "minimum": 0, "maximum": 1 }, + "tonality": { "type": "string" }, + "loopable": { "type": "boolean" }, + "limit": { "type": "integer", "minimum": 0 }, + "offset": { "type": "integer", "minimum": 0 } }, "required": ["profile"], "additionalProperties": false @@ -296,14 +309,50 @@ fn tool_argv(name: &str, args: &Value) -> std::result::Result, Strin for (key, flag) in [ ("source", "--source"), ("category", "--category"), + ("family", "--family"), ("mode", "--mode"), ("use_case", "--use-case"), + ("scene", "--scene"), + ("tonality", "--tonality"), ] { if let Some(value) = optional_str(args, key)? { argv.push(flag.into()); argv.push(value.to_owned()); } } + for (key, flag) in [ + ("min_duration", "--min-duration"), + ("max_duration", "--max-duration"), + ("min_intensity", "--min-intensity"), + ("max_intensity", "--max-intensity"), + ("min_brightness", "--min-brightness"), + ("max_brightness", "--max-brightness"), + ] { + if let Some(value) = args.get(key) { + let number = value + .as_f64() + .ok_or_else(|| format!("`{key}` must be a number"))?; + argv.push(flag.into()); + argv.push(number.to_string()); + } + } + if let Some(value) = args.get("loopable") { + let value = value + .as_bool() + .ok_or_else(|| "`loopable` must be a boolean".to_owned())?; + argv.push("--loopable".into()); + argv.push(value.to_string()); + } + for key in ["limit", "offset"] { + if let Some(value) = args.get(key) { + let value = value + .as_u64() + .and_then(|value| usize::try_from(value).ok()) + .ok_or_else(|| format!("`{key}` must be a non-negative integer"))?; + argv.push(format!("--{key}")); + argv.push(value.to_string()); + } + } if let Some(tags) = args.get("tags") { let tags = tags .as_array() diff --git a/src/texture.rs b/src/texture.rs index 2266961..7e341a9 100644 --- a/src/texture.rs +++ b/src/texture.rs @@ -9,17 +9,17 @@ //! able to enumerate the available sources, tell materially different //! candidates apart, and conclude that nothing fits — before it writes //! `textures[].source` into a scene. That only works if every source carries -//! the same metadata, so the descriptive fields are required, not optional: -//! optional metadata makes the contract only as good as the laziest entry. +//! stable metadata. V1 requires a complete descriptive record; v2 requires +//! family, tags, playback, and scenes, with additional audio descriptors +//! available where curators know them. //! //! The split of responsibility is deliberate: //! -//! - **This file declares intent** — path, category, tags, playback -//! constraints, scene use cases, originating library. -//! - **`texture_check` measures physics** — existence, decodability, -//! duration, loudness, checksum. Measured facts are never hand-written -//! here, because a hand-written duration is a fact nothing can verify and -//! everything can outdate. +//! - **This file declares curated intent** — path, family, tags, playback +//! constraints, scenes, and optional descriptive audio properties. +//! - **`texture_check` measures file facts** — existence, decodability, +//! actual duration, loudness, and checksum. Curated audio descriptors help +//! discovery but are not substitutes for measurements of the recording. use crate::error::{Error, Location, Result}; use crate::schema::TextureMode; @@ -31,8 +31,9 @@ use std::collections::BTreeMap; use std::fmt; use std::path::{Path, PathBuf}; -/// Protocol version of the texture-profile format. The only supported value. +/// Protocol version emitted for profiles that omit an explicit version. pub const SCHEMA_VERSION: u16 = 1; +const LATEST_SCHEMA_VERSION: u16 = 2; fn default_schema_version() -> u16 { SCHEMA_VERSION @@ -132,6 +133,9 @@ pub struct Playback { /// The mode to reach for when a scene has no specific reason otherwise. /// Must appear in `modes`. pub default_mode: TextureMode, + /// Whether the recording is known to loop seamlessly. + #[serde(default)] + pub loopable: Option, } /// Where the recording came from. Points at a corpus library identity @@ -153,24 +157,53 @@ pub struct TextureSource { /// or absolute. pub path: String, /// One-line human description of what is actually audible. - pub description: String, + #[serde(default)] + pub description: Option, /// Coarse sound family; the stable axis agents filter on first. - pub category: Category, + #[serde(default)] + pub category: Option, + /// V2 sound family; v1 uses the closed `category` vocabulary. + #[serde(default)] + pub family: Option, /// Free-form descriptors (`[a-z][a-z0-9_-]{0,31}`), 1..=16, no duplicates. pub tags: Vec, /// Declared scheduling constraints. pub playback: Playback, /// Scene intents this source is meant to serve (`forest`, `dungeon`, /// `tension`), same syntax and limits as `tags`. + #[serde(default)] pub use_cases: Vec, + /// V2 scene intents this source is suitable for. + #[serde(default)] + pub scenes: Vec, /// Originating library identity. - pub provenance: Provenance, + #[serde(default)] + pub provenance: Option, + /// Curated audio descriptors. `texture check` independently verifies + /// that the referenced recording exists and decodes. + #[serde(default)] + pub audio: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AudioMetadata { + /// Declared duration in seconds; finite and greater than zero. + #[serde(default)] + pub duration_seconds: Option, + /// Relative intensity in the inclusive range 0..=1. + #[serde(default)] + pub intensity: Option, + /// Relative brightness in the inclusive range 0..=1. + #[serde(default)] + pub brightness: Option, + /// Curated tonal character such as `atonal`, `dark`, or `bright`. + #[serde(default)] + pub tonality: Option, } -/// A source binding accepts the original path-only form for build -/// compatibility, while discovery and certification require the structured -/// form. Keeping both variants in the exported schema makes the v0 protocol -/// additive rather than repurposing the published `sources` values. +/// A source binding accepts original path-only profiles for build and +/// metadata-free discovery, alongside structured source declarations. #[derive(Debug, Clone, Serialize, JsonSchema)] #[serde(untagged)] pub enum TextureSourceBinding { @@ -228,9 +261,9 @@ impl From for TextureSourceBinding { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct TextureProfile { - /// Protocol version. The only supported value is 1. + /// Protocol version. Omitted versions retain the v1 compatibility format. #[serde(default = "default_schema_version")] - #[schemars(range(min = 1, max = 1))] + #[schemars(range(min = 1, max = 2))] pub schema_version: u16, /// Human-readable profile name. pub name: String, @@ -262,6 +295,10 @@ fn valid_token(token: &str) -> bool { }) } +pub fn valid_filter_token(token: &str) -> bool { + valid_token(token) +} + fn fail(path: String, message: String) -> Result { Err(Error::Validation { path, message }) } @@ -313,32 +350,143 @@ fn valid_library_identity(identity: &str) -> bool { .all(|b| b.is_ascii_alphanumeric() || b"._+-".contains(&b)) } +fn edit_distance(left: &str, right: &str) -> usize { + let mut previous: Vec = (0..=right.len()).collect(); + for (i, left_byte) in left.bytes().enumerate() { + let mut current = Vec::with_capacity(right.len() + 1); + current.push(i + 1); + for (j, right_byte) in right.bytes().enumerate() { + current.push( + (previous[j + 1] + 1) + .min(current[j] + 1) + .min(previous[j] + usize::from(left_byte != right_byte)), + ); + } + previous = current; + } + previous[right.len()] +} + impl TextureSource { - fn validate(&self, field: &str) -> Result<()> { + fn validate(&self, field: &str, schema_version: u16) -> Result<()> { if self.path.trim().is_empty() { return fail( format!("{field}.path"), "audio path must not be empty".to_owned(), ); } - if self.description.trim().is_empty() { + if schema_version == 1 + && self + .description + .as_deref() + .is_none_or(|description| description.trim().is_empty()) + { return fail( format!("{field}.description"), "description must not be empty (it is what an agent reads when choosing)" .to_owned(), ); } + if self + .description + .as_deref() + .is_some_and(|description| description.trim().is_empty()) + { + return fail( + format!("{field}.description"), + "description must not be empty".to_owned(), + ); + } validate_tokens(&format!("{field}.tags"), &self.tags)?; - validate_tokens(&format!("{field}.use_cases"), &self.use_cases)?; - if !valid_library_identity(&self.provenance.library) { + if schema_version == 1 { + if self.category.is_none() { + return fail( + format!("{field}.category"), + "field is required in schema_version 1".to_owned(), + ); + } + validate_tokens(&format!("{field}.use_cases"), &self.use_cases)?; + if self.provenance.is_none() { + return fail( + format!("{field}.provenance.library"), + "versioned library provenance is required in schema_version 1".to_owned(), + ); + } + } else { + if self.family.is_none() { + return fail( + format!("{field}.family"), + "field is required in schema_version 2".to_owned(), + ); + } + if self.category.is_some() { + return fail( + format!("{field}.category"), + "use `family` in schema_version 2".to_owned(), + ); + } + if !self.use_cases.is_empty() { + return fail( + format!("{field}.use_cases"), + "use `scenes` in schema_version 2".to_owned(), + ); + } + if let Some(family) = &self.family + && !valid_token(family) + { + return fail( + format!("{field}.family"), + format!("`{family}` must match [a-z][a-z0-9_-]{{0,31}}"), + ); + } + let scenes = if self.scenes.is_empty() { + &self.use_cases + } else { + &self.scenes + }; + validate_tokens(&format!("{field}.scenes"), scenes)?; + } + if let Some(provenance) = &self.provenance + && !valid_library_identity(&provenance.library) + { return fail( format!("{field}.provenance.library"), format!( "`{}` must be a versioned identity matching @", - self.provenance.library + provenance.library ), ); } + if let Some(audio) = &self.audio { + if audio + .duration_seconds + .is_some_and(|duration| !duration.is_finite() || duration <= 0.0) + { + return fail( + format!("{field}.audio.duration_seconds"), + "must be a finite number greater than 0".to_owned(), + ); + } + for (key, value) in [ + ("intensity", audio.intensity), + ("brightness", audio.brightness), + ] { + if value.is_some_and(|value| !value.is_finite() || !(0.0..=1.0).contains(&value)) { + return fail( + format!("{field}.audio.{key}"), + "must be a finite number in the inclusive range 0..=1".to_owned(), + ); + } + } + if let Some(tonality) = &audio.tonality + && !valid_token(tonality) + { + return fail( + format!("{field}.audio.tonality"), + format!("`{tonality}` must match [a-z][a-z0-9_-]{{0,31}}"), + ); + } + } let modes = &self.playback.modes; if modes.is_empty() { return fail( @@ -371,20 +519,49 @@ impl TextureSource { Ok(()) } - pub fn to_json(&self, name: &str, resolved: &Path) -> serde_json::Value { + pub fn to_json(&self, name: &str, resolved: &Path, schema_version: u16) -> serde_json::Value { + if schema_version == 1 { + return json!({ + "source": name, + "path": self.path, + "resolved_path": resolved.display().to_string(), + "exists": resolved.is_file(), + "description": self.description, + "category": self.category.map(Category::key), + "tags": self.tags, + "playback": { + "modes": self.playback.modes.iter().map(|m| mode_key(*m)).collect::>(), + "default_mode": mode_key(self.playback.default_mode), + }, + "use_cases": self.use_cases, + "provenance": self.provenance.as_ref().map(|p| json!({ "library": p.library })), + }); + } + let family = self + .family + .as_deref() + .or_else(|| self.category.map(Category::key)); + let scenes = if self.scenes.is_empty() { + &self.use_cases + } else { + &self.scenes + }; json!({ "source": name, "path": self.path, "resolved_path": resolved.display().to_string(), + "exists": resolved.is_file(), "description": self.description, - "category": self.category.key(), + "family": family, "tags": self.tags, "playback": { "modes": self.playback.modes.iter().map(|m| mode_key(*m)).collect::>(), "default_mode": mode_key(self.playback.default_mode), + "loopable": self.playback.loopable, }, - "use_cases": self.use_cases, - "provenance": { "library": self.provenance.library }, + "scenes": scenes, + "audio": self.audio, + "provenance": self.provenance.as_ref().map(|p| json!({ "library": p.library })), }) } } @@ -397,25 +574,13 @@ impl TextureSourceBinding { } } - fn validate(&self, field: &str) -> Result<()> { + fn validate(&self, field: &str, schema_version: u16) -> Result<()> { match self { Self::LegacyPath(path) if path.trim().is_empty() => { fail(field.to_owned(), "audio path must not be empty".to_owned()) } Self::LegacyPath(_) => Ok(()), - Self::Discoverable(source) => source.validate(field), - } - } - - pub fn discoverable(&self, field: &str) -> Result<&TextureSource> { - match self { - Self::Discoverable(source) => Ok(source), - Self::LegacyPath(_) => fail( - field.to_owned(), - "path-only legacy binding has no discovery metadata; migrate it to a structured \ - source before using `texture inspect` or `texture check`" - .to_owned(), - ), + Self::Discoverable(source) => source.validate(field, schema_version), } } @@ -439,12 +604,12 @@ impl TextureSourceBinding { impl TextureProfile { pub fn validate(&self) -> Result<()> { - if self.schema_version != SCHEMA_VERSION { + if !(SCHEMA_VERSION..=LATEST_SCHEMA_VERSION).contains(&self.schema_version) { return fail( "schema_version".to_owned(), format!( - "{} is unsupported; expected {SCHEMA_VERSION}", - self.schema_version + "{} is unsupported; expected {SCHEMA_VERSION} or {LATEST_SCHEMA_VERSION}", + self.schema_version, ), ); } @@ -467,7 +632,7 @@ impl TextureProfile { format!("`{name}` must match [a-z][a-z0-9_-]{{0,63}} (portable source name)"), ); } - source.validate(&format!("sources.{name}"))?; + source.validate(&format!("sources.{name}"), self.schema_version)?; } Ok(()) } @@ -482,12 +647,42 @@ impl TextureProfile { /// Look up one declared source, or fail naming the exact profile field. pub fn source(&self, name: &str) -> Result<&TextureSourceBinding> { - self.sources.get(name).ok_or_else(|| Error::Validation { - path: format!("texture_profile.sources.{name}"), - message: format!( - "texture profile `{}` has no mapping for source `{name}`", - self.name - ), + self.sources.get(name).ok_or_else(|| { + let mut suggestions: Vec<(&String, usize)> = self + .sources + .keys() + .map(|candidate| (candidate, edit_distance(name, candidate))) + .filter(|(candidate, distance)| { + *distance <= 2 + || candidate.starts_with(name) + || name.starts_with(candidate.as_str()) + }) + .collect(); + suggestions.sort_by(|(left, left_distance), (right, right_distance)| { + left_distance + .cmp(right_distance) + .then_with(|| left.cmp(right)) + }); + let hint = if suggestions.is_empty() { + String::new() + } else { + format!( + "; similar source key(s): {}", + suggestions + .iter() + .take(3) + .map(|(candidate, _)| candidate.as_str()) + .collect::>() + .join(", ") + ) + }; + Error::Validation { + path: format!("texture_profile.sources.{name}"), + message: format!( + "texture profile `{}` has no mapping for source `{name}`{hint}; use `scorekit texture inspect ` to enumerate available keys", + self.name + ), + } }) } @@ -508,45 +703,70 @@ impl TextureProfile { .map(|(name, source)| (name, source, root.join(source.path()))) .collect() } - - pub fn resolved_discoverable_sources<'a>( - &'a self, - profile_dir: &Path, - ) -> Result> { - self.resolved_sources(profile_dir) - .into_iter() - .map(|(name, binding, resolved)| { - binding - .discoverable(&format!("sources.{name}")) - .map(|source| (name, source, resolved)) - }) - .collect() - } } /// Exact, explainable selection criteria. Every populated field must match -/// (AND), and every comparison is equality or set membership — never a -/// similarity score. Ranking candidates by "closeness" would put creative -/// judgement inside the compiler and would hand back a plausible-looking -/// wrong answer exactly when the honest answer is "nothing fits". +/// (AND), and every comparison is exact equality, set membership, or an +/// inclusive numeric bound — never a similarity score. Ranking candidates by +/// "closeness" would put creative judgement inside the compiler and would +/// hand back a plausible-looking wrong answer exactly when the honest answer +/// is "nothing fits". #[derive(Debug, Clone, Default)] pub struct Filter { pub source: Option, pub category: Option, + pub family: Option, pub tags: Vec, pub mode: Option, pub use_case: Option, + pub scene: Option, + pub min_duration: Option, + pub max_duration: Option, + pub min_intensity: Option, + pub max_intensity: Option, + pub min_brightness: Option, + pub max_brightness: Option, + pub tonality: Option, + pub loopable: Option, + pub limit: Option, + pub offset: usize, } impl Filter { + fn has_metadata_constraints(&self) -> bool { + self.category.is_some() + || self.family.is_some() + || !self.tags.is_empty() + || self.mode.is_some() + || self.use_case.is_some() + || self.scene.is_some() + || self.min_duration.is_some() + || self.max_duration.is_some() + || self.min_intensity.is_some() + || self.max_intensity.is_some() + || self.min_brightness.is_some() + || self.max_brightness.is_some() + || self.tonality.is_some() + || self.loopable.is_some() + } + fn matches(&self, name: &str, source: &TextureSource) -> bool { if let Some(wanted) = &self.source && name != wanted { return false; } - if let Some(wanted) = self.category - && source.category != wanted + if let Some(wanted) = self.category { + if source.category != Some(wanted) && source.family.as_deref() != Some(wanted.key()) { + return false; + } + } + if let Some(wanted) = &self.family + && source + .family + .as_deref() + .or_else(|| source.category.map(Category::key)) + != Some(wanted.as_str()) { return false; } @@ -560,6 +780,55 @@ impl Filter { } if let Some(use_case) = &self.use_case && !source.use_cases.contains(use_case) + && !source.scenes.contains(use_case) + { + return false; + } + if let Some(scene) = &self.scene + && !source.scenes.contains(scene) + && !source.use_cases.contains(scene) + { + return false; + } + if let Some(audio) = &source.audio { + for (bound, actual, is_min) in [ + (self.min_duration, audio.duration_seconds, true), + (self.max_duration, audio.duration_seconds, false), + (self.min_intensity, audio.intensity, true), + (self.max_intensity, audio.intensity, false), + (self.min_brightness, audio.brightness, true), + (self.max_brightness, audio.brightness, false), + ] { + if let Some(bound) = bound + && actual.is_none_or(|actual| { + if is_min { + actual < bound + } else { + actual > bound + } + }) + { + return false; + } + } + if let Some(tonality) = &self.tonality + && audio.tonality.as_ref() != Some(tonality) + { + return false; + } + } else if self.min_duration.is_some() + || self.max_duration.is_some() + || self.min_intensity.is_some() + || self.max_intensity.is_some() + || self.min_brightness.is_some() + || self.max_brightness.is_some() + || self.tonality.is_some() + { + return false; + } + if self + .loopable + .is_some_and(|wanted| source.playback.loopable != Some(wanted)) { return false; } @@ -570,9 +839,21 @@ impl Filter { json!({ "source": self.source, "category": self.category.map(Category::key), + "family": self.family, "tags": self.tags, "mode": self.mode.map(mode_key), "use_case": self.use_case, + "scene": self.scene, + "min_duration": self.min_duration, + "max_duration": self.max_duration, + "min_intensity": self.min_intensity, + "max_intensity": self.max_intensity, + "min_brightness": self.min_brightness, + "max_brightness": self.max_brightness, + "tonality": self.tonality, + "loopable": self.loopable, + "limit": self.limit, + "offset": self.offset, }) } } @@ -581,6 +862,7 @@ impl Filter { pub struct InspectReport { pub profile: String, pub total: usize, + pub matching: usize, pub status: &'static str, filter: Filter, matched: Vec, @@ -588,7 +870,7 @@ pub struct InspectReport { impl InspectReport { pub fn matched(&self) -> usize { - self.matched.len() + self.matching } pub fn to_json(&self) -> serde_json::Value { @@ -596,6 +878,9 @@ impl InspectReport { "profile": self.profile, "total": self.total, "matched": self.matched(), + "returned": self.matched.len(), + "offset": self.filter.offset, + "limit": self.filter.limit, "status": self.status, "filters": self.filter.to_json(), "categories": Category::keys(), @@ -604,7 +889,7 @@ impl InspectReport { } pub fn summary(&self) -> String { - if self.matched.is_empty() { + if self.matching == 0 { return format!( "no_match: profile `{}`: 0 of {} source(s) match; no suitable source exists — \ acquire and declare one rather than substituting an approximation", @@ -612,10 +897,12 @@ impl InspectReport { ); } let mut lines = vec![format!( - "ok: profile `{}`: {} of {} source(s) match", + "ok: profile `{}`: {} of {} source(s) match (showing {} from offset {})", self.profile, self.matched(), - self.total + self.total, + self.matched.len(), + self.filter.offset )]; for source in &self.matched { let text = |value: &serde_json::Value| value.as_str().unwrap_or_default().to_owned(); @@ -632,12 +919,23 @@ impl InspectReport { .unwrap_or_default() }; lines.push(format!( - " {} [{}] modes={} tags={} use_cases={} — {}", + " {} [{}] modes={} tags={} scenes={} — {}", text(&source["source"]), - text(&source["category"]), + source["family"] + .as_str() + .or_else(|| source["category"].as_str()) + .unwrap_or_default(), list(&source["playback"]["modes"]), list(&source["tags"]), - list(&source["use_cases"]), + source["scenes"] + .as_array() + .or_else(|| source["use_cases"].as_array()) + .map(|values| values + .iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join(",")) + .unwrap_or_default(), text(&source["description"]), )); } @@ -654,20 +952,46 @@ pub fn inspect( profile_dir: &Path, filter: &Filter, ) -> Result { - let matched: Vec = profile - .resolved_discoverable_sources(profile_dir)? - .into_iter() - .filter(|(name, source, _)| filter.matches(name, source)) - .map(|(name, source, resolved)| source.to_json(name, &resolved)) - .collect(); + let mut matched = Vec::new(); + for (name, binding, resolved) in profile.resolved_sources(profile_dir) { + let entry = match binding { + TextureSourceBinding::Discoverable(source) => { + if !filter.matches(name, source) { + continue; + } + source.to_json(name, &resolved, profile.schema_version) + } + TextureSourceBinding::LegacyPath(_) => { + if filter.has_metadata_constraints() + || filter + .source + .as_deref() + .is_some_and(|wanted| wanted != name) + { + continue; + } + json!({ + "source": name, + "path": binding.path(), + "resolved_path": resolved.display().to_string(), + "exists": resolved.is_file(), + "metadata_available": false, + }) + } + }; + matched.push(entry); + } + let matching = matched.len(); + let start = filter.offset.min(matching); + let end = filter + .limit + .map_or(matching, |limit| start.saturating_add(limit).min(matching)); + let matched = matched.drain(start..end).collect(); Ok(InspectReport { profile: profile.name.clone(), total: profile.sources.len(), - status: if matched.is_empty() { - "no_match" - } else { - "match" - }, + matching, + status: if matching == 0 { "no_match" } else { "match" }, filter: filter.clone(), matched, }) @@ -698,7 +1022,61 @@ pub fn profile_dir(path: &Path) -> PathBuf { pub fn schema_json() -> String { let schema = schemars::schema_for!(TextureProfile); let mut value = serde_json::to_value(schema).expect("schema serializes"); - value["properties"]["schema_version"]["const"] = json!(SCHEMA_VERSION); + value["properties"]["schema_version"]["enum"] = json!([SCHEMA_VERSION, LATEST_SCHEMA_VERSION]); + value["$defs"]["TextureSource"]["required"] = json!([ + "path", + "description", + "category", + "tags", + "playback", + "use_cases", + "provenance", + ]); + let source_properties = value["$defs"]["TextureSource"]["properties"].clone(); + let mut v2_properties = serde_json::Map::new(); + for field in [ + "path", + "family", + "tags", + "playback", + "scenes", + "audio", + "description", + "provenance", + ] { + if let Some(schema) = source_properties.get(field) { + v2_properties.insert(field.to_owned(), schema.clone()); + } + } + value["$defs"]["TextureSourceV2"] = json!({ + "type": "object", + "properties": v2_properties, + "required": ["path", "family", "tags", "playback", "scenes"], + "additionalProperties": false, + }); + let family_schema = &mut value["$defs"]["TextureSourceV2"]["properties"]["family"]; + family_schema["type"] = "string"; + if let Some(properties) = family_schema.as_object_mut() { + properties.remove("anyOf"); + } + value["$defs"]["TextureSourceV2"]["properties"]["family"]["pattern"] = + "^[a-z][a-z0-9_-]{0,31}$"; + value["$defs"]["TextureSourceV2"]["properties"]["scenes"]["items"]["pattern"] = + "^[a-z][a-z0-9_-]{0,31}$"; + value["$defs"]["TextureSourceV2"]["properties"]["scenes"]["minItems"] = 1; + value["$defs"]["TextureSourceV2"]["properties"]["scenes"]["maxItems"] = MAX_TOKENS; + value["$defs"]["TextureSourceV2"]["properties"]["scenes"]["uniqueItems"] = true; + value["$defs"]["AudioMetadata"]["properties"]["duration_seconds"]["exclusiveMinimum"] = true; + value["$defs"]["AudioMetadata"]["properties"]["duration_seconds"]["minimum"] = 0; + for field in ["intensity", "brightness"] { + value["$defs"]["AudioMetadata"]["properties"][field]["minimum"] = 0; + value["$defs"]["AudioMetadata"]["properties"][field]["maximum"] = 1; + } + value["$defs"]["AudioMetadata"]["properties"]["tonality"]["pattern"] = + "^[a-z][a-z0-9_-]{0,31}$"; + if let Some(variants) = value["$defs"]["TextureSourceBinding"]["anyOf"].as_array_mut() { + variants.push(json!({"$ref": "#/$defs/TextureSourceV2"})); + } serde_json::to_string_pretty(&value).expect("schema serializes") } @@ -709,17 +1087,21 @@ mod tests { fn source(path: &str) -> TextureSource { TextureSource { path: path.to_owned(), - description: "Wide river bed".to_owned(), - category: Category::Organic, + description: Some("Wide river bed".to_owned()), + category: Some(Category::Organic), + family: None, tags: vec!["water".to_owned(), "flowing".to_owned()], playback: Playback { modes: vec![TextureMode::Loop], default_mode: TextureMode::Loop, + loopable: None, }, use_cases: vec!["forest".to_owned()], - provenance: Provenance { + scenes: Vec::new(), + provenance: Some(Provenance { library: "vcsl@1.2.2".to_owned(), - }, + }), + audio: None, } } @@ -766,7 +1148,7 @@ mod tests { } #[test] - fn legacy_path_bindings_remain_loadable_but_are_not_discoverable() { + fn legacy_path_bindings_remain_loadable_and_discoverable_without_invented_metadata() { let profile: TextureProfile = serde_yaml_ng::from_str("name: legacy\nsources:\n river: river.wav\n").unwrap(); assert_eq!(profile.schema_version, SCHEMA_VERSION); @@ -775,14 +1157,15 @@ mod tests { profile.resolve(Path::new("/profiles"), "river").unwrap(), Path::new("/profiles/river.wav") ); - let error = inspect(&profile, Path::new("/profiles"), &Filter::default()).unwrap_err(); - assert!(matches!(error, Error::Validation { ref path, .. } if path == "sources.river")); + let report = inspect(&profile, Path::new("/profiles"), &Filter::default()).unwrap(); + assert_eq!(report.matched(), 1); + assert_eq!(report.to_json()["sources"][0]["metadata_available"], false); } #[test] fn rejects_unsupported_schema_version() { let mut profile = profile(vec![("river", source("river.wav"))]); - profile.schema_version = 2; + profile.schema_version = 3; let error = profile.validate().unwrap_err(); assert!(matches!(error, Error::Validation { ref path, .. } if path == "schema_version")); } @@ -793,7 +1176,7 @@ mod tests { let cases: Vec<(&str, Mutate)> = vec![ ( "sources.river.description", - Box::new(|s: &mut TextureSource| s.description = " ".to_owned()), + Box::new(|s: &mut TextureSource| s.description = Some(" ".to_owned())), ), ( "sources.river.tags", @@ -827,11 +1210,15 @@ mod tests { ), ( "sources.river.provenance.library", - Box::new(|s: &mut TextureSource| s.provenance.library = String::new()), + Box::new(|s: &mut TextureSource| { + s.provenance.as_mut().unwrap().library = String::new() + }), ), ( "sources.river.provenance.library", - Box::new(|s: &mut TextureSource| s.provenance.library = "unversioned".to_owned()), + Box::new(|s: &mut TextureSource| { + s.provenance.as_mut().unwrap().library = "unversioned".to_owned() + }), ), ]; for (field, mutate) in cases { @@ -854,7 +1241,7 @@ mod tests { #[test] fn filters_are_exact_and_conjunctive() { let mut grind = source("grind.wav"); - grind.category = Category::Industrial; + grind.category = Some(Category::Industrial); grind.tags = vec!["metal".to_owned(), "grinding".to_owned()]; grind.use_cases = vec!["factory".to_owned()]; let profile = profile(vec![("river", source("river.wav")), ("grind", grind)]); diff --git a/src/texture_check.rs b/src/texture_check.rs index 0f53baf..1da78b0 100644 --- a/src/texture_check.rs +++ b/src/texture_check.rs @@ -14,7 +14,7 @@ //! report. use crate::error::{Error, Result}; -use crate::texture::{self, TextureProfile, mode_key}; +use crate::texture::{self, mode_key, TextureProfile, TextureSource}; use crate::tools; use serde::Serialize; use sha2::{Digest, Sha256}; @@ -29,10 +29,18 @@ pub struct SourceReport { pub source: String, pub path: String, pub status: String, - pub category: String, - pub modes: Vec, - pub default_mode: String, - pub library: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub category: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub family: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub modes: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub loopable: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub library: Option, /// SHA-256 of the **source file** bytes, not of the normalized PCM: it /// identifies the recording itself and stays stable across FFmpeg /// versions, so a stored report doubles as a corpus-drift baseline. @@ -181,20 +189,40 @@ fn sha256_file(path: &Path) -> Result { .collect()) } -fn entry(name: &str, path: &Path, source: &texture::TextureSource, status: &str) -> SourceReport { +fn entry( + name: &str, + path: &Path, + source: Option<&TextureSource>, + schema_version: u16, + status: &str, +) -> SourceReport { SourceReport { source: name.to_owned(), path: path.display().to_string(), status: status.to_owned(), - category: source.category.key().to_owned(), - modes: source - .playback - .modes - .iter() - .map(|mode| mode_key(*mode).to_owned()) - .collect(), - default_mode: mode_key(source.playback.default_mode).to_owned(), - library: source.provenance.library.clone(), + category: source + .and_then(|source| source.category.map(|category| category.key().to_owned())), + family: if schema_version == 2 { + source.and_then(|source| { + source + .family + .clone() + .or_else(|| source.category.map(|category| category.key().to_owned())) + }) + } else { + None + }, + modes: source.map(|source| { + source + .playback + .modes + .iter() + .map(|mode| mode_key(*mode).to_owned()) + .collect() + }), + default_mode: source.map(|source| mode_key(source.playback.default_mode).to_owned()), + loopable: source.and_then(|source| source.playback.loopable), + library: source.and_then(|source| source.provenance.as_ref().map(|p| p.library.clone())), sha256: None, duration_seconds: None, frames: None, @@ -210,12 +238,16 @@ pub fn check(profile_path: &Path, sample_rate: u32) -> Result { let scratch = Scratch::create()?; let mut entries = Vec::with_capacity(profile.sources.len()); - for (index, (name, source, resolved)) in profile - .resolved_discoverable_sources(&profile_dir)? + for (index, (name, binding, resolved)) in profile + .resolved_sources(&profile_dir) .into_iter() .enumerate() { - let mut report = entry(name, &resolved, source, "ok"); + let source = match binding { + texture::TextureSourceBinding::LegacyPath(_) => None, + texture::TextureSourceBinding::Discoverable(source) => Some(source), + }; + let mut report = entry(name, &resolved, source, profile.schema_version, "ok"); if !resolved.is_file() { report.status = "missing".to_owned(); report.error = Some(format!( diff --git a/tests/cli.rs b/tests/cli.rs index 3bc7395..5693a40 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -2564,6 +2564,42 @@ fn build_missing_texture_source_leaves_no_partial_artifact() { assert_dir_contains_exactly(dir.path(), &["scene.yaml", "textures.yaml"]); } +#[test] +fn build_unknown_texture_source_suggests_a_profile_key() { + let dir = tempfile::tempdir().unwrap(); + write_texture_wave(&dir.path().join("river.wav"), 180.0, 0.5); + let profile = dir.path().join("textures.yaml"); + fs::write(&profile, river_birds_profile()).unwrap(); + let scene = dir.path().join("scene.yaml"); + fs::write( + &scene, + "tempo: 120\nbars: 1\ntextures:\n - { source: rivr, mode: loop }\n\ + tracks:\n - { id: piano, instrument: piano, pattern: sustain }\n", + ) + .unwrap(); + + let out = bin() + .args(["--json", "build"]) + .arg(&scene) + .arg("--soundfont") + .arg(sf2()) + .arg("--texture-profile") + .arg(&profile) + .arg("-o") + .arg(dir.path().join("out").join("scene.wav")) + .assert() + .failure() + .code(2); + let error: serde_json::Value = serde_json::from_slice(&out.get_output().stderr).unwrap(); + assert_eq!(error["field"], "texture_profile.sources.rivr"); + assert!(error["message"].as_str().unwrap().contains("river")); + assert!(error["message"] + .as_str() + .unwrap() + .contains("scorekit texture inspect")); + assert!(!dir.path().join("out").exists()); +} + #[test] fn suite_failure_rolls_back_all_previously_built_sections() { let dir = tempfile::tempdir().unwrap(); @@ -5945,7 +5981,21 @@ fn mcp_exposes_texture_inspect_and_check() { let tools = replies[0]["result"]["tools"].as_array().unwrap(); let find = |name: &str| tools.iter().find(|tool| tool["name"] == name).unwrap(); let inspect = find("inspect_textures"); - for arg in ["profile", "category", "tags", "mode", "use_case"] { + for arg in [ + "profile", + "category", + "family", + "tags", + "mode", + "use_case", + "scene", + "min_duration", + "min_intensity", + "min_brightness", + "loopable", + "limit", + "offset", + ] { assert!( inspect["inputSchema"]["properties"][arg].is_object(), "inspect_textures must accept {arg}: {inspect}" @@ -6010,7 +6060,10 @@ fn texture_profile_schema_publishes_required_discovery_metadata() { .success(); let schema: serde_json::Value = serde_json::from_slice(&out.get_output().stdout).unwrap(); - assert_eq!(schema["properties"]["schema_version"]["const"], 1); + assert_eq!( + schema["properties"]["schema_version"]["enum"], + serde_json::json!([1, 2]) + ); assert_eq!( schema["properties"]["sources"]["additionalProperties"]["$ref"], "#/$defs/TextureSourceBinding" @@ -6078,10 +6131,8 @@ fn texture_profile_schema_publishes_required_discovery_metadata() { ); } - // Physics is measured by `texture check`, never declared here: a - // hand-written duration is a fact nothing can verify and every re-export - // silently invalidates. - for measured in ["duration_seconds", "sample_rate", "channels", "peak"] { + // Physical file measurements remain the responsibility of `texture check`. + for measured in ["sample_rate", "channels", "peak"] { assert!( source["properties"][measured].is_null(), "{measured} is measured, not declared: {source}" @@ -6242,15 +6293,26 @@ fn texture_inspect_filters_exactly_and_admits_no_match() { ); } -/// Existing path-only profiles remain valid for the build behavior published -/// before source discovery existed. Discovery refuses to invent metadata for -/// them, so compatibility does not turn into a plausible but dishonest catalog. +/// Existing path-only profiles remain valid for build and can be enumerated +/// without inventing semantic metadata. #[test] -fn legacy_texture_profile_builds_but_discovery_requires_metadata() { +fn legacy_texture_profile_builds_and_discovery_marks_metadata_unknown() { let dir = tempfile::tempdir().unwrap(); write_texture_wave(&dir.path().join("river.wav"), 180.0, 0.5); + write_texture_wave(&dir.path().join("creek.wav"), 260.0, 0.4); let profile = dir.path().join("textures.yaml"); - fs::write(&profile, "name: legacy\nsources:\n river: river.wav\n").unwrap(); + let sources = format!( + " river: river.wav\n{}", + texture_source_yaml( + "creek", + "creek.wav", + "organic", + &["water", "flowing"], + &["loop"], + &["forest"], + ) + ); + fs::write(&profile, texture_profile_yaml("mixed", &sources)).unwrap(); let scene = dir.path().join("scene.yaml"); fs::write( &scene, @@ -6275,17 +6337,31 @@ fn legacy_texture_profile_builds_but_discovery_requires_metadata() { .args(["--json", "texture", "inspect"]) .arg(&profile) .assert() - .failure() - .code(2); - let error: serde_json::Value = serde_json::from_slice(&out.get_output().stderr).unwrap(); - assert_eq!(error["code"], "validation"); - assert_eq!(error["field"], "sources.river"); - assert!( - error["message"] - .as_str() - .unwrap() - .contains("legacy binding") - ); + .success(); + let report: serde_json::Value = serde_json::from_slice(&out.get_output().stdout).unwrap(); + assert_eq!(report["status"], "match"); + assert_eq!(report["total"], 2); + let sources = report["sources"].as_array().unwrap(); + let legacy = sources + .iter() + .find(|source| source["source"] == "river") + .unwrap(); + assert_eq!(legacy["exists"], true); + assert_eq!(legacy["metadata_available"], false); + assert_eq!(sources[0]["source"], "creek"); + + let check = bin() + .args(["--json", "texture", "check"]) + .arg(&profile) + .assert() + .success(); + let report: serde_json::Value = serde_json::from_slice(&check.get_output().stdout).unwrap(); + assert_eq!(report["sources"], 2); + assert!(report["entries"] + .as_array() + .unwrap() + .iter() + .all(|entry| { entry["status"] == "ok" })); } /// Structured profiles must carry complete discovery metadata. The additive @@ -6440,17 +6516,16 @@ fn texture_check_measures_physics_of_declared_sources() { assert_eq!(report, repeat, "check must be deterministic"); } -/// Certification must fail loudly on missing and silent sources — a silent -/// file is the failure mode that survives every structural check and only -/// surfaces as a missing layer in the finished mix — and must leave no -/// scratch residue behind when it does. +/// Certification must fail loudly on missing, silent, and undecodable +/// sources, and must leave no scratch residue behind when it does. #[test] -fn texture_check_rejects_missing_and_silent_sources_without_residue() { +fn texture_check_rejects_missing_silent_and_undecodable_sources_without_residue() { let dir = tempfile::tempdir().unwrap(); write_texture_wave(&dir.path().join("good.wav"), 220.0, 0.3); write_const_wav(&dir.path().join("silent.wav"), 0, 6615); + fs::write(dir.path().join("corrupt.wav"), "not an audio file").unwrap(); let sources = format!( - "{}{}{}", + "{}{}{}{}", texture_source_yaml("good", "good.wav", "tonal", &["tone"], &["loop"], &["test"]), texture_source_yaml( "gone", @@ -6468,6 +6543,14 @@ fn texture_check_rejects_missing_and_silent_sources_without_residue() { &["loop"], &["test"] ), + texture_source_yaml( + "corrupt", + "corrupt.wav", + "sound_design", + &["broken"], + &["one_shot"], + &["test"] + ), ); let profile = dir.path().join("textures.yaml"); fs::write(&profile, texture_profile_yaml("broken", &sources)).unwrap(); @@ -6479,12 +6562,12 @@ fn texture_check_rejects_missing_and_silent_sources_without_residue() { .env("SCOREKIT_TMPDIR", &scratch) .assert() .failure() - .code(2); + .code(4); let err: serde_json::Value = serde_json::from_slice(&out.get_output().stderr).unwrap(); assert_eq!(err["code"], "texture_check"); let report = &err["report"]; assert_eq!(report["passed"], 1); - assert_eq!(report["failed"], 2); + assert_eq!(report["failed"], 3); let status = |name: &str| -> String { report["entries"] .as_array() @@ -6499,6 +6582,7 @@ fn texture_check_rejects_missing_and_silent_sources_without_residue() { assert_eq!(status("good"), "ok"); assert_eq!(status("gone"), "missing"); assert_eq!(status("silent"), "silent"); + assert_eq!(status("corrupt"), "undecodable"); // The scratch root is honored and swept, so a corpus-sized check cannot // accumulate normalized copies of every source on failure. @@ -6616,3 +6700,208 @@ fn mcp_tool_failure_passes_structured_error_through() { assert_eq!(replies[1]["error"]["code"], -32602); assert_eq!(replies[2]["error"]["code"], -32601); } + +#[test] +fn texture_v2_sources_are_searchable_paginated_and_certifiable() { + let dir = tempfile::tempdir().unwrap(); + write_texture_wave(&dir.path().join("chain.wav"), 210.0, 0.4); + write_texture_wave(&dir.path().join("machine.wav"), 110.0, 0.4); + let profile = dir.path().join("textures.yaml"); + fs::write( + &profile, + r#"schema_version: 2 +name: industrial +root: . +sources: + chain_grind: + path: chain.wav + family: industrial + tags: [chain, metal, grinding] + playback: + modes: [loop] + default_mode: loop + loopable: true + audio: + duration_seconds: 0.4 + intensity: 0.65 + brightness: 0.35 + tonality: atonal + scenes: [factory, machinery] + machine_grind: + path: machine.wav + family: industrial + tags: [machine, metal, grinding] + playback: + modes: [loop] + default_mode: loop + loopable: true + audio: + duration_seconds: 0.4 + intensity: 0.68 + brightness: 0.3 + tonality: atonal + scenes: [factory, machinery] +"#, + ) + .unwrap(); + + let schema = bin() + .args(["--json", "schema", "--texture-profile"]) + .assert() + .success(); + let schema: serde_json::Value = serde_json::from_slice(&schema.get_output().stdout).unwrap(); + assert!( + schema["properties"]["schema_version"]["enum"] + .as_array() + .is_some_and(|versions| versions.iter().any(|version| version == 2)), + "the exported schema must publish v2: {schema}" + ); + let v2_schema = &schema["$defs"]["TextureSourceV2"]; + assert_eq!(v2_schema["additionalProperties"], false); + let required: Vec<&str> = v2_schema["required"] + .as_array() + .unwrap() + .iter() + .map(|field| field.as_str().unwrap()) + .collect(); + for field in ["path", "family", "tags", "playback", "scenes"] { + assert!( + required.contains(&field), + "missing {field} from {v2_schema}" + ); + } + let source_schema = &schema["$defs"]["TextureSource"]; + assert!(source_schema["properties"]["family"].is_object()); + assert!(source_schema["properties"]["scenes"].is_object()); + assert!(schema["$defs"]["AudioMetadata"]["properties"]["brightness"].is_object()); + assert_eq!( + schema["$defs"]["AudioMetadata"]["properties"]["intensity"]["maximum"], + 1 + ); + assert!(schema["$defs"]["Playback"]["properties"]["loopable"].is_object()); + + let out = bin() + .args([ + "--json", + "texture", + "inspect", + profile.to_str().unwrap(), + "--family", + "industrial", + "--tag", + "metal", + "--scene", + "factory", + "--mode", + "loop", + "--min-duration", + "0.3", + "--max-duration", + "0.5", + "--min-intensity", + "0.6", + "--max-intensity", + "0.7", + "--limit", + "1", + "--offset", + "1", + ]) + .assert() + .success(); + let report: serde_json::Value = serde_json::from_slice(&out.get_output().stdout).unwrap(); + assert_eq!(report["status"], "match"); + assert_eq!(report["total"], 2); + assert_eq!(report["matched"], 2); + assert_eq!(report["returned"], 1); + assert_eq!(report["offset"], 1); + assert_eq!(report["sources"][0]["source"], "machine_grind"); + assert_eq!(report["sources"][0]["family"], "industrial"); + assert_eq!(report["sources"][0]["scenes"][0], "factory"); + assert_eq!(report["sources"][0]["audio"]["duration_seconds"], 0.4); + assert_eq!(report["sources"][0]["audio"]["intensity"], 0.68); + assert_eq!(report["sources"][0]["audio"]["tonality"], "atonal"); + assert_eq!(report["sources"][0]["playback"]["loopable"], true); + assert_eq!(report["sources"][0]["exists"], true); + + let no_match = bin() + .args([ + "--json", + "texture", + "inspect", + profile.to_str().unwrap(), + "--tag", + "glass", + ]) + .assert() + .success(); + let no_match: serde_json::Value = + serde_json::from_slice(&no_match.get_output().stdout).unwrap(); + assert_eq!(no_match["status"], "no_match"); + assert_eq!(no_match["matched"], 0); + + bin() + .args(["--json", "texture", "check"]) + .arg(profile) + .assert() + .success(); +} + +#[test] +fn texture_v2_rejects_invalid_audio_and_playback_metadata() { + let dir = tempfile::tempdir().unwrap(); + let profile = dir.path().join("textures.yaml"); + fs::write( + &profile, + r#"schema_version: 2 +name: invalid +sources: + chain: + path: chain.wav + family: industrial + tags: [chain] + playback: + modes: [loop] + default_mode: loop + audio: + intensity: 1.01 + scenes: [factory] +"#, + ) + .unwrap(); + + let out = bin() + .args(["--json", "texture", "inspect"]) + .arg(profile) + .assert() + .failure() + .code(2); + let error: serde_json::Value = serde_json::from_slice(&out.get_output().stderr).unwrap(); + assert_eq!(error["code"], "validation"); + assert_eq!(error["field"], "sources.chain.audio.intensity"); + + fs::write( + &profile, + r#"schema_version: 2 +name: invalid +sources: + chain: + path: chain.wav + family: industrial + tags: [chain] + playback: + modes: [loop] + default_mode: one_shot + scenes: [factory] +"#, + ) + .unwrap(); + let out = bin() + .args(["--json", "texture", "inspect"]) + .arg(profile) + .assert() + .failure() + .code(2); + let error: serde_json::Value = serde_json::from_slice(&out.get_output().stderr).unwrap(); + assert_eq!(error["field"], "sources.chain.playback.default_mode"); +} From d72c2df09f1690b245bf04fe1974458d95ecf579 Mon Sep 17 00:00:00 2001 From: Mira Voss Date: Sat, 3 Oct 2026 12:13:19 +0800 Subject: [PATCH 2/6] style: satisfy Rust 2024 formatting checks Apply current stable rustfmt import ordering and assertion layout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/mcp.rs | 2 +- src/texture_check.rs | 2 +- tests/cli.rs | 22 +++++++++++++--------- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/mcp.rs b/src/mcp.rs index fd7f4a9..9e7f7d0 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -8,7 +8,7 @@ //! MCP layer is protocol adaptation only. use crate::error::{Error, Result}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use std::io::{BufRead, Write}; use std::process::Command; diff --git a/src/texture_check.rs b/src/texture_check.rs index 1da78b0..3c00776 100644 --- a/src/texture_check.rs +++ b/src/texture_check.rs @@ -14,7 +14,7 @@ //! report. use crate::error::{Error, Result}; -use crate::texture::{self, mode_key, TextureProfile, TextureSource}; +use crate::texture::{self, TextureProfile, TextureSource, mode_key}; use crate::tools; use serde::Serialize; use sha2::{Digest, Sha256}; diff --git a/tests/cli.rs b/tests/cli.rs index 5693a40..d9e1b62 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -2593,10 +2593,12 @@ fn build_unknown_texture_source_suggests_a_profile_key() { let error: serde_json::Value = serde_json::from_slice(&out.get_output().stderr).unwrap(); assert_eq!(error["field"], "texture_profile.sources.rivr"); assert!(error["message"].as_str().unwrap().contains("river")); - assert!(error["message"] - .as_str() - .unwrap() - .contains("scorekit texture inspect")); + assert!( + error["message"] + .as_str() + .unwrap() + .contains("scorekit texture inspect") + ); assert!(!dir.path().join("out").exists()); } @@ -6357,11 +6359,13 @@ fn legacy_texture_profile_builds_and_discovery_marks_metadata_unknown() { .success(); let report: serde_json::Value = serde_json::from_slice(&check.get_output().stdout).unwrap(); assert_eq!(report["sources"], 2); - assert!(report["entries"] - .as_array() - .unwrap() - .iter() - .all(|entry| { entry["status"] == "ok" })); + assert!( + report["entries"] + .as_array() + .unwrap() + .iter() + .all(|entry| { entry["status"] == "ok" }) + ); } /// Structured profiles must carry complete discovery metadata. The additive From b73e5518eb1961f24d1a5a6085e9fc38b69a3056 Mon Sep 17 00:00:00 2001 From: Mira Voss Date: Sat, 3 Oct 2026 12:17:40 +0800 Subject: [PATCH 3/6] fix: convert schema values to JSON Use explicit serde_json value conversions and keep a profile path borrowed across validation assertions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/texture.rs | 23 ++++++++++++----------- tests/cli.rs | 2 +- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/texture.rs b/src/texture.rs index 7e341a9..c1fe6ef 100644 --- a/src/texture.rs +++ b/src/texture.rs @@ -1055,25 +1055,26 @@ pub fn schema_json() -> String { "additionalProperties": false, }); let family_schema = &mut value["$defs"]["TextureSourceV2"]["properties"]["family"]; - family_schema["type"] = "string"; + family_schema["type"] = "string".into(); if let Some(properties) = family_schema.as_object_mut() { properties.remove("anyOf"); } value["$defs"]["TextureSourceV2"]["properties"]["family"]["pattern"] = - "^[a-z][a-z0-9_-]{0,31}$"; + "^[a-z][a-z0-9_-]{0,31}$".into(); value["$defs"]["TextureSourceV2"]["properties"]["scenes"]["items"]["pattern"] = - "^[a-z][a-z0-9_-]{0,31}$"; - value["$defs"]["TextureSourceV2"]["properties"]["scenes"]["minItems"] = 1; - value["$defs"]["TextureSourceV2"]["properties"]["scenes"]["maxItems"] = MAX_TOKENS; - value["$defs"]["TextureSourceV2"]["properties"]["scenes"]["uniqueItems"] = true; - value["$defs"]["AudioMetadata"]["properties"]["duration_seconds"]["exclusiveMinimum"] = true; - value["$defs"]["AudioMetadata"]["properties"]["duration_seconds"]["minimum"] = 0; + "^[a-z][a-z0-9_-]{0,31}$".into(); + value["$defs"]["TextureSourceV2"]["properties"]["scenes"]["minItems"] = 1.into(); + value["$defs"]["TextureSourceV2"]["properties"]["scenes"]["maxItems"] = MAX_TOKENS.into(); + value["$defs"]["TextureSourceV2"]["properties"]["scenes"]["uniqueItems"] = true.into(); + value["$defs"]["AudioMetadata"]["properties"]["duration_seconds"]["exclusiveMinimum"] = + true.into(); + value["$defs"]["AudioMetadata"]["properties"]["duration_seconds"]["minimum"] = 0.into(); for field in ["intensity", "brightness"] { - value["$defs"]["AudioMetadata"]["properties"][field]["minimum"] = 0; - value["$defs"]["AudioMetadata"]["properties"][field]["maximum"] = 1; + value["$defs"]["AudioMetadata"]["properties"][field]["minimum"] = 0.into(); + value["$defs"]["AudioMetadata"]["properties"][field]["maximum"] = 1.into(); } value["$defs"]["AudioMetadata"]["properties"]["tonality"]["pattern"] = - "^[a-z][a-z0-9_-]{0,31}$"; + "^[a-z][a-z0-9_-]{0,31}$".into(); if let Some(variants) = value["$defs"]["TextureSourceBinding"]["anyOf"].as_array_mut() { variants.push(json!({"$ref": "#/$defs/TextureSourceV2"})); } diff --git a/tests/cli.rs b/tests/cli.rs index d9e1b62..c2793f8 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -6876,7 +6876,7 @@ sources: let out = bin() .args(["--json", "texture", "inspect"]) - .arg(profile) + .arg(&profile) .assert() .failure() .code(2); From 65a909b5bb7f5d742a8d3d7f13fa1c1f37183e34 Mon Sep 17 00:00:00 2001 From: Mira Voss Date: Sat, 3 Oct 2026 12:21:09 +0800 Subject: [PATCH 4/6] fix: satisfy strict Clippy for texture filters Box structured source metadata and collapse the category filter guard. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/main.rs | 1 + src/texture.rs | 17 +++++++++-------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/main.rs b/src/main.rs index 1b4d4a8..fc93d90 100644 --- a/src/main.rs +++ b/src/main.rs @@ -272,6 +272,7 @@ enum OrchestrationCommand { } #[derive(Subcommand)] +#[allow(clippy::large_enum_variant)] enum TextureCommand { /// Enumerate texture sources, filtered by exact declared properties Inspect { diff --git a/src/texture.rs b/src/texture.rs index c1fe6ef..ecbfdb4 100644 --- a/src/texture.rs +++ b/src/texture.rs @@ -208,7 +208,7 @@ pub struct AudioMetadata { #[serde(untagged)] pub enum TextureSourceBinding { LegacyPath(String), - Discoverable(TextureSource), + Discoverable(Box), } impl<'de> Deserialize<'de> for TextureSourceBinding { @@ -244,7 +244,7 @@ impl<'de> Deserialize<'de> for TextureSourceBinding { M: MapAccess<'de>, { TextureSource::deserialize(de::value::MapAccessDeserializer::new(map)) - .map(TextureSourceBinding::Discoverable) + .map(|source| TextureSourceBinding::Discoverable(Box::new(source))) } } @@ -254,7 +254,7 @@ impl<'de> Deserialize<'de> for TextureSourceBinding { impl From for TextureSourceBinding { fn from(source: TextureSource) -> Self { - Self::Discoverable(source) + Self::Discoverable(Box::new(source)) } } @@ -756,10 +756,11 @@ impl Filter { { return false; } - if let Some(wanted) = self.category { - if source.category != Some(wanted) && source.family.as_deref() != Some(wanted.key()) { - return false; - } + if let Some(wanted) = self.category + && source.category != Some(wanted) + && source.family.as_deref() != Some(wanted.key()) + { + return false; } if let Some(wanted) = &self.family && source @@ -1114,7 +1115,7 @@ mod tests { root: Some("audio".to_owned()), sources: sources .into_iter() - .map(|(name, source)| (name.to_owned(), TextureSourceBinding::Discoverable(source))) + .map(|(name, source)| (name.to_owned(), TextureSourceBinding::from(source))) .collect(), } } From a544c612472a37e6cf2813e0bb9d8c456845ede0 Mon Sep 17 00:00:00 2001 From: Mira Voss Date: Sat, 3 Oct 2026 12:24:44 +0800 Subject: [PATCH 5/6] fix: dereference boxed texture source metadata Pass the structured source reference to texture check serialization. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/texture_check.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/texture_check.rs b/src/texture_check.rs index 3c00776..8a43413 100644 --- a/src/texture_check.rs +++ b/src/texture_check.rs @@ -247,7 +247,13 @@ pub fn check(profile_path: &Path, sample_rate: u32) -> Result { texture::TextureSourceBinding::LegacyPath(_) => None, texture::TextureSourceBinding::Discoverable(source) => Some(source), }; - let mut report = entry(name, &resolved, source, profile.schema_version, "ok"); + let mut report = entry( + name, + &resolved, + source.as_deref(), + profile.schema_version, + "ok", + ); if !resolved.is_file() { report.status = "missing".to_owned(); report.error = Some(format!( From 88703d295042e5b5beaafbe4bc075c60cb7585f1 Mon Sep 17 00:00:00 2001 From: Mira Voss Date: Sat, 3 Oct 2026 12:28:12 +0800 Subject: [PATCH 6/6] fix: convert boxed source reference for reporting Map the borrowed boxed source to the metadata reference expected by texture checks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/texture_check.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/texture_check.rs b/src/texture_check.rs index 8a43413..2378996 100644 --- a/src/texture_check.rs +++ b/src/texture_check.rs @@ -250,7 +250,7 @@ pub fn check(profile_path: &Path, sample_rate: u32) -> Result { let mut report = entry( name, &resolved, - source.as_deref(), + source.map(|source| source.as_ref()), profile.schema_version, "ok", );