Agent Bridge: a crossover engine for a finished tune, a probe that reads without changing anything, and a guide that asks before it answers - #172
Conversation
…ads without changing anything, and a guide that asks before it answers Four things a field run of the assistant on a hand-tuned car asked for. Asked about a 3 dB dip between the mid and the tweeter on one side, it cleared the PEQ, ran Auto crossover — which put the user's 48 dB/oct back to 24, moved two corners, levelled the gains cut-only and undid the timing the delays were set for — and the sum lost 20 dB. Every part below comes out of that run. tuneJunction, the crossover engine for a tune that already works. The wizard is built for a blank one: magnitude only under an ideal-alignment assumption, anchored on the conventional slope, gains levelled. This takes ONE junction by the package's own id, searches the lower block's low-pass and the upper block's high-pass — corner on the wizard's lattice, family, slopes — and scores each candidate on the pair's coherent sum at the CURRENT delays and polarity, through the whole current chains: the summation loss and its dip (MeasureJunctionSpectrum, the alignment score's own gated bins) plus the ripple of the sum, which the loss alone cannot see. The ranking reads every candidate on ONE band, an octave outside the window and the current corner — two candidates read on their own octave-each-side bands are not comparable, the car's own ripple differs between the bands by more than a corner decides, and on the reference session that artefact was a 0.6 dB phantom win — while each is read again on its own band, the package's, for the report. No slope is preferred; the current crossover stands unless a candidate beats it by 0.5 dB on the shared band AND reads no worse on its own, since a tuned junction is a decision the user made. Gains, delays, polarity, PEQ and every other junction stay; one crossover is written to both sides of both blocks, as the wizard writes one, and Undo AI import puts it back. The summary reports the edges before and after, per side the loss, dip and ripple, and what the best delay of the upper block would still take back — the figure that says whether runAutoDelay should follow, which the same reply may ask for. probe, the operation that changes nothing. Until now the only way to learn what a change would measure was to have the user apply it and undo it; the guide's diagnostic pass was three replies long for that reason. A reply now names a junction and up to ten variants, each a set of changes to that junction's own two channels stated exactly as the settings operations state them — gainDb, delayMs, invertPolarity, crossover, peq, in any combination, everything left out kept, every value held to the very limit the operation that writes it is held to, through the same validator path. Resonalyze measures each variant on COPIES and reports, per side: the loss, the dip, the ripple, the pair's cross-phase block and what the alignment would leave after re-running for that variant, beside the same figures for the junction as it stands. An empty peq bank is the bank cleared, so the whole diagnostic pass is one row. junctionDelay reports what a delay search would find and pick here; excessGroupDelay is the curve, requested in the reply rather than found in a menu. The answer comes back as one clipboard text, one paste. Probes run before anything else in an import, are the one row the review offers plain rather than with a warning, overwrite nothing and are overwritten by nothing, and survive a package the session can no longer vouch for — where every engine request is refused — because what they read is the session as it is now; the document says whether that session still matches. On the reference session a probe of one junction answered in 488 ms with the fingerprint unchanged: clearing that channel's bank takes the junction from −0.5 dB loss and a −1.8 dB dip to −1.2 and −5.4, so the bank is holding the sum together — the pass's question, answered with nothing applied. The guide asks before it answers, and it is half the size. It had grown into a rail — read the package, then work steps 1 through 8 — and a rail is what makes an assistant stop thinking. The first reply now reads only what gates every answer, says what it found including anything that BLOCKS work, and asks what the user wants: the system tuned from scratch, advice on the crossovers, the stage or the tonal balance, a look over a tune they already made, or something they hear in the car. The answer names a route through the same readings. Section 1 says outright that the guide is a map and not a script. The hard rules are marked "never" and cover only damage and claiming more than the measurement supports. 42 KB to 22 KB, 609 lines to 327: the schema is left to PROTOCOL.md, the engine mechanics likewise, and what stays is what a chat cannot derive. And the corner that kills drivers. Asked about a pair of 4-inch door midranges, the assistant called a 120 Hz high-pass fine — the measurement reaches there, so the number looked defensible. Excursion rises as the square of falling frequency, so the same SPL an octave lower costs four times the displacement, and a sweep at a polite level says nothing about the level the user listens at. A low corner is now judged on Xmax and rated power rather than Fs, cone size or where the measured band happens to reach; and the chain is read first, because where the system has a driver whose job that band is — an underseat woofer, a midbass — the band is handed to it, and the small driver's corner belongs where the bigger one takes over cleanly rather than as low as the small one can still be heard. Auto crossover's row says plainly that it knows no driver's limits. Guide 1.5. The reply JSON depth limit goes from 8 to 12: a probe variant's PEQ bank sits two levels deeper than a settings operation's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 78a575cb8c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| /// Where a tune reports how long its phases took, for a harness; null in | ||
| /// production. | ||
| /// </summary> | ||
| public static Action<string>? Trace { get; set; } |
There was a problem hiding this comment.
Remove the unused public trace hook
A repo-wide symbol search finds no caller for CrossoverJunctionTuner.Trace, including the tests, and it lacks the required reserve-API remark and coverage. Remove it rather than exposing mutable process-wide state solely for a hypothetical harness.
AGENTS.md reference: AGENTS.md:L60-L66
Useful? React with 👍 / 👎.
| .Select(candidate => new JunctionDelayProbeCandidate( | ||
| candidate.DelayMs, candidate.InvertPolarity, candidate.ScoreDb, | ||
| candidate.LossDb, candidate.DipDb, |
There was a problem hiding this comment.
Report the upper channel's final polarity
When the upper chain is already inverted, FindAlignmentCandidates returns InvertPolarity relative to the processed IR, so forwarding it directly as InvertUpper reports the opposite absolute state: false means keep the existing inversion, not make the channel normal. The probe document and junction-tune summary describe this field as the polarity the search would pick, so assistants can recommend the wrong polarity; XOR the relative result with upperChain.InvertPolarity, or explicitly expose it as a relative flip everywhere.
Useful? React with 👍 / 👎.
| wire.Variants?.Select(variant => new AgentProbeVariant( | ||
| variant.Label, | ||
| (variant.Changes ?? []).Select(change => new AgentProbeChange( | ||
| change.ChannelId, |
There was a problem hiding this comment.
Reject null probe collection elements before mapping
For malformed but valid JSON such as "variants":[null] or "changes":[null], this mapping dereferences the null element before CheckStrings can reject it. Because ReadOperation catches only JsonException, a single bad probe escapes AgentProposalParser.Parse as NullReferenceException and aborts the whole import instead of producing the intended rejected row; null-check each nested element through NotNull while mapping (the same issue also applies to null PEQ-band elements below).
Useful? React with 👍 / 👎.
| operation while the rest of the import carries on. `tuneJunction` runs | ||
| without a dialog, on one junction, and is described after the table. |
There was a problem hiding this comment.
Update the engine-request count after adding tuneJunction
Adding tuneJunction here leaves the introduction at lines 438–440 saying there are four value-free operations, three of which open engines, even though this section now describes the additional junction engine (and its table also includes probes). Update that introductory count and classification so protocol readers are not given internally contradictory instructions.
AGENTS.md reference: AGENTS.md:L155-L160
Useful? React with 👍 / 👎.
Ten variants per probe was a number, not a bound. Measured on a reference session a variant costs about 65 ms and 0.8 KB, so ten is a second and 10 KB — nowhere near anything that needed protecting, while what the limit actually protects is real: the readings run while the user waits, with no progress and nothing to cancel, and the answer is a text they have to paste. Worse, the cap did not bound that. It counted per probe, and a reply could send two probes — except that it could not, because probes inherited the engines' once-per-import rule and a second probe on the same junction was refused as a repeat. So a reply wanting fourteen variants of one junction had to drop four, and one wanting them of two junctions could have twenty. Both halves are fixed. A probe writes nothing, so a second one on the same junction is another QUESTION about it rather than a second run of an engine: the repeat rule now skips probes. And the count becomes a budget for the whole import — 24 variants however the reply splits them, about a second and a half and a text the size of a package — which is what the user actually spends. A reply that wants more searched is asking for the junction tune, and the refusal says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ide a probe refuses one row, and the trace hook goes Three from the automated review of the branch, all of them real. The polarity was relative and read as absolute. The alignment search works on the response the chain has ALREADY inverted, so its answer means "flip what I was given". The probe document and the junction tune's summary call the field the polarity the search would pick, which a reader takes for the channel's resulting state — and for a channel already running inverted the two are opposites, so a reply could have proposed exactly the wrong polarity. Both reports now XOR the search's answer with the chain's own inversion and say in the conventions that the figure is what the channel ENDS UP as. A null inside a probe took the whole import down. `"variants": [null]` and `"changes": [null]` are valid JSON that `required` does not catch, and the mapper dereferenced them; the parser catches JsonException, not NullReferenceException, so one malformed probe aborted the import instead of becoming one rejected row. Every nested element now goes through NotNull, whose JsonException is what the parser is built to turn into a refusal — the PEQ band elements too. And the trace hook is gone. It was a mutable public static that nothing in the solution called: it earned its keep while the panel's own timings were being measured, and AGENTS.md is right that a member with no caller is dead code, not a reserve. The protocol's engine-request introduction, which still said there were four value-free operations of which three open engines, now counts the junction tune and the probe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…at says only "busy" Everything the bridge does slowly used to happen behind an hourglass and a greyed-out button: gathering a package is a second or two on a large session, the excess group delay is a gated FFT and a minimum-phase reconstruction per channel, and an import can probe a junction, tune its crossover, search delays and fit a bank one after another. A cursor cannot say "still going" or "at what", so a run of ten seconds reads as a hang. AgentProgressDialog names the step under way, lists the last few behind it and moves a marquee bar. Copy for AI shows it while it reads the tune (and says so if the tune moved and it is reading again), Copy diagnostics names the channel each curve is being taken on, and an import reports every probe and every engine as it starts, in the words the summary will use afterwards. It is deliberately informational rather than modal. The steps that must not have the tune changed under them — Auto delay, Auto-tune — already disable the panel themselves, and one of them opens a window of its own that a modal progress box would have to fight for the foreground. And there is no Cancel: every step behind it is a single computation the panel cannot interrupt without leaving a half-written tune, and a button that cannot keep its word is worse than none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… them The commit-time check had a hole this branch opened. Prepare re-reviews the ticked rows against the session as it is and refuses when it has moved, and Apply then writes without checking anything — that division was safe while the only thing between them was a modal message box. Then the probes went in front of the writes: they take seconds, the progress window over them deliberately takes nothing away from the panel, and the rows being written had been judged before they started. A user who moved a gain while a ten-variant probe read the junction would have had the old values written over it. The writing half is now its own method, and it opens with the same Prepare — against a snapshot taken at that moment. When the tune moved, nothing is written, no undo is armed, and the summary says so; the probes stand, because they only ever read and their answer describes what they read. A test pins both halves: the guard refuses and leaves the edited gain alone, and the rows prepared before the probes — the list the import used to carry across them — still write straight over that gain when applied, which is what the guard is between. The lesser half of the same report: several readings of one document could straddle an edit. They are not refused (each is true of what it read), but the document now carries sessionChangedWhileReading, and the summary says it, so a reader never compares figures from two states without being told. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd the batch The flag that says a document holds readings from more than one state was computed from one fingerprint before the batch and one after it, and the protocol claimed its absence meant one session throughout. It did not: a tune changed and changed back — the reading in between taken off the other state — matches at both ends and passed as steady. The comparison now happens at every reading's boundary and accumulates. Each reading is taken off snapshots of its own, so an edit made and undone entirely within one changes nothing that reading holds and is rightly not flagged; an edit that outlives a reading is caught at the boundary it crossed. The protocol and the document's own conventions say exactly that now, instead of the stronger claim they could not keep. A fingerprint costs nothing beside a junction reading, so the extra samples are free. Not covered by an automated test: the case needs an edit to land between two readings while the loop is running, which nothing in the panel offers a seam for, and the flag is informational — no probe writes anything, so the worst a missed flag can do is let a reader compare two figures without being told to be careful. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d an invented number Four corrections from a reading of 1.5, all of them places where the guide told an assistant two different things. The junction tune's row said to ask for runAutoDelay in the same reply when the after-delay reading is far below the loss at the current timing; the paragraph below says one engine per reply, and that a step which reads worse than the baseline is undone before the next. Both cannot be obeyed, and the second is the one this guide is built on: the tune is asked for, the package is read, and the realignment goes in the NEXT reply. The import can still carry both — the protocol says so, and now says why the guide separates them anyway: the second step cannot be judged while the first is unread. "Request each engine once — autoTunePeq per channel, tuneJunction and probe per junction" contradicted the paragraph above it, the validator and its tests: a probe writes nothing and the once-per-import rule deliberately skips it. The rule now names what it is about — the engines that CHANGE something, per their own scope — and says probes may repeat as the question needs, inside the reply's variant budget. The excursion rule said not to judge a corner by cone size and then judged one by cone size: "a 4-inch door midrange asked for real output below roughly 150–200 Hz runs out of excursion". It now reads as what it is — a reason to CHECK a driver of that class, not a corner to propose from its size — and sends the reader to Xmax, power, enclosure and the level the user listens at. And "say in dB what an engine would win" asks for a number the assistant cannot have before a search runs, which invites exactly the invention the guide forbids elsewhere. It now asks to quantify the PROBLEM and what the data supports, says never to invent a predicted gain, and points at the probe, which is where a measured one comes from. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A junction's two edges need not share a frequency — the reference v6 car crosses its mid and tweeter at 1610 and 1800 Hz — and a probe variant states the two channels separately, so it could always ask for corners apart. What it could not do was read them properly: the band an entry was measured on came from the lower channel's low-pass alone, and the shared band every entry is compared on was built from those same lower corners, so an upper edge sitting above them fell outside the reading that was supposed to cover the pair. A variant's handover is now the geometric middle of its two edges when both are there (the lower one alone when it is the only one), and the shared band is drawn from every edge of every variant rather than one per side. With equal corners — the common case — nothing changes; with corners apart, the entry is read around the pair instead of under half of it. Pinned on a synthetic pair 2 kHz against 8 kHz: the entry keeps both edges, reads at 4 kHz between them, the shared band reaches past the upper one, and the gap shows up as ripple. On the v6 mid/tweeter junction (BW48 1610/1800) a probe of three variants — corners apart each way, and two different families and slopes on the one junction — answered in 432 ms with the session unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… the guide forbids The correction went into §6 and stopped there. Rule 3 of the rules that travel inside every package still said to "say in dB what an engine would win before asking for it" — a number an assistant cannot have before a search has run, and the one rule a chat that cannot fetch the guide reads instead of the guide. So the shortest, most-read text asked for exactly the invention the long text forbids. Both copies now say the same: quantify the problem and any available improvement in dB where the data supports it, never invent a predicted engine gain, and judge every step against the user's tune rather than the step before. The mirror check confirms the guide's copy of the rules is still word for word what the package carries. The protocol's description of a probe entry's own band also spells out what follows from reading around the handover: with two edges more than two octaves apart — a hole rather than a handover — the shared band is the only one that contains them both, which is where the comparison already lives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A variant may give a midrange a new crossover, bank or gain, and the reading covers only the junction the reply named: a change that wins at C-D can spoil B-C, and nothing in the answer said so. Leaving that to the assistant's memory is the weakest place to leave it. The report now names those junctions itself. NeighbourJunctionIds walks every adjacency of every side and group and keeps the ones a changed channel takes part in, each confirmed through ResolveJunction, so the field never points at a junction a probe would refuse; the guide and the conventions say to probe them under the same variant before proposing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…robe Pooled over the probe, affectedJunctions said something no entry meant: a probe of C-D whose one variant rewrites C and whose other trims D named B-C and D-E together, and whichever won, the assistant was sent to both. The guide's answer to that list — probe them under the same variant — was not even executable: a variant states the named junction's two channels, and C is not one of D-E's. Each entry now carries the junctions ITS OWN changes reach, the baseline none; the guide and the protocol say to carry the change to the channel the two junctions share, which is the part of the entry that travels. The test now sends two variants that touch opposite ends and asserts the one neighbour sits on the entry that earned it — it fails on the pooled list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
From the field run of the assistant on a hand-tuned car (the mid/tweeter dip that ended in the wizard putting BW48 back to BW24, moving two corners, levelling the gains and losing 20 dB).
tuneJunction— the crossover engine for a finished tuneThe wizard is built for a blank tune: magnitude only, ideal alignment assumed, anchored on 24 dB/oct, gains levelled. There was no engine for one junction of a system that already works, where the phase the two drivers put into the junction at their current delays is the whole question and a steeper slope is a legitimate answer the magnitude cannot see.
tuneJunctiontakes a junction by the package's id (left:C-D), searches the lower block's low-pass and the upper block's high-pass — corner on the wizard's lattice, family, slopes — and scores each candidate on the pair's coherent sum at the current delays and polarity, through the whole current chains (PEQ included), on every side the pair is measured on: summation loss, dip (MeasureJunctionSpectrum, the alignment score's own gated bins) and the ripple of the sum. Ranking reads every candidate on one shared band (an octave outside the window); each is read again on its own octave-each-side band, the package's, for the report. No slope is preferred; the current crossover keeps its place unless a candidate beats it by 0.5 dB on the shared-band score and reads no worse on its own. Gains, delays, polarity, PEQ and other junctions stay; one crossover is written to both sides of both blocks; Undo AI import puts it back. The summary reports the edges before/after, per side the loss, dip and ripple before/after, and what the best delay of the upper block would still leave — the figure that says whetherrunAutoDelayshould follow (same reply, runs after).CrossoverJunctionTuner(dsp/) is pure, pinned on synthetic pairs; the headless run on the panel is pinned end-to-end with Undo. The review resolves the junction as the panel's read-outs would (both blocks measured, in the sum, not bypassed, one group, spectral neighbours that hand over; the session snapshot now carries zone/enabled/bypass), holds window/families/slopes to what the tuner can use, refuses a tune beside the wizard and a hand-writtensetCrossoveron either block, and counts once-per-import per junction.probe— a reply may ask what a setting would doThe only way for the assistant to learn what a change would measure used to be to have the user apply it and undo it; the guide's diagnostic pass was three replies long for that reason.
probereads and writes nothing: a reply names a junction and up to ten variants, each a set of changes to that junction's own two channels stated exactly as the settings operations state them (gainDb,delayMs,invertPolarity,crossover,peq— any combination, everything left out kept, every value held to the same limits through the sameCheckValuepath). Each variant is measured on copies and reported per side: summation loss, dip, ripple, the pair's phase block, and what the alignment would leave after re-running for it — beside the junction as it stands (current). An emptypeqbank is the bank cleared, so the whole diagnostic pass is one row.junctionDelayreports what a delay search would find and pick here;excessGroupDelayis the curve, requested in the reply instead of the menu.The answer arrives as one clipboard text (
resonalyze.agent-probe), one paste. Probes run before anything else in the import, are the one row offered plain rather than with a warning, overwrite nothing, and survive a package the session cannot vouch for (the document carriessessionMatchesPackage). Each entry is read on its own junction band and on one shared band, since two corners compare only on the shared one. The reply JSON depth limit goes 8 → 12: a variant's PEQ bank sits two levels deeper than an operation's.On the reference Passat session, a probe of
left:C-Danswered in 488 ms with the session fingerprint unchanged: clearing C's bank takes the junction from −0.5 dB loss / −1.8 dB dip to −1.2 / −5.4 — the bank is holding the sum together, which is exactly what the pass exists to ask.The first reply asks what the user wants
The guide put the assistant on rails: read the package, then work steps 1–8 whatever the user pasted it for. Now the first reply reads the two steps that gate every answer (is the measurement trustworthy, what does it support), says what they found — including anything that blocks work — and asks: tune from scratch, advice on the crossovers, on the stage, on the tonal balance, a look over a tune they already made, or something they hear. The answer names a route through the same steps, and the guide tabulates them. Notes that already say what the user is after are the answer; a second package in a conversation is not a first one. Rule 2 of the rules that travel inside every package says the same for chats that cannot fetch the guide — and rule 7 (ask a probe rather than have the user apply and undo) was in the guide's mirror but missing from the rules themselves, which a mirror check caught.
Guide 1.5: driver limits, a looser leash, half the size
Asked about 4-inch door midranges, the assistant called a 120 Hz high-pass fine — the measurement reaches there. Excursion rises as the square of falling frequency, so the same SPL an octave lower costs four times the displacement, and a sweep at a polite level says nothing about the level the user listens at. The guide now judges a low corner on Xmax and rated power rather than Fs, cone size or the measured band's edge; names the case (a 4-inch door mid below roughly 150–200 Hz is out of excursion, not bandwidth); and looks at the chain first — where the system has an underseat woofer or a midbass for that band, the band is handed to it and the small driver's corner belongs where the bigger one takes over. Auto crossover's row says plainly that it knows no driver's limits.
The rails are gone: §2 asks what the user wants and the routes say which readings that answer needs, in whatever order suits. §1 says the guide is a map, not a script. Hard rules are marked "never" and cover only damage and over-claiming. 42 KB → 22 KB, 609 → 327 lines, with the schema left to PROTOCOL.md and nothing load-bearing dropped. Guide version 1.5.
What else the guide gained
The judgment the field run was missing: the wizard is for a tune with no crossovers yet and the junction tune for a finished one; on a tune that already works the user's tune is the baseline every step is judged against, a step that reads worse is undone before the next, one engine per reply, the win and the risk stated in dB before asking, one metric never leads, a budget for experiments, and "this is where it should be" as a right reply; after any engine, every change it made is named, asked for or not; the gain balance and a tweeter trim are the user's stage, not a number to level; a channel already within 1.5 dB of the target is tuned. The excess group delay reads the measurement and not the chain, so it is the same with any bank in place — it was being compared before and after a PEQ change, which it cannot show. Rules 2, 3 and 7 of the rules that travel inside every package carry the same, for chats that cannot fetch the guide, and a mirror check now confirms the guide's copy of them is word for word (it caught one that had drifted).
Checks
🤖 Generated with Claude Code