diff --git a/docs/ipa-vocal-tract-roadmap.md b/docs/ipa-vocal-tract-roadmap.md index bc3b7f3..4e74511 100644 --- a/docs/ipa-vocal-tract-roadmap.md +++ b/docs/ipa-vocal-tract-roadmap.md @@ -692,6 +692,15 @@ store evidence before querying. The outer trial loop runs `index` at round boundaries and passes `--skip-index true` to agent searches so each agent query uses fresh vectors without re-embedding the store. +Retrieval tuning treats phonetic class as evidence, not decoration. Search +reports expose `class_focus` for the query and `class_match` for each hit. The +ranker boosts candidate records that match the requested class, demotes +off-class records that only match generic failure language, and treats +`mixed/generalization` as a secondary transfer hint when a concrete class such +as stop, nasal, fricative, or vowel is also present. This keeps `mix-p` useful +for plosive seams without letting every mixed candidate impersonate stop +evidence. + `tools/run-ipa-trial-loop.ps1` orchestrates the outer loop with external `codex exec` workers. Each round searches accumulated trial memory, asks a hypothesis worker to write a batch of candidates, scores those candidates diff --git a/state/evidence.jsonl b/state/evidence.jsonl index f988d16..aae8123 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -242,3 +242,4 @@ {"date":"2026-05-29","area":"ipa-trial-evidence-quality","lesson":"The IPA exploration loop should optimize evidence quality, not just prompt compliance. Hypothesizers must publish specificity/comparability/falsifiability/reuse ledgers per family, and evaluators must penalize vague receipts even when candidate metrics look better."} {"date":"2026-05-29","area":"ipa-trial-prompt-optimization","lesson":"Five prompt optimization passes showed that live external-agent retrieval is too slow and noisy for science unless tightly bounded. The sharpest hypothesizer smoke used a fixed six-receipt packet and a reasoning-only boundary, then scored evidence by contrast pairs, concrete show metrics, primitive timeline excerpts, class-consistent references, owner sentences, micro-sweep plans, and novelty gates. Production prompts now require those witnesses before treating a DSL seam as actionable."} {"date":"2026-05-30","area":"ipa-trial-vector-retrieval","lesson":"The IPA trial semantic retrieval organ now uses the local full-ass stack: CultCache `.cc` records remain truth, while `IpaTrialWorker index/search` derives Qdrant points with stable SHA-256 UUIDs and Ollama `qwen3-embedding:0.6b` vectors. Search reports include vector scores, metric facts, evidence tags, contrast candidates, artifact snippets, and show commands, with lexical fallback only when the vector stack is unavailable.","evidence":"tools/IpaTrialWorker/Program.cs; artifacts/parity/ipa-trial-worker-smoke/20260529T2204/qdrant-index-smoke.md; artifacts/parity/ipa-trial-worker-smoke/20260529T2204/qdrant-search-stops.md"} +{"date":"2026-05-30","area":"ipa-trial-retrieval-tuning","lesson":"Semantic retrieval smokes showed raw vector similarity was useful but too permissive: class queries could leak through generic weak-articulation language, and mixed-set records could impersonate every class because their target set contains all phonemes. `IpaTrialWorker search` now adds phonetic class-affinity scoring, candidate-specific class ownership for mixed lanes, demotes mixed/generalization to a secondary transfer hint when a real class is present, and only tags dressing when the record itself mentions dressing/FM/AM/envelope/helper evidence.","evidence":"artifacts/parity/ipa-trial-worker-smoke/20260529T2204/retrieval-tuning-20260530-pass1; artifacts/parity/ipa-trial-worker-smoke/20260529T2204/retrieval-tuning-20260530-pass4; tools/IpaTrialWorker/Program.cs"} diff --git a/state/memory.json b/state/memory.json index 7fdd3e4..647c60d 100644 --- a/state/memory.json +++ b/state/memory.json @@ -92,7 +92,7 @@ "Surge XT for modern hybrid synth modulation", "Cardinal/VCV-style patches for modular cable/rate semantics" ], - "rule": "For speech, add schema fields and learned-control lanes only when the parity harness has a loss surface that can exercise them. For reference synths, add DSL abstractions only when rebuilds prove the missing invariant. Parsing alone is inventory, not evidence of expressivity. Pink Trombone should be decomposed into reusable low-level Aqua primitives before audio golf, not copied as a bespoke clone or inflated into PT-shaped modules. After the 2026-05-29 proprioception correction, collapse graph-vocal lowering into a small set of instrumented physical primitives before more utterance golf. Gesture motion belongs in ControlSurface/ControlSpline timeline ownership, not primitive-local smoothing; cut any generated contact/port spline that invents motion not supported by primitive reference timelines. Public clients author gestures through ControlSurfaceCatalog plus ControlSplineTimeline and stream surface control dictionaries; higher-level gesture templates and phoneme_gesture are only spline emitters. IPA descriptors seed which anatomical surfaces move, while local datasets golf numeric spline defaults and patch voice code. Training reports must separate gesture_score, clean_vocal_score, and full_parity_score so full-patch recording-condition helpers cannot impersonate articulation. Frozen IPA gesture rounds own candidate scripts, primitive timelines, gesture metrics, manifests, evidence receipts, metric summaries, candidate clusters, and science briefs; `IpaTrialResult` CultCache records own rendered trial outcomes. External Codex IPA exploration must query the `.cc` store through the worker's semantic `search`/`show` organ, backed by Qdrant/Ollama vector retrieval when available; Qdrant is a derived index over CultCache and local artifacts, not a second truth. Crude full-store dumps are audit fallback, not agent memory. Statistical science workers and audio parity stages consume those bundles without rewriting gesture evidence. If gesture scores rise but rendered audio is silent, fix the primitive source/tract owner before adding full-patch dressing." + "rule": "For speech, add schema fields and learned-control lanes only when the parity harness has a loss surface that can exercise them. For reference synths, add DSL abstractions only when rebuilds prove the missing invariant. Parsing alone is inventory, not evidence of expressivity. Pink Trombone should be decomposed into reusable low-level Aqua primitives before audio golf, not copied as a bespoke clone or inflated into PT-shaped modules. After the 2026-05-29 proprioception correction, collapse graph-vocal lowering into a small set of instrumented physical primitives before more utterance golf. Gesture motion belongs in ControlSurface/ControlSpline timeline ownership, not primitive-local smoothing; cut any generated contact/port spline that invents motion not supported by primitive reference timelines. Public clients author gestures through ControlSurfaceCatalog plus ControlSplineTimeline and stream surface control dictionaries; higher-level gesture templates and phoneme_gesture are only spline emitters. IPA descriptors seed which anatomical surfaces move, while local datasets golf numeric spline defaults and patch voice code. Training reports must separate gesture_score, clean_vocal_score, and full_parity_score so full-patch recording-condition helpers cannot impersonate articulation. Frozen IPA gesture rounds own candidate scripts, primitive timelines, gesture metrics, manifests, evidence receipts, metric summaries, candidate clusters, and science briefs; `IpaTrialResult` CultCache records own rendered trial outcomes. External Codex IPA exploration must query the `.cc` store through the worker's semantic `search`/`show` organ, backed by Qdrant/Ollama vector retrieval when available; Qdrant is a derived index over CultCache and local artifacts, not a second truth. Search reports expose class focus/match and use phonetic class-affinity so agents do not treat generic weak-articulation language as cross-class evidence. Crude full-store dumps are audit fallback, not agent memory. Statistical science workers and audio parity stages consume those bundles without rewriting gesture evidence. If gesture scores rise but rendered audio is silent, fix the primitive source/tract owner before adding full-patch dressing." }, "ipa_vocal_tract_path": { "status": "This is now the active project center. Roadmap and research notes align the curriculum around structured utterance metadata -> learned utterance embedding -> learned synth automation -> vocal tract/envelope/LFO/filter/spectral controls -> rendered audio loss. The first Core contracts still defend the boundary: PhoneticIntent is the Weksa/language-owned handoff, ArticulatoryPlan is the AquaSynth realization layer, VocalTractMorphology declares anatomy capabilities, ArticulatoryConstraintReport is the host-visible veto surface, and VocalTractPlanResult is the tiny end-to-end proof artifact for host branching. IPA is an authoring input only: tokenization and feature mapping lower into language phonology profiles, timed articulatory gestures, morphology-specific tract/excitation curves, and Faust DSP. The staged support plan starts with tokenization and vowels, then core pulmonic consonants, expanded human IPA, airstream/phonation, and Weksa/alien morphologies. Optional eSpeak NG tests provide the first generated speech workout when ESPEAK_NG or espeak-ng is installed; eSpeak is intelligibility/timing/log-mel pressure, not anatomy truth. AquaSynth owns the learned utterance embedding encoder and synth driver: speech text embeddings plus prosody/emphasis hints plus Ghostlight/Epiphany-shaped character state vectors compress into utterance embeddings, and phonetic features plus that embedding feed a packed-buffer native C# backprop/Adam mapper that predicts tract, AM/FM, LFO, filter, and mel spectral-envelope controls. SpeechBackpropagationPipeline chains the two models: synth-driver target loss returns gradients with respect to its input, slices the utterance-embedding gradient, and updates the utterance encoder from downstream speech loss. The generated ground-truth training fixture renders eSpeak syllables, extracts log-mel evidence, and trains that chained pipeline toward eSpeak-seeded mel/control targets. CultMesh speech training now has payload manifests, peer/lease admission receipts, worker assignment, result collection, and central checkpoint application; workers do not own model updates. Real remote artifact chunk transfer and process supervision remain CultMesh infrastructure surfaces, not AquaSynth workaround territory. IpaGestureExperiment writes frozen descriptor/variant rounds with candidate scripts, primitive timelines, gesture metrics, manifests, and evidence JSONL, then analyzes them into metric summaries, score-band clusters, and a science brief. IpaTrialOrchestrator now writes local five-seed IPA render trials, accepts externally authored candidate scripts through RunCandidateScriptsAsync, writes WAV/report artifacts, evaluator reports, and `ipa-trial-results.cc` CultCache records. `tools/IpaTrialWorker` is the public worker tool for seed/score/search/show/index over the store; search now auto-upserts Qdrant/Ollama evidence chunks and returns vector-backed reports, while `tools/run-ipa-trial-loop.ps1` dispatches external Codex hypothesis/evaluator workers around five five-phoneme target sets. The latest seed run improved voiced vowels/nasals after primitive `SourcePort` lowering gained an oscillatory carrier; plosive closure/release remains the next weak owner.", @@ -121,8 +121,8 @@ "binding_rule": "Parameter binding uses field-site references such as `lpf=@/macro/brightness` and `/opgraphs/0/operators/2/level`; the field owns meaning and the parameter owns the runtime value. Avoid target-routing semantics unless a new graph authority is deliberately mapped." }, "last_verified": { - "command": "dotnet build tools\\IpaTrialWorker\\IpaTrialWorker.csproj --disable-build-servers -p:UseSharedCompilation=false -p:BuildInParallel=false -v minimal; dotnet run --no-build --no-restore --project tools\\IpaTrialWorker\\IpaTrialWorker.csproj -- index --store artifacts\\parity\\ipa-trial-worker-smoke\\20260529T2204\\ipa-trial-results.cc --output artifacts\\parity\\ipa-trial-worker-smoke\\20260529T2204\\qdrant-index-smoke.md --force true; dotnet run --no-build --no-restore --project tools\\IpaTrialWorker\\IpaTrialWorker.csproj -- search --store artifacts\\parity\\ipa-trial-worker-smoke\\20260529T2204\\ipa-trial-results.cc --query \"stop closure release reservoir weak plosive contrast primitive timeline\" --limit 8 --output artifacts\\parity\\ipa-trial-worker-smoke\\20260529T2204\\qdrant-search-stops.md --require-vector true; powershell -NoProfile -ExecutionPolicy Bypass -File tools\\run-ipa-trial-loop.ps1 -Rounds 0 -SkipLocalTrialRun", - "result": "IPA trial retrieval now indexes CultCache trial records and local artifact snippets into Docker Qdrant using local Ollama qwen3-embedding:0.6b vectors. Build passes; index smoke wrote 25 chunks with 1024-dimensional vectors; vector search returned through qdrant-ollama with relevant stop/closure evidence, vector scores, contrast candidates, excerpts, and show commands. The trial loop skeleton still passes after adding index boundaries and skip-index agent search commands.", + "command": "dotnet build tools\\IpaTrialWorker\\IpaTrialWorker.csproj --disable-build-servers -p:UseSharedCompilation=false -p:BuildInParallel=false -v minimal; retrieval smokes under artifacts\\parity\\ipa-trial-worker-smoke\\20260529T2204\\retrieval-tuning-20260530-pass{1..4}; powershell -NoProfile -ExecutionPolicy Bypass -File tools\\run-ipa-trial-loop.ps1 -Rounds 0 -SkipLocalTrialRun", + "result": "IPA trial retrieval now indexes CultCache trial records and local artifact snippets into Docker Qdrant using local Ollama qwen3-embedding:0.6b vectors. Build passes. Retrieval tuning smokes improved class-specific evidence: stop queries surface stop lanes plus mix-p before off-class records, vowel queries surface vowel lanes plus mix-a/u, nasal queries surface nasal lanes first, and dressing tags no longer appear from accidental artifact tokens. The trial loop skeleton still passes after adding index boundaries and skip-index agent search commands.", "commit": "pending ipa qdrant retrieval" } } diff --git a/state/scratch.md b/state/scratch.md index 709d0a5..cc097f6 100644 --- a/state/scratch.md +++ b/state/scratch.md @@ -19,6 +19,15 @@ Current slice: after each round's scoring boundary, then passes `--skip-index true` to agent searches so the agents query fresh vectors without re-embedding on every receipt. +- Retrieval tuning smokes found two gremlin-feeding flaws: generic + weak-articulation language could pull off-class records into class-specific + searches, and mixed target-set records could match every class because their + target contains `a/m/s/p/u`. The live ranker now adds class-affinity scoring, + candidate-specific ownership for mixed lanes (`mix-p` is stop, `mix-a/u` are + vowel, `mix-m` is nasal, `mix-s` is fricative), demotes mixed/generalization + to a secondary transfer hint when a real class is present, and only labels + dressing when the record text actually mentions dressing/FM/AM/envelope/helper + evidence. Search reports now expose `class_focus` and per-hit `class_match`. - Ran five IPA prompt-optimization passes against the external trial-loop agent prompts. Pass 1 produced useful seam science but treated fallback file diff --git a/state/spine.yaml b/state/spine.yaml index 6e11ee8..14f9c6a 100644 --- a/state/spine.yaml +++ b/state/spine.yaml @@ -145,7 +145,7 @@ live_invariants: - IPA training scoring contract is documented. Reports must keep `gesture_score`, `clean_vocal_score`, and `full_parity_score` separate. Gesture score measures descriptor-to-surface coverage, motion direction, spline contour/timing, primitive timeline consequences, and optional external articulation landmarks. Full parity patches may use FM/AM/modulators/envelopes/filters, auxiliary animated voices, and recording-condition helpers, but those helpers cannot count as articulation evidence unless gesture and clean-vocal scores move coherently. - `IpaGestureExperiment.WriteRound` is the first frozen batch harness for IPA hypothesis work. It writes `manifest.yaml`, candidate `.aqua` scripts, primitive `ProbeTimelineReport` CSVs, gesture-layer `metrics.csv`, and per-candidate `evidence.jsonl` under a caller-provided artifact root. `IpaGestureExperiment.AnalyzeRound` writes `analysis/metric-summary.csv`, `analysis/candidate-clusters.csv`, and `analysis/science-brief.md` for the science-worker handoff. This harness owns frozen descriptor/spline/primitive evidence only; clean vocal identity, full spectrogram parity, optimizer checkpoints, and worker orchestration stay downstream. Focused verification: `PatchScriptTests` pass 93/93. - `IpaTrialOrchestrator.RunAsync` is the first local render/scoring worker for IPA trials. It generates candidate patches through `IpaGestureExperiment`, renders them through Faust, compares against local PT fixture references with `AudioAnalyzer`, writes WAV/report artifacts, and stores typed `IpaTrialResult` records in `ipa-trial-results.cc`. The five-seed trial run at `tests/AquaSynth.Dsl.Tests/bin/Debug/net10.0/artifacts/parity/ipa-trials/20260529T214127955/five-seed-trials` showed voiced vowels/nasals became audible only after primitive `SourcePort` lowering gained a true oscillatory voiced carrier; sibilants are pressure, plosives remain weak. Verification: opt-in IPA trial test passed, and focused `PatchScriptTests|IpaTrialResultsRoundTripThroughCultCache` pass 94/94. - - External IPA exploration now has a worker/tool boundary. `tools/IpaTrialWorker` owns `seed`, `score`, `search`, `show`, and audit-only `dump` commands over the typed `.cc` trial-result store; `score` renders external Codex-authored `.aqua` candidates through `IpaTrialOrchestrator.RunCandidateScriptsAsync`, while `search` is the semantic retrieval organ for hypothesis agents. `search` now follows VoidBot's Qdrant shape: `.cc` records plus referenced local artifacts become evidence chunks, local Ollama `qwen3-embedding:0.6b` produces 1024-dim vectors, Qdrant collection `aquasynth_ipa_trial_results` stores stable SHA-256 UUID points with trial/candidate payload metadata, and the report surfaces vector scores, excerpts, tags, metric facts, contrast candidates, and `show` commands. Qdrant is a derived index; CultCache remains truth, and lexical/metric search is fallback when the vector stack is unavailable. `tools/run-ipa-trial-loop.ps1` orchestrates external `codex exec` hypothesis/evaluator workers around that store. The active trial shape is five target sets of five phoneme lanes each: vowels, nasals/approximants, fricatives, stops, and mixed generalization. Candidate names are exactly `____.aqua`, with lowercase kebab-case family ids. Three dry external Codex smoke passes refined hypothesizer/evaluator prompts into exact 25-lane output, retrieval receipts, claim audits, evidence-quality ledgers/verdicts, and fail-closed evaluator compliance. A later five-pass prompt optimization showed the best science comes from fixed receipt packets plus constrained reasoning; production prompts now require contrast pairs, `show` metric values, primitive timeline excerpts or explicit `missing`, class-consistent reference matrices, `X owns Y so Z remains true` owner sentences, at least five micro-sweep perturbations before a seam is actionable, and a novelty gate rejecting loudness/dressing-only families as loss-landscape novelty. Verification: `IpaTrialWorker` builds, Qdrant/Ollama index/search smoke reads the seed store, and the loop skeleton runs with `-Rounds 0 -SkipLocalTrialRun`. + - External IPA exploration now has a worker/tool boundary. `tools/IpaTrialWorker` owns `seed`, `score`, `search`, `show`, and audit-only `dump` commands over the typed `.cc` trial-result store; `score` renders external Codex-authored `.aqua` candidates through `IpaTrialOrchestrator.RunCandidateScriptsAsync`, while `search` is the semantic retrieval organ for hypothesis agents. `search` now follows VoidBot's Qdrant shape: `.cc` records plus referenced local artifacts become evidence chunks, local Ollama `qwen3-embedding:0.6b` produces 1024-dim vectors, Qdrant collection `aquasynth_ipa_trial_results` stores stable SHA-256 UUID points with trial/candidate payload metadata, and the report surfaces vector scores, excerpts, tags, metric facts, contrast candidates, `class_focus`, per-hit `class_match`, and `show` commands. Qdrant is a derived index; CultCache remains truth, and lexical/metric search is fallback when the vector stack is unavailable. Retrieval tuning added phonetic class-affinity scoring and candidate-specific mixed-lane ownership so `mix-p` can support stop seams without every mixed candidate matching stop evidence. `tools/run-ipa-trial-loop.ps1` orchestrates external `codex exec` hypothesis/evaluator workers around that store. The active trial shape is five target sets of five phoneme lanes each: vowels, nasals/approximants, fricatives, stops, and mixed generalization. Candidate names are exactly `____.aqua`, with lowercase kebab-case family ids. Three dry external Codex smoke passes refined hypothesizer/evaluator prompts into exact 25-lane output, retrieval receipts, claim audits, evidence-quality ledgers/verdicts, and fail-closed evaluator compliance. A later five-pass prompt optimization showed the best science comes from fixed receipt packets plus constrained reasoning; production prompts now require contrast pairs, `show` metric values, primitive timeline excerpts or explicit `missing`, class-consistent reference matrices, `X owns Y so Z remains true` owner sentences, at least five micro-sweep perturbations before a seam is actionable, and a novelty gate rejecting loudness/dressing-only families as loss-landscape novelty. Verification: `IpaTrialWorker` builds, Qdrant/Ollama index/search smoke reads the seed store, and the loop skeleton runs with `-Rounds 0 -SkipLocalTrialRun`. - TurbulenceJet now includes a release-timed burst-noise component in addition to the deterministic pressure pulse, but a stronger trial barely moved papa; the source is not the main remaining articulation bottleneck. Graph radiation now mixes 30% raw boundary flow with 70% high-passed flow after local admittance weighting, down from the over-bright 15/85 blend. Latest radiation/source artifact under `artifacts/parity/pink-trombone-utterance-logmel/20260527T174236049`: mama/papa/thrombosis cosine 0.6578/0.7692/0.5585, rmsRatio 3.0006/2.6153/2.3488, articulation 0.1437/0.1173/0.1191. Static `closure-release` under `artifacts/parity/pink-trombone-logmel/20260527T174139661` has rmsRatio 0.9900 and speechBandRatio 0.0842. This is still not speaking; next pressure is true tract resonance/passive scattering and pressure storage, not more burst gain. - Same-path two-port area discontinuities now emit `graph_area_energy_in_*` and `graph_area_energy_out_*` probes alongside the three-port branch `graph_connection_energy_*` probes. Use these to inspect passivity and formant-body failures at the scatter sites rather than judging only final output loudness. - `FaustExportOptions.DebugProbeUi` wraps graph probe signals in Faust `vbargraph` UI controls under `/debug/...` without changing audio output arity. Native streaming Faust patches now separate writable controls from read-only probe zones and can read/snapshot probe values after `ProcessBlock`; use this for acoustic graph passivity/radiation inspection, leave it off for normal parity renders. diff --git a/tools/IpaTrialWorker/Program.cs b/tools/IpaTrialWorker/Program.cs index 05cb797..9909da9 100644 --- a/tools/IpaTrialWorker/Program.cs +++ b/tools/IpaTrialWorker/Program.cs @@ -266,7 +266,11 @@ static IReadOnlyList MergeVectorAndLexicalHits( var matchedTerms = lexicalHit?.MatchedTerms ?? []; var tags = EvidenceTags(result, SearchDocument.From(result, DocumentFields(result, store))); var lexicalScore = lexicalHit?.Score ?? 0; - var score = hit.Score * 10f + MathF.Min(lexicalScore, 8f) + MetricBias(result, ExpandTerms(Tokenize(query)).ToArray()); + var queryTerms = ExpandTerms(Tokenize(query)).ToArray(); + var score = hit.Score * 10f + + MathF.Min(lexicalScore, 8f) + + MetricBias(result, queryTerms) + + ClassAffinityBias(result, queryTerms); return new SearchHit( result, score, @@ -480,7 +484,8 @@ static IReadOnlyList Rank( .ToArray(); var metricBias = MetricBias(result, queryTerms); var evidenceBias = EvidenceBias(result, queryTerms, document); - var score = lexical + metricBias + evidenceBias; + var classBias = ClassAffinityBias(result, queryTerms); + var score = lexical + metricBias + evidenceBias + classBias; return new SearchHit(result, score, matchedTerms, matchedByField, EvidenceTags(result, document), retrievalMode); }) .Where(item => item.Score > 0) @@ -578,6 +583,174 @@ static float EvidenceBias(IpaTrialResult result, IReadOnlyCollection ter return bias; } +static float ClassAffinityBias(IpaTrialResult result, IReadOnlyCollection terms) +{ + var intents = QueryClasses(terms).ToArray(); + if (intents.Length == 0) + { + return 0; + } + + if (intents.Contains("dressing", StringComparer.OrdinalIgnoreCase)) + { + return MentionsDressing(result) ? 2.0f : -0.75f; + } + + var classes = ResultClasses(result).ToArray(); + var primaryIntents = intents + .Where(intent => !intent.Equals("mixed", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (primaryIntents.Length > 0) + { + if (primaryIntents.Intersect(classes, StringComparer.OrdinalIgnoreCase).Any()) + { + return classes.Contains("mixed", StringComparer.OrdinalIgnoreCase) ? 2.0f : 2.5f; + } + + return classes.Contains("mixed", StringComparer.OrdinalIgnoreCase) ? -1.0f : -1.75f; + } + + return intents.Intersect(classes, StringComparer.OrdinalIgnoreCase).Any() ? 1.25f : -0.75f; +} + +static IEnumerable QueryClasses(IEnumerable terms) +{ + var set = new HashSet(terms, StringComparer.OrdinalIgnoreCase); + if (set.Overlaps(["stop", "plosive", "closure", "release", "reservoir"])) + { + yield return "stop"; + } + if (set.Overlaps(["fricative", "sibilant", "constriction", "turbulence", "labiodental", "dental"])) + { + yield return "fricative"; + } + if (set.Overlaps(["nasal", "velum", "veloparynx", "velopharynx", "branch", "admittance"])) + { + yield return "nasal"; + } + if (set.Overlaps(["vowel", "formant", "tongue", "body", "rounded", "unrounded"])) + { + yield return "vowel"; + } + if (set.Overlaps(["mixed", "transfer", "generalization"])) + { + yield return "mixed"; + } + if (set.Overlaps(["dressing", "fm", "am", "modulator", "envelope", "helper"])) + { + yield return "dressing"; + } +} + +static IEnumerable ResultClasses(IpaTrialResult result) +{ + var candidateClasses = CandidateSpecificClasses(result.CandidateId).ToArray(); + var target = result.TargetSetId.ToLowerInvariant(); + if (candidateClasses.Length > 0) + { + foreach (var candidateClass in candidateClasses) + { + yield return candidateClass; + } + + if (target.Contains("mixed", StringComparison.Ordinal)) + { + yield return "mixed"; + } + + yield break; + } + + if (target.Contains("mixed", StringComparison.Ordinal)) + { + yield return "mixed"; + yield break; + } + if (target.Contains("stop", StringComparison.Ordinal)) + { + yield return "stop"; + yield break; + } + if (target.Contains("fricative", StringComparison.Ordinal)) + { + yield return "fricative"; + yield break; + } + if (target.Contains("nasal", StringComparison.Ordinal)) + { + yield return "nasal"; + yield break; + } + if (target.Contains("vowel", StringComparison.Ordinal)) + { + yield return "vowel"; + yield break; + } + + foreach (var phoneme in result.Phonemes) + { + var normalized = phoneme.ToLowerInvariant(); + if (normalized is "p" or "b" or "t" or "d" or "k") + { + yield return "stop"; + } + if (normalized is "s" or "z" or "f" or "v" or "th" or "θ") + { + yield return "fricative"; + } + if (normalized is "m" or "n" or "ng" or "ŋ") + { + yield return "nasal"; + } + if (normalized is "a" or "i" or "u" or "e" or "o") + { + yield return "vowel"; + } + } +} + +static IEnumerable CandidateSpecificClasses(string candidateId) +{ + var normalized = candidateId.ToLowerInvariant(); + var prefix = normalized; + if (prefix.StartsWith("mix-", StringComparison.Ordinal)) + { + prefix = prefix[4..]; + } + + prefix = prefix.Split(['-', '_'], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? prefix; + if (prefix is "p" or "b" or "t" or "d" or "k") + { + yield return "stop"; + } + if (prefix is "s" or "z" or "f" or "v" or "th" or "θ") + { + yield return "fricative"; + } + if (prefix is "m" or "n" or "ng" or "ŋ") + { + yield return "nasal"; + } + if (prefix is "a" or "i" or "u" or "e" or "o") + { + yield return "vowel"; + } + if (prefix is "l" or "r") + { + yield return "approximant"; + } +} + +static bool MentionsDressing(IpaTrialResult result) +{ + var text = string.Join(' ', [ + result.Hypothesis, + result.EvaluationSummary, + string.Join(' ', result.KnownLies) + ]); + return Tokenize(text).Any(term => term is "dressing" or "fm" or "am" or "modulator" or "envelope" or "helper"); +} + static Dictionary DocumentFields(IpaTrialResult result, string store) { var fields = new Dictionary(StringComparer.OrdinalIgnoreCase) @@ -764,9 +937,7 @@ static string[] EvidenceTags(IpaTrialResult result, SearchDocument document) { tags.Add("timeline"); } - if (document.AllTerms.Contains("dressing", StringComparer.OrdinalIgnoreCase) || - document.AllTerms.Contains("fm", StringComparer.OrdinalIgnoreCase) || - document.AllTerms.Contains("am", StringComparer.OrdinalIgnoreCase)) + if (MentionsDressing(result)) { tags.Add("dressing"); } @@ -795,6 +966,7 @@ static string SearchReport( builder.AppendLine($"retrieval: `{(ranked.Any(hit => hit.RetrievalMode == "qdrant-ollama") ? "qdrant-ollama" : "lexical-fallback")}`"); builder.AppendLine($"collection: `{options.Collection}`"); builder.AppendLine($"embedder: `{options.EmbedModel}`"); + builder.AppendLine($"class_focus: `{string.Join(", ", QueryClasses(ExpandTerms(Tokenize(query))).Distinct(StringComparer.OrdinalIgnoreCase))}`"); builder.AppendLine($"matches: {ranked.Count}"); builder.AppendLine(); foreach (var hit in ranked) @@ -819,6 +991,8 @@ static string SearchReport( builder.AppendLine(string.Join('|', hit.MatchedTerms.Take(12))); builder.Append(" tags: "); builder.AppendLine(string.Join(", ", hit.Tags)); + builder.Append(" class_match: "); + builder.AppendLine(string.Join(", ", ResultClasses(result).Distinct(StringComparer.OrdinalIgnoreCase))); builder.Append(" metrics: gesture="); builder.Append(Metric(result, "gesture_score")); builder.Append(", logMelCosine=");