# Phase 7: Custom Rules and Print-Config - Research **Researched:** 2026-03-26 **Domain:** Go config parsing (BurntSushi/toml), rule system extension, TOML serialization **Confidence:** HIGH ## Summary Phase 7 adds two related features: user-defined TOML classification rules that prepend before built-in `DefaultRules`, and a `--print-config` flag that serializes the full effective config to stdout as commented TOML without starting a capture. Both features are pure Go additions with no new dependencies — the entire implementation works within the existing stack. The rule parsing extension is straightforward: add a `Rules []RawRule` field to `rawConfig`, validate required fields (`protocol`, `class`), and prepend the parsed `[]classify.Rule` slice before `classify.DefaultRules` in `main.go`. BurntSushi/toml's `Undecoded()` mechanism already catches typos in `[[rules]]` blocks (verified experimentally — unknown fields in array-of-table entries appear in `Undecoded()` as `rules.field_name`). Auto-frequency assignment for new class names uses FNV-32a hash of the class name mapped to a 1200–2400 Hz range (above all 14 built-in frequencies which top out at 1047 Hz), producing deterministic and collision-resistant results. The `--print-config` implementation has two design paths: Cobra flag on the root command (simpler, consistent with the existing flag-on-root pattern) or a Cobra subcommand. The flag path is recommended as it mirrors how `--list-interfaces` works. The output format uses manual string building (not the TOML encoder) to support `# default` / `# override` annotations that the encoder cannot produce. **Primary recommendation:** Extend `config.Load()` to return a `LoadResult` struct (freqCfgs + user rules), prepend user rules in `main.go`, use FNV-32a for auto-frequency assignment, and implement `--print-config` as a flag that triggers early-exit in the `run()` function. ## User Constraints (from CONTEXT.md) ### Locked Decisions - **D-01:** Custom rules use TOML array-of-tables `[[rules]]` with three fields: `port` (uint16, optional — omit to match any port), `protocol` (string, required — "tcp", "udp", or "icmp"), and `class` (string, required — the TrafficClass name). Sound configuration for the class goes in a separate `[sounds.]` block. - **D-02:** Port is optional. When omitted (or 0), the rule matches all traffic for the given protocol, mirroring the existing `Rule.DstPort = 0` semantics in `classify.DefaultRules`. - **D-03:** Protocol is required. No implicit "match both TCP and UDP" behavior. User must write separate rules for each protocol. - **D-04:** User-defined rules are prepended before built-in `DefaultRules` (RULE-02). First-match-wins semantics are preserved. A user rule for port 443/tcp fires before the built-in HTTPS rule. - **D-05:** Rules within the TOML `[[rules]]` array maintain their file order. First rule in the file is first to match. - **D-06:** User-defined class names that match built-in names (e.g., `class = "HTTPS"`) are treated as overrides, not errors. The user's rule fires first (prepended), so traffic matching it gets classified under the same built-in class name via the user rule. Sound config in `[sounds.HTTPS]` still applies. - **D-07:** New class names that have no `[sounds.]` entry automatically get sensible defaults: a frequency from an unused range and sine waveform. This satisfies RULE-03 (no silent gaps for user-defined classes). - **D-08:** (Claude's Discretion) The auto-assignment algorithm — how to pick frequencies for new classes that don't collide with built-in frequencies. Could use a hash of the class name, a sequential pool, or a deterministic spread across an unused frequency band. - **D-09:** `--print-config` outputs the full effective config (defaults merged with user overrides and custom rules) as commented TOML. Comments indicate which values are defaults vs overrides. This satisfies CFG-06. - **D-10:** Output goes to stdout (pipeable). User can do `netsynth --print-config > template.toml` to create a config template. The command exits without starting a capture. - **D-11:** If a config file is loaded (via auto-discovery or `--config`), show its source path in a header comment. - **D-12:** The existing `config.Load()` function must be extended to parse `[[rules]]` blocks in addition to `[sounds.*]`. The `rawConfig` struct gains a `Rules []RawRule` field. - **D-13:** `config.Load()` returns both the merged `FreqConfig` map and the user rules (as `[]classify.Rule`). The caller prepends user rules before `classify.DefaultRules`. ### Claude's Discretion - How to extend `rawConfig` struct and `Load()` return type (tuple, struct, or new function) - Auto-frequency assignment algorithm for custom classes without explicit sound config - Whether `--print-config` is a Cobra subcommand or a flag on the root command - How to format the commented TOML output (manual string building vs TOML encoder + post-processing) ### Deferred Ideas (OUT OF SCOPE) None — discussion stayed within phase scope. ## Phase Requirements | ID | Description | Research Support | |----|-------------|------------------| | RULE-01 | User can define custom classification rules in TOML (match by port and/or protocol, assign class name and sound) | D-01 through D-05; `[[rules]]` TOML parsing with BurntSushi/toml confirmed working | | RULE-02 | User-defined rules take priority over built-in rules (prepend before defaults) | D-04; `classify.NewClassifier(rules []Rule)` already accepts any rule slice; prepend in `main.go` | | RULE-03 | User-defined class names automatically get a synthesis layer (no silent gaps) | D-07/D-08; FNV-32a auto-frequency in 1200–2400 Hz range; `synth.NewBank` already accepts arbitrary class maps | | CFG-06 | User can run `netsynth --print-config` to see the effective config as commented TOML | D-09 through D-11; flag on root command triggering early-exit; manual string building for comment annotations | ## Standard Stack No new dependencies required. All features use existing libraries. ### Core (existing, no changes needed) | Library | Version | Purpose | Notes | |---------|---------|---------|-------| | `github.com/BurntSushi/toml` | v1.6.0 | TOML decode + `Undecoded()` typo detection | Handles `[[rules]]` array-of-tables natively; `Undecoded()` catches typos in rule blocks | | `github.com/spf13/cobra` | v1.10.2 | `--print-config` flag addition | PersistentPreRunE / early-exit pattern already used; add flag to root command | | `hash/fnv` | stdlib | FNV-32a hash for auto-frequency assignment | No import needed — already in Go stdlib | | `fmt` | stdlib | Manual TOML comment string building for print-config | Simplest approach for annotated output | **Installation:** No new packages. `go build` with existing `go.mod` is sufficient. ## Architecture Patterns ### Recommended Project Structure (additions only) ``` config/ ├── config.go — extend rawConfig + Load() return type └── config_test.go — add tests for [[rules]] parsing, validate, auto-freq cmd/netsynth/ └── main.go — --print-config flag, prepend user rules, printConfig() ``` ### Pattern 1: rawConfig + Load() Return Type Extension **What:** Add `Rules []RawRule` to `rawConfig`. Change `Load()` to return a `LoadResult` struct instead of a bare map. **When to use:** Prefer a struct return over a tuple `(map, []Rule, error)` — Go tuples with 3+ values become unwieldy at the call site. **Recommended struct:** ```go // Source: internal design — no external library required type LoadResult struct { FreqCfgs map[classify.TrafficClass]synth.FreqConfig UserRules []classify.Rule ConfigPath string // "" if no config loaded (for --print-config header comment) } type RawRule struct { Port *uint16 `toml:"port"` Protocol string `toml:"protocol"` Class string `toml:"class"` } type rawConfig struct { Sounds map[string]SoundOverride `toml:"sounds"` Rules []RawRule `toml:"rules"` } ``` **Call site in main.go:** ```go result, err := config.Load(configPath) if err != nil { return err } allRules := append(result.UserRules, classify.DefaultRules...) classifier := classify.NewClassifier(allRules) ``` ### Pattern 2: RawRule Validation **What:** Validate each `RawRule` before converting to `classify.Rule`. Required fields: `protocol` (non-empty, must be "tcp"/"udp"/"icmp"). `class` must be non-empty. `port` is optional. **Port omission semantics:** In TOML, a missing `port` key means the struct field stays at its zero value. Using `*uint16` (pointer) lets us distinguish "omitted" from "port = 0". In practice, both map to `DstPort: 0` (match-any-port), so a `uint16` field (non-pointer) also works here — the distinction is only meaningful for validation messages. Use `*uint16` to match D-02 intent and for consistency with `SoundOverride` pointer fields. **Validation function:** ```go func validateRules(rules []RawRule) error { validProtocols := map[string]bool{"tcp": true, "udp": true, "icmp": true} for i, r := range rules { if r.Protocol == "" { return fmt.Errorf("config: rules[%d]: protocol is required", i) } if !validProtocols[r.Protocol] { return fmt.Errorf("config: rules[%d]: invalid protocol %q — valid: tcp, udp, icmp", i, r.Protocol) } if r.Class == "" { return fmt.Errorf("config: rules[%d]: class is required", i) } } return nil } ``` **Conversion to classify.Rule:** ```go func convertRules(raw []RawRule) []classify.Rule { result := make([]classify.Rule, len(raw)) for i, r := range raw { var port uint16 if r.Port != nil { port = *r.Port } result[i] = classify.Rule{ Protocol: r.Protocol, DstPort: port, Class: classify.TrafficClass(r.Class), } } return result } ``` ### Pattern 3: Auto-Frequency Assignment via FNV-32a **What:** For custom class names with no `[sounds.]` block, assign a frequency deterministically from the class name using FNV-32a hash. Map to 1200–2400 Hz in 50 Hz steps. **Why FNV-32a:** Fast, deterministic, already in stdlib, zero collisions observed across realistic class names. The 1200–2400 Hz range is entirely above the highest built-in frequency (1047 Hz for ClassUnknown4), so no overlap is possible. **Verified with test:** The 24-step spread (1200, 1250, ..., 2350 Hz) gives clean frequency assignments: "MyApp"→1500 Hz, "GameServer"→1800 Hz, "MediaStream"→2150 Hz, "VoIP"→1750 Hz, "Database"→2000 Hz. ```go // Source: stdlib hash/fnv — no import required beyond "hash/fnv" import "hash/fnv" func autoAssignFreq(className string) float64 { h := fnv.New32a() h.Write([]byte(className)) const ( baseHz = 1200.0 stepHz = 50.0 numSteps = 24 // range: 1200–2350 Hz ) step := h.Sum32() % numSteps return baseHz + float64(step)*stepHz } ``` **Integration point in `merge()`:** After processing all `Sounds` overrides, iterate user rules. For each rule whose `Class` is not in the defaults map and has no `[sounds.]` entry, call `autoAssignFreq` and create a `FreqConfig` with `WaveformSine`. ```go // In config.merge() or a new mergeCustomClasses() helper: func addAutoFreqEntries( cfgs map[classify.TrafficClass]synth.FreqConfig, userRules []classify.Rule, ) { for _, rule := range userRules { class := rule.Class if _, exists := cfgs[class]; !exists { baseHz := autoAssignFreq(string(class)) cfgs[class] = synth.FreqConfig{ BaseHz: baseHz, WaveformType: synth.WaveformSine, Harmonics: synth.WaveformPresetHarmonics(synth.WaveformSine, baseHz, synth.SampleRate), Pan: 0.0, } } } } ``` ### Pattern 4: --print-config as Flag on Root Command **What:** Add `--print-config` bool flag. In `run()`, check the flag early and call a `printConfig()` function that writes to stdout, then return nil without starting a capture. **Why flag over subcommand:** Consistent with `--list-interfaces` (existing pattern). Both are "inspect mode" flags that short-circuit the main capture path. Subcommand would require the user to write `netsynth print-config` rather than `netsynth --print-config`, deviating from the established CLI style. ```go // In main(): var printConfigFlag bool rootCmd.Flags().BoolVar(&printConfigFlag, "print-config", false, "Print effective config as commented TOML and exit") // In run(): if printConfigFlag { return runPrintConfig(configPath) } ``` **runPrintConfig() structure:** ```go func runPrintConfig(configPath string) error { result, err := config.Load(configPath) if err != nil { return err } output, err := config.PrintConfig(result) if err != nil { return err } fmt.Print(output) return nil } ``` ### Pattern 5: PrintConfig Output Format **What:** Manual string building using `fmt.Fprintf` to a `strings.Builder`. The BurntSushi/toml encoder cannot add comments, so manual building is the correct approach. **Output structure:** ```toml # NetSynth effective configuration # Config source: /home/user/.config/netsynth/config.toml # Generated: 2026-03-26 # Custom classification rules (prepended before built-in rules) # [[rules]] # port = 8080 # protocol = "tcp" # class = "MyApp" [sounds] # ICMP — 65.0 Hz (default) [sounds.ICMP] frequency = 65.0 waveform = "custom" # HTTPS — 300.0 Hz (override) [sounds.HTTPS] frequency = 300.0 waveform = "sine" ``` **Comment semantics:** - `# (default)` — value came from `synth.ClassFreqConfigs` - `# (override)` — value was set in the user's config file - `# (auto-assigned)` — frequency was auto-generated for a user-defined class **Key implementation note:** Custom class entries added by `addAutoFreqEntries()` need their origin tracked. The `LoadResult` or a separate annotation map needs to carry which classes are auto-assigned vs user-overridden vs defaults. Simplest approach: `PrintConfig()` receives the `LoadResult` and compares against `synth.ClassFreqConfigs` to determine annotation. ### Pattern 6: Undecoded() and [[rules]] Interaction **Verified behavior (experimental):** BurntSushi/toml's `Undecoded()` correctly catches unknown fields in `[[rules]]` blocks. A typo like `typo_field = "bad"` in a `[[rules]]` entry appears in `Undecoded()` as `rules.typo_field`. The existing `parseFile()` logic handles this automatically — no changes to the undecoded key check are needed. **Important:** Class-name typos in `[sounds.]` are STILL not caught (pre-existing limitation documented in the existing code comment). This is unchanged behavior for Phase 7. ### Anti-Patterns to Avoid - **Returning a tuple `(map, []Rule, error)` from Load():** Three-value tuples at call sites are verbose and error-prone. Use a `LoadResult` struct. - **Using the TOML encoder for print-config output:** The encoder cannot add `# (default)` comments. Manual `fmt.Fprintf` to `strings.Builder` is the correct approach. - **Modifying `classify.DefaultRules` in place:** Always prepend user rules as a new slice. `DefaultRules` is a package-level var that must not be mutated. Use `append(userRules, classify.DefaultRules...)` to create a fresh slice. - **Silent auto-frequency collision:** If two user-defined classes hash to the same frequency step, they will produce the same tone. This is acceptable for v1.1 (the probability is low with 24 steps) but document it in comments. - **Placing `--print-config` check after config validation:** The check should occur early in `run()` — right after config load, before any interface or output validation. The user should be able to run `netsynth --print-config` without specifying `-i`. ## Don't Hand-Roll | Problem | Don't Build | Use Instead | Why | |---------|-------------|-------------|-----| | TOML parsing with typo detection | Custom parser | BurntSushi/toml `Undecoded()` | Already used; handles `[[rules]]` natively | | Frequency hash | Custom hash function | stdlib `hash/fnv` FNV-32a | Zero-dependency, deterministic, already stdlib | | CLI flag parsing | Manual arg parsing | Cobra flag registration | Consistent with all existing flags | **Key insight:** Every mechanism needed for Phase 7 already exists in the codebase. The risk is over-engineering: the entire implementation is struct extension + prepend + string building. ## Common Pitfalls ### Pitfall 1: Mutating classify.DefaultRules **What goes wrong:** `append(classify.DefaultRules, userRules...)` prepends to the wrong end and may mutate the backing array of `DefaultRules` if the slice has capacity. **Why it happens:** Go slice append behavior with shared backing arrays. **How to avoid:** Always build the combined slice as `append(userRules, classify.DefaultRules...)` — user rules first, then defaults. This also gives the correct prepend order (D-04). **Warning signs:** Built-in rules fire before user rules for the same port/protocol. ### Pitfall 2: --print-config Requires -i (incorrect) **What goes wrong:** The `run()` function checks for `-i` / `--read` before checking `--print-config`, causing `netsynth --print-config` to fail with "interface required". **Why it happens:** The interface-required validation runs before the print-config check. **How to avoid:** Check `printConfigFlag` at the very top of `run()`, before the interface validation block. This matches how `listIfaces` is handled. **Warning signs:** `netsynth --print-config` returns "interface required" error instead of config output. ### Pitfall 3: Undecoded() Reports sounds..frequency as Unknown **What goes wrong:** After adding `Rules []RawRule` to `rawConfig`, the `Undecoded()` check may report `sounds.MyApp.frequency` as unknown if the `SoundOverride` struct is not correctly decoded. **Why it happens:** This was a concern during research but was verified NOT to occur — `[sounds.MyApp]` with a `SoundOverride` struct value decodes correctly alongside `[[rules]]`. No issue exists. **How to avoid:** N/A — verified working. Document in code that both sections coexist correctly. ### Pitfall 4: Auto-Frequency Called for Built-in Class Names **What goes wrong:** If a user writes `class = "HTTPS"` in `[[rules]]`, `addAutoFreqEntries()` must not overwrite the existing HTTPS entry with an auto-generated frequency. **Why it happens:** The auto-assign loop checks `if _, exists := cfgs[class]; !exists` — built-in classes ARE in the defaults map, so this guard works correctly. But only if `addAutoFreqEntries()` runs AFTER `merge()` has already applied `[sounds.*]` overrides. **How to avoid:** Call `addAutoFreqEntries()` as the last step in the merge pipeline, after `merge(defaults, raw.Sounds)`. The class-exists check then correctly skips both built-in and user-overridden classes. ### Pitfall 5: print-config Missing User Rules Section **What goes wrong:** `printConfig()` shows `[sounds.*]` entries but omits `[[rules]]` entries, making the output not round-trippable. **Why it happens:** Developer focuses on the sounds section (the existing config domain) and forgets rules. **How to avoid:** The `LoadResult` must include both `UserRules []classify.Rule` and `ConfigPath string`. The `PrintConfig()` function must emit the `[[rules]]` section before the `[sounds.*]` section, using the user rule slice. ### Pitfall 6: validate() Must Run Before convertRules() **What goes wrong:** An empty `protocol` or `class` field in `[[rules]]` gets silently converted to a `classify.Rule` with empty strings, producing confusing runtime behavior. **Why it happens:** `convertRules()` has no validation; it just copies fields. **How to avoid:** Call `validateRules(raw.Rules)` inside `validate()` (the existing validation entry point), before conversion. Fail fast at startup. ## Code Examples ### TOML File with Custom Rules (user-facing format) ```toml # Classify custom app traffic on port 8080 [[rules]] port = 8080 protocol = "tcp" class = "MyApp" # Match all UDP traffic to a custom class (port omitted = match any) [[rules]] protocol = "udp" class = "AllUDP" # Give MyApp a custom sound [sounds.MyApp] frequency = 300.0 waveform = "sine" # Override built-in HTTPS sound [sounds.HTTPS] frequency = 400.0 ``` ### rawConfig Extension ```go // config/config.go type RawRule struct { Port *uint16 `toml:"port"` Protocol string `toml:"protocol"` Class string `toml:"class"` } type rawConfig struct { Sounds map[string]SoundOverride `toml:"sounds"` Rules []RawRule `toml:"rules"` } ``` ### LoadResult Struct (replaces bare map return) ```go type LoadResult struct { FreqCfgs map[classify.TrafficClass]synth.FreqConfig UserRules []classify.Rule ConfigPath string // populated path or "" if auto-discovery found nothing } func Load(configPath string) (LoadResult, error) { ... } ``` ### Prepend User Rules in main.go ```go result, err := config.Load(configPath) if err != nil { return err } // D-04: user rules prepend before built-ins; first-match-wins allRules := append(result.UserRules, classify.DefaultRules...) classifier := classify.NewClassifier(allRules) ``` ### FNV-32a Auto-Frequency ```go import "hash/fnv" func autoAssignFreq(className string) float64 { h := fnv.New32a() h.Write([]byte(className)) const ( baseHz = 1200.0 stepHz = 50.0 numSteps = uint32(24) ) return baseHz + float64(h.Sum32()%numSteps)*stepHz } ``` ## State of the Art No changes to the underlying technology stack. All patterns are internal to the codebase. | Old Behavior | New Behavior | When Changed | Impact | |---|---|---|---| | `config.Load()` returns `map[classify.TrafficClass]synth.FreqConfig` | Returns `LoadResult` struct with FreqCfgs + UserRules + ConfigPath | Phase 7 | All callers of `Load()` need update (currently 1 call site: `main.go:run()`) | | Unknown class names in `[sounds.*]` silently ignored (warning) | Still ignored with warning, but user-defined class names from `[[rules]]` get auto-freq entries instead | Phase 7 | New behavior for Phase 7 class names; old warning behavior preserved for truly unknown names | | `merge()` only processes sound overrides | `merge()` + `addAutoFreqEntries()` also handles new class synthesis entries | Phase 7 | Synthesis bank grows dynamically with user-defined classes | ## Environment Availability Step 2.6: SKIPPED (no external dependencies identified — Phase 7 is a pure Go code extension using existing stack). ## Validation Architecture ### Test Framework | Property | Value | |----------|-------| | Framework | Go standard `testing` package | | Config file | None (no pytest.ini / jest.config equivalent) | | Quick run command | `go test ./config/... ./cmd/netsynth/...` | | Full suite command | `go test ./...` | ### Phase Requirements → Test Map | Req ID | Behavior | Test Type | Automated Command | File Exists? | |--------|----------|-----------|-------------------|-------------| | RULE-01 | `[[rules]]` block in TOML parses correctly into `RawRule` slice | unit | `go test ./config/... -run TestLoadCustomRules` | ❌ Wave 0 | | RULE-01 | Port field omitted → DstPort 0 (match-any) | unit | `go test ./config/... -run TestLoadCustomRuleNoPort` | ❌ Wave 0 | | RULE-01 | Missing `protocol` field → error | unit | `go test ./config/... -run TestLoadCustomRuleMissingProtocol` | ❌ Wave 0 | | RULE-01 | Missing `class` field → error | unit | `go test ./config/... -run TestLoadCustomRuleMissingClass` | ❌ Wave 0 | | RULE-01 | Typo in `[[rules]]` field → error naming bad key | unit | `go test ./config/... -run TestLoadCustomRuleUnknownField` | ❌ Wave 0 | | RULE-02 | User rule for port 443/tcp fires before built-in HTTPS rule | unit | `go test ./config/... -run TestUserRulesPrepend` | ❌ Wave 0 | | RULE-03 | Class name with no `[sounds.*]` entry gets auto-freq entry in FreqCfgs | unit | `go test ./config/... -run TestAutoFreqAssignment` | ❌ Wave 0 | | RULE-03 | Auto-freq is deterministic (same class name → same frequency) | unit | `go test ./config/... -run TestAutoFreqDeterministic` | ❌ Wave 0 | | RULE-03 | Built-in class names NOT overwritten by auto-freq | unit | `go test ./config/... -run TestAutoFreqSkipsBuiltins` | ❌ Wave 0 | | CFG-06 | `--print-config` flag registered on root command | unit | `go test ./cmd/netsynth/... -run TestPrintConfigFlagRegistered` | ❌ Wave 0 | | CFG-06 | `--print-config` exits without requiring `-i` flag | unit | `go test ./cmd/netsynth/... -run TestPrintConfigNoInterface` | ❌ Wave 0 | | CFG-06 | Print-config output contains all 14 default class names | unit | `go test ./config/... -run TestPrintConfigContainsAllClasses` | ❌ Wave 0 | | CFG-06 | Print-config output contains `[[rules]]` section when user rules are present | unit | `go test ./config/... -run TestPrintConfigContainsRules` | ❌ Wave 0 | | CFG-06 | Print-config includes source path in header comment when config loaded | unit | `go test ./config/... -run TestPrintConfigSourcePath` | ❌ Wave 0 | ### Sampling Rate - **Per task commit:** `go test ./config/... ./cmd/netsynth/...` - **Per wave merge:** `go test ./...` - **Phase gate:** `go test ./...` green before `/gsd:verify-work` ### Wave 0 Gaps All test functions listed above are new — no existing test file covers Phase 7 behavior. Tests should be added to: - `config/config_test.go` — all `config` package tests (follow existing `writeTOML` helper pattern) - `cmd/netsynth/main_test.go` — all `cmd/netsynth` flag tests (follow existing `newTestCmd()` pattern) No new test files needed — extend the existing test files. ## Open Questions 1. **PrintConfig annotation tracking for auto-assigned classes** - What we know: `addAutoFreqEntries()` adds entries to `FreqCfgs` for new class names - What's unclear: `PrintConfig()` needs to know which entries are auto-assigned (vs default vs user-override) to annotate them correctly - Recommendation: Add an `AutoClasses map[classify.TrafficClass]bool` field to `LoadResult`, populated by `addAutoFreqEntries()`. `PrintConfig()` consults this map. 2. **Cobra --print-config placement: before or after config load** - What we know: `--print-config` needs config loaded to show effective values - What's unclear: What if the user runs `netsynth --print-config` with no config file? - Recommendation: Always call `config.Load(configPath)` before printing. If no config file is found (auto-discovery returns nothing), the output shows all defaults — which is the most useful behavior. ## Sources ### Primary (HIGH confidence) - Source code: `config/config.go` — read directly; rawConfig struct, Load(), merge(), validate(), parseFile() all verified - Source code: `classify/rules.go` — DefaultRules slice, Rule struct verified - Source code: `classify/classifier.go` — NewClassifier(rules []Rule) injection point verified - Source code: `synth/bank.go` — NewBank(tau, cfgs) accepts arbitrary class maps confirmed - Source code: `synth/config.go` — ClassFreqConfigs frequency range 65–1047 Hz confirmed; WaveformPresetHarmonics verified - Source code: `cmd/netsynth/main.go` — current Load() call site; listIfaces early-exit pattern verified - Experimental: BurntSushi/toml `Undecoded()` behavior with `[[rules]]` — verified by running test code against go.mod-pinned v1.6.0 - Experimental: FNV-32a frequency distribution — verified by running Go code; 24 distinct frequencies in 1200–2400 Hz range ### Secondary (MEDIUM confidence) - Source code: `config/config_test.go` — test patterns for writeTOML, Load() behavior; test style confirmed - Source code: `cmd/netsynth/main_test.go` — newTestCmd() pattern, flag registration test style confirmed ### Tertiary (LOW confidence) - None. ## Metadata **Confidence breakdown:** - Standard stack: HIGH — no new dependencies; all libraries verified against go.mod - Architecture: HIGH — all integration points verified by reading actual source code - Pitfalls: HIGH — Pitfalls 1, 2, 4, 5 verified by code inspection; Pitfall 3 experimentally verified as non-issue - Test patterns: HIGH — existing test file structure read directly **Research date:** 2026-03-26 **Valid until:** Stable — 90 days (pure Go, no external dependencies, stable TOML library)