On the hrt branch the derived period is not just a spacing: it is the
burst width ClockOffset latches against, so a wrong one shifts the whole
trace by an amount that is usually too small for kRecalibThresholdS to
ever heal. Three routes to a wrong period were open.
Packet loss. elapsed spans every packet since the last one seen, but it
was divided by prevAccCount alone, so a lost datagram scaled the period
by the whole counter gap. Since a burst is anchored on its LAST element,
too wide means it ends in the FUTURE: +22.5 ms for one loss, +225 ms for
ten, at 10 samples per 25 ms packet, mis-spacing 2.7% of all samples at
1% loss. The declared branch already reads the counter for exactly this;
the hrt branch now does too.
Producer restart and reorder. Both leave elapsed at zero, so no period
can be measured -- and the restart packet is also the one that re-latches
after offset.reset(). Falling back to kDefaultDt is only right at 1 kHz;
measured standing displacement was +13.5 ms at 10 samples per 25 ms and
-89 ms at 100 per 10 ms. Remember the last measured period instead.
A stray hrt == 0 packet re-enters the warm-up branch, which spans from
packetBurst's lastPacketWall -- a field the hrt branch never wrote, so it
still held the start of the session. After 153 packets that emitted a
burst 3.8 s in the past, worse the longer the scope had run.
Also: rule 2 with no declared rate stacked every element of the array on
one instant (as UDPSourceSession.cpp:522 does, harmlessly, for a
host-local consumer). Spread it from consecutive time-signal anchors,
which measure the burst on the producer's own clock.
Reverts the previous commit's wallElapsed <= 0 change: it was measurably
inert -- the step floor two lines below already yields the same number --
and its comment claimed a divergence it did not stop.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
An undeclared-rate accumulated scalar is served by packetBurst until
HrtRateFit is ready, then by the hrt branch. The two place a burst
differently -- packetBurst ends it at wallNow, the hrt branch at
wallNow - (nElems-1)*hrtDt -- and the warm-up left no state behind, so
the handover packet skipped the monotonic clamp and stepped the signal
backwards by up to a burst width (-6.5 ms at 10 samples per 2.5 ms
packet, -0.99 s at 1000 samples per 10 ms).
Seeding lastEmitted* alone would only restore ordering. Without
lastAccHrt/prevAccCount the first hrt packet also has no tick delta to
measure, falls back to kDefaultDt and latches ClockOffset against a
burst width that is wrong whenever the cadence is not 1 kHz -- 89 ms of
permanent displacement at 100 samples per 10 ms, below the
recalibration threshold that would otherwise heal it. Seed both.
Also close the wallElapsed <= 0 bypass in both branches: skipping the
bleed cap when the wall has not moved hands back the full proportional
advance, letting a run of same-tick arrivals gain lead while no wall
time passes at all.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Eight review findings on FrameDecoder's accumulated-scalar rule.
The squeeze that pulls a leading timeline back was expressed as a fraction of
the NOMINAL burst width, which cannot converge: inside one timestamps() call
the wall clock is frozen, so any positive step raises the lead measured at that
instant, and the lead only falls because the wall advances between packets. At
the kMinBleedFactor floor the timeline still gained 0.05 * nominal per packet,
so a declared SamplingRate of 30 against a producer really flushing 10 samples
at 1 kHz ran away without bound (667 s of lead after 1000 s of stream). Cap the
burst's total advance at half the wall time really elapsed since this signal's
previous burst instead, and the lead strictly falls for any declared rate.
SigState gained lastEmittedWall for that reference; lastPacketWall could not be
reused because it belongs to packetBurst.
The hrt branch contributed zero elapsed for a late datagram but still wrote the
hrt reference back to it, so the next packet's delta spanned two intervals and
fabricated a whole extra packet of producer time — permanently, since the
monotonic clamp discards the correction ClockOffset would have made. Reordering
is reachable in production: udps_client.c only counts counter gaps. Simply
never regressing the reference is not the fix either, because a producer restart
would then freeze the signal forever, so the two are now separated by the size
of the backward jump.
The hrt branch's clamp was also one-directional, reintroducing on that branch
exactly the defect the declared branch's squeeze exists to prevent: a backward
wall step (NTP, suspend/resume) left a permanent lead. It now shares the same
wall-elapsed cap.
Also: anchor an hrt-branch burst's LAST element on arrival, matching the
declared branch, so two accumulated scalars in one scope do not sit a burst
apart on the shared X axis; treat a non-finite samplingRate off the wire as
undeclared, since +inf produced a 0.0/0.0 factor the floor could not catch and
turned every stamp NaN; write lastCounter on the hrt branch so duplicate
datagrams are dropped there too; and correct two comments that argued for the
current code with claims that are false (a counter-gap clamp reaches the
opposite outcome, not the same one earlier, and the squeeze's steady state is a
sawtooth, not a fixed offset).
Seven new tests, each proven non-vacuous by sabotage; 54 pass. Plan document
Task 4 re-synced and its stale test count and "agree on the same stream" claim
corrected.
Round 4 of Task 4 review. Four defects in FrameDecoder's rule 3:
- The undeclared-rate (hrt) path positioned each burst at an ABSOLUTE
hrt/ticksPerSecond(). hrt counts from the producer's boot, so it is ~1e11
ticks by the time a scope attaches, and the rate is refitted every packet
with a few parts in 1e4 of wobble. The product is tens of milliseconds of
jitter in BOTH directions -- not merely imprecise, non-monotonic. Integrate
short tick deltas into accProdSec instead and let ClockOffset latch the
epoch that leaves behind.
- The lead bleed used a fixed 0.9 factor, which converges only while the
declared rate is within ~10%. Squeeze proportionally to the excess instead
(floored at kMinBleedFactor), settling it in a single burst.
- A single-sample flush fell through to the plain-scalar rule, dating it from
arrival and leaving lastCounter stale so the next real burst reinstated a
hole that never existed. Accumulate mode flushes on a timer, so a short
cycle legitimately yields one sample; keep it on the chain.
- kMaxCounterGap was inert: an absurd gap yields an absurd prediction that the
arrival backstop already rejects, and no input can distinguish the two
rules. Removed rather than left implying a behaviour it did not have.
FrameDecoder.h now states the deliberate divergence from StreamHub -- which
converts hrt with the LOCAL MARTe timer frequency, valid only because it runs
on the producer's host -- and why a remote scope's drift is irreducible.
Three new tests, each sabotage-proven non-vacuous: producer restart, short
flushes staying on the chain, and a 20000-packet undeclared run after a
day of producer uptime that asserts SPACING as well as ordering (the
monotonic guard alone restores order while leaving positions wrong).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Round 3 of the Task 4 review. Three defects, all in FrameDecoder rule 3.
The resync backstop was one-directional. `predicted` is never below
lastEmittedEnd + dt, so rejecting a correction that would step backwards
meant only a LAGGING chain could ever be pulled back; a chain running fast
drifted ahead without bound. Two hosts' crystals differ by tens of ppm, so a
declared SamplingRate is always slightly wrong in one direction or the other
and this is certain on a long session. A leading timeline cannot be corrected
in one burst without going backwards -- lastEmittedEnd is by definition past
arrival -- so the excess is bled off by drawing each burst 10 % narrower until
the timeline is back inside the threshold.
A repeated packet counter was treated as a normal packet. The C client
de-duplicates fragments only, so an unfragmented update reaching a host that
joined the group on two interfaces was emitted twice, doubling the values and
advancing the timeline by a burst that never existed.
The samplingRate == 0 path differenced two HrtRateFit::toSeconds() results.
toSeconds() divides an absolute tick count -- ~1e11 on a producer that has
been up a day -- by a rate refitted on every packet, so its few-parts-in-1e4
wobble arrives multiplied by the whole elapsed epoch: tens of milliseconds of
jitter on a value whose consecutive difference is a few milliseconds. Raw
ticks are differenced instead, anchored on the first usable packet so the
wobble applies only to the interval since attach.
The existing hrt-gap test could not have caught the last one: its 10 ms
producer period made the expected answer exactly kDefaultDt, so a decoder
that derived nothing passed. It now uses 25 ms.
Four tests added, all sabotage-proven. The plan is updated to match.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Review found the decoder was estimating something the wire states exactly.
FrameView::counter increments once per update, so a gap of g means g-1 lost
datagrams; reinstating their duration restores the hole precisely, with no
threshold and no dependence on arrival time. The arrival-anchor comparison
survives only as a backstop for what the counter cannot express — a producer
restart, a counter stuck at zero, a wrong declared rate — and can no longer
step a signal's timestamps backwards, which the ring and trigger forbid.
Also from review: guard the time-signal lookup against a frame carrying more
signals than the installed table, and give FrameBuilder a counter parameter.
Leaving it at zero had hidden the counter rules from every test, and made the
hrt-gap test vacuous — under uniform arrivals the hrt path and packetBurst
agree by construction, so it could not tell which branch answered. Its
arrivals now carry zero-mean jitter.
Each new assertion was proven non-vacuous by sabotage: dropping the gap term,
the backward guard, or the hrt branch fails exactly its own test.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Forward-chaining each accumulated burst onto the previous one suppresses
arrival jitter, but an unchecked chain never recovers: one lost datagram, or a
declared sampling rate that differs from the producer's real one, dates every
later sample early for the rest of the run. The chain is now a prediction,
compared each packet against the arrival anchor and abandoned beyond
kBurstResyncThresholdS, which bounds the error instead of accumulating it.
Plan amended so the hrt-fit fallback (unusable here: the fit needs 32 packets
and is itself corrupted by bursty arrivals) cannot come back.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implements FrameDecoder with five timing rules that mirror
UDPSourceSession.cpp: FullArray (per-element time signal), FirstSample
and LastSample (rate-spread from anchor), accumulated scalar with
declared rate (forward-chain anchoring, immune to arrival jitter), and
PACKET burst (backward-span from previous arrival). 9 new tests, 38
pass total.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The fit keeps the slope and discards the intercept, so the result counts from
the producer's boot. Tasks 4 and 7 compose it with ClockOffset::map, which is
correct, but the bare name invites passing it straight to a plot axis.
Also unwrapped the stalled-clock assertion from behind `if (fit.ready())` —
that branch never runs, so the test confirmed nothing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The jitter test fed a receive timestamp that went backwards (1001.02 then
1000.97), putting the second reading 1.03 s from the prediction — twice the
threshold, so not jitter under any reading. That is a digit slip for 1001.97.
It had been worked around by making the threshold one-sided, which passes the
test but never fires when the producer's clock steps forward: the prediction
stays ahead of the wall clock, the error stays negative, and the trace sits in
the future for the rest of the run. Restored std::fabs, corrected the test data,
and added the forward-jump case that the one-sided version silently failed.
Plan amended so the bad data does not come back.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds TimeBase.h/cpp with ClockOffset (latched wall-clock offset with
one-sided recalibration on positive drift only, so early-arriving packets
do not wobble the trace) and HrtRateFit (sliding-window OLS that recovers
an unknown hrt tick rate from receive timestamps). Also adds
TimeSignalScale() which maps UDPS type codes to seconds-per-count.
9 new tests, all 28 pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Gap 1: closeLeaf was only tested closing the first sibling; added test
closing the second sibling to cover the else branch of parent->a.get()==leaf.
The test verifies the correct sibling survives with its signal intact.
Gap 2: closeLeaf was only tested at depth 1 (root's direct children);
added test with depth-2 leaf (in right subtree) to exercise findParent's
recursive search in both subtrees. Tests that the correct leaf is promoted
and remaining signals are preserved.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The ramp data in EmitsPointsInTimeOrder never produced a bucket whose maximum
preceded its minimum, so an implementation ordering the emitted pair by value
instead of by time would have passed. Replaced with an explicit two-bucket case
whose second bucket reverses the order.
file(COPY) is a hard configure error on a missing source, so a checkout without
the sibling StreamHub resources failed to configure despite the ASCII-icon
fallback the block above had just selected.
Plan amended to match on both points.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Each of these would have surfaced as a compile/link failure or a reviewer
rejection mid-execution, when the implementer holding the task has no view of
the neighbouring task that contradicts it.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Eighteen TDD tasks covering the build scaffold, pane tree, time base, frame
decoding, trigger FSM, threading, UI, persistence and export, so the scope can
be built task-by-task with a reviewable deliverable at each step.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
A bench scope that attaches straight to one UDPStreamer through the
standalone C client, so it can be dropped on a machine with no StreamHub,
no Go and no browser. Records the decisions that are easy to get wrong:
the time-base rules must follow UDPSourceSession rather than the C
library's arrival-time estimate, decimation must be min/max rather than
LTTB so glitches survive, and the ring must be sized past the trigger
window by an explicit harvest margin.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Consuming a UDPStreamer feed so far meant either linking MARTe2 (UDPSClient)
or writing Go (Common/Client/go/udpsprotocol). Common/Client/c fills the gap
for plain C/C++ integrators: two files depending on nothing but libc and BSD
sockets, covering the whole receive path — CONNECT, fragment reassembly,
CONFIG/DATA decoding with dequantisation, keepalives and silence-triggered
reconnect. No threads are spawned; udps_client_poll() does all the work and
runs every callback, so it drops into an existing event loop unsynchronised.
Verified against run_udp_producer.sh at 1 Msps: unicast (120 MiB, no loss) and
multicast with 12-fragment cycles (116k datagrams, no loss).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
UDPSClient::ConnectMulticast was calling the single-arg BasicUDPSocket::Join,
which forwards NULL as the local interface and lets the kernel bind to
INADDR_ANY. On a multi-homed host (or when the server sends on loopback via
Interface = "127.0.0.1") the client joins the wrong interface and silently
receives nothing.
Fix: read the optional Interface key inside the useMulticast block in
UDPSClient::Initialise; in ConnectMulticast call the two-arg
Join(group, interface) when Interface is set, and fall back to the one-arg
call otherwise to preserve the existing INADDR_ANY behaviour for configs that
omit it.
Forward the new optional Interface key through UDPStreamerClient (read from
DataSource config, written into the UDPSClient ConfigurationDatabase only
when non-empty). Extend the "Joined multicast group" log to report the
interface name or "default".
Regression test TestExecute_MulticastReceivesDataOnInterface: mock TCP
control listener + multicast UDP DATA socket with IP_MULTICAST_IF set to
127.0.0.1, verifying a uint32 value of 424242 reaches DataSource signal
memory. Confirmed FAILED without the Join fix and PASSED with it.
Suite: 133/133 (was 132/132 before this commit).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
UDPSServer parses Interface with inet_addr() to set IP_MULTICAST_IF, so
a name like "eth0" yields INADDR_NONE and Initialise fails. Every
example in UDPStreamer.md used "eth0", so anyone following the docs hit
that failure; the .cfg files in Test/ already used a dotted-quad.
Replace the examples with 192.168.1.10, state the dotted-quad
requirement in the parameter table and the Multicast section, and note
that receivers must join on the matching interface or they silently
receive nothing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Commit 3e0a481 made Interface mandatory for multicast in both
UDPStreamer::Initialise and UDPSServer::Initialise and updated
Docs/UDPStreamer.md, but left the GTest configs untouched. All four
multicast tests have failed since.
Add Interface to the five multicast configs. The field is parsed with
inet_addr(), so it takes a dotted-quad, not an interface name; 127.0.0.1
keeps the tests self-contained and off the LAN.
TestInitialise_MulticastMode_InvalidDataPort was passing for the wrong
reason: UDPStreamer.cpp rejected it on the missing Interface before the
DataPort == Port check could run. It now proves what its name claims.
TestExecute_MulticastConnectDataDisconnect additionally needed the
two-argument Join(): the single-argument form passes INADDR_ANY, so the
reader joined the default-route interface while the server sent on
loopback via IP_MULTICAST_IF, and the DATA datagram never arrived.
GTest: 132/132 (was 128/132), multicast subset stable over 3 runs.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
These subagent handoff artifacts were committed before
.superpowers/sdd/.gitignore took effect. They are ephemeral
scratch, not project content.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Per-signal data calibration (value = raw * scale + offset) with an optional
unit override, plus sources+calibration persistence in a single config file,
implemented identically in the Go hub and the C++ StreamHub.
- calibration.js: fix baseSignalName('[0]') parity with Go/C++ (>= 0 not > 0)
- calibration.test.js: add assertions for '[0]' edge case in two existing tests
- app.js: remove stale typeof guard around refreshVScaleMenu (always defined)
- app.js: call refreshTrigThresholdField on trig-signal change (both assignment sites)
- index.html: drop maxlength='16' on unit input; normaliseCal is the sole enforcer
- configcheck/main.go: delete dead nextOneOf function (no callers)
- hub_calibration_test.go: delete orphaned waitBroadcast comment (function never existed)
- calibration.go: correct arrayIndexSuffix comment to document known Go/C++ difference
- Docs/StreamHub-API.md: add calibration entry count and unit byte limits to §5 table
- spec: fix configReloaded missing path field, '16 chars'→'16 UTF-8 bytes', StreamString→char[], chain scenario→configcheck program, four→five new frames
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Finding 1 (HandleReloadConfig data loss): move ClearCalibration inside
LoadSourcesFile so the table is wiped only after a successful fread.
Adds a clearCalibration bool parameter (default false); the reload path
passes true, the startup path passes false.
Finding 2 (JSON injection via unit/source/signal): add JsonEscape()
static helper (escapes \", \\, \n \r \t, and \u00XX for other control
chars). Applied at all three emission sites: BroadcastCalibration,
HandleSaveSources, and BroadcastSources (label). Teach JsonGetString to
unescape the same set on read, so values round-trip correctly.
Finding 3 (%.17g verbosity): add ShortFloat() static helper that tries
%.15g then %.16g then %.17g, stopping at the first precision whose
strtod() output compares equal to the original. Applied at both float
emission sites. 0.1 now prints as "0.1", not "0.10000000000000001".
Minor: fix two inaccurate comments in StreamHub.h — the CalibrationEntry
rationale (not a 133 MB / address-limit issue; the real reason is no
per-entry heap churn, STL-free, trivially copyable) and "chars" to "bytes"
for the unit cap.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Finding 1 (Critical): replace "16 chars" with "16 UTF-8 bytes" in the
setCalibration unit field description (StreamHub-API.md) and the Cal·Unit
toolbar row (WebUI.md); note that multi-byte characters consume more than
one byte and that truncation never splits a character.
- Finding 2 (Important): correct the claim that a sources broadcast after
reloadConfig is conditional on new sources being added — that is true only
of the Go hub. The C++ hub calls BroadcastSources() unconditionally on
success. Both the reloadConfig command description and the configReloaded
event description in StreamHub-API.md are updated; the Reload bullet in
WebUI.md is updated with a brief note. Clients must tolerate an unsolicited
sources frame after any reload.
- Finding 3 (Minor): the configSaved failure example used "no SourcesFile
configured", which matches neither hub. Corrected to the C++ form
"no sources file configured" and added a note that the exact error text
is not part of the protocol contract (Go uses "no sources-file configured").
Source evidence: calibration.go (maxUnitLen, len(), rune-repair loop),
StreamHub.cpp (kMaxUnitLen, byte strncpy, HandleReloadConfig unconditional
BroadcastSources, HandleSaveSources error string).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Update Docs/StreamHub-API.md (new setCalibration/reloadConfig commands,
calibration/configSaved/configReloaded events, §4 config file format),
ARCHITECTURE.md §6 (updated command/event tables and Config File Format
subsection), Docs/WebUI.md (Cal row in V-Scale Toolbar, Sources & Config
sidebar section). Also corrects the spec's Validation sentence to match
the shipped cal-invalid border behaviour instead of silent field revert.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Renames the "Add Source" section to "Sources & Config" and replaces the
fire-and-forget "Save list" button with Save and Reload, plus a one-line
status area that renders the hub's configSaved/configReloaded ack.
onConfigAck replaces the no-op stub Task 7 left behind. It branches on the
frame type because configSaved carries a path and configReloaded does not,
and surfaces the hub's error text on failure rather than failing silently.
The status is kept outside the DOM because buildSidebar() recreates this
section on every sources broadcast.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Finding 1: revert the comma/quote strip added in 3cb998c from
normaliseCal() in calibration.js. The strip broke byte-identical parity
with Go CalConfig.Normalise and C++ StreamHub::SetCalibrationEntry, both
of which only trim whitespace and cap at 16 UTF-8 bytes. Delete the
companion test that asserted the now-removed behaviour (suite returns to
18 tests).
Finding 2: fix the actual CSV-safety problem at the point of use in
exportAllCSV() in app.js. Header cells (time column and signal columns)
are now RFC 4180-quoted: wrapped in double quotes with any embedded
double quote doubled. This safely handles units or signal names that
contain commas or quotes without touching normaliseCal.
Finding 3: update two stale comments in app.js that called the trigger
threshold or rawFromNorm result 'raw' — Task 9 moved trig.threshold into
calibrated units throughout, so the comments now say 'calibrated'.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
refreshVScaleMenu() was unconditionally removing the cal-invalid class
from all three calibration inputs, which silently cleared the red-border
error indicator on a field the user was editing whenever a hub
calibration broadcast arrived. Apply the same focused-element guard
already used for .value writes so a rejected value's error styling
persists until the user corrects it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds the Cal row (Scale / Offset / Unit / Reset) to #vscale-menu, its
CSS, and the refreshVScaleMenu() / commitCal() logic that wires it to
calTable and the hub via setCalibrationWS.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TextEncoder always emits well-formed UTF-8, so truncation can strand at
most a lead byte plus three continuations — one repair pass covers it.
The C++ hub needs a loop because its input is raw bytes off the wire.
Also drops a dead variable from the byte-boundary test.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
normaliseCal was using String.length/.slice() (UTF-16 code units), so
multi-byte characters like °, Ω, µ could slip through oversized. Now uses
TextEncoder to slice at 16 bytes, then repairs any incomplete trailing
UTF-8 sequence by walking back over continuation bytes to find the lead
byte and dropping the incomplete rune — exactly mirroring Go's
utf8.DecodeLastRuneInString loop and the C++ walk-back in
StreamHub::SetCalibrationEntry. Adds 4 new test cases covering the
non-ASCII/boundary scenarios, and corrects the canonical test command in
the task-6 report to 'cd Client/udpstreamer && node --test'.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implements Calib JS module (calibration.js) with affine transform primitives,
CalTable, and normaliseCal matching Go CalConfig.Normalise semantics; 14 node
--test cases all pass. Loads before app.js in index.html.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fix round 1 review findings:
1. Frame-matching strategy: add protocolFrameTypes set; next() now fails immediately
on an out-of-order protocol frame instead of silently discarding it. Ambient
frames (data, stats, triggerState, monotonicState) are logged and skipped.
nextSkipping() accepts explicitly-listed protocol frames that legitimately differ
in order across hubs (connect-time "sources" before "calibration" in C++).
2. BroadcastSources documented exception: the extra "sources" frame C++ emits after
reload is accepted and logged as [KNOWN DIFFERENCE] with full rationale; the
report recommendation to remove it has been retracted (it is required for correct
client-side source-list updates after reload-with-new-sources).
3. Sort-order constraint exercised: three calibration entries submitted in
reverse-sort order (src2/Beta, src1/Zeta, src1/Alpha); each broadcast and the
saved config file are asserted to be sorted by source then signal.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The single-pass repair left invalid bytes when the candidate lead byte
had class 0 (illegal 0xF8-0xFF bytes, or a bare continuation byte
reached after the 3-byte backward-scan cap). Convert to a loop with a
`cut` flag mirroring Go's loop: each iteration either makes no cut
(exits) or strictly reduces ulen by >= 1 byte (terminates in <= 16
iterations). Also treat expected==0 as a cut target, matching Go's
behaviour of stripping any byte that decodes as an invalid one-byte
sequence.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous walk-back in SetCalibrationEntry was unconditional, corrupting
short valid units ending in multi-byte characters (e.g. Omega, mu, degree).
Also failed to drop an orphaned lead byte left after stripping continuation
bytes. Restructured to use a 256-byte staging buffer so truncation can be
detected, then repair runs only in the truncation branch. Algorithm now
matches Go CalConfig.Normalise() exactly: scan back over continuation bytes
(up to 3), find the lead byte, derive expected sequence length, cut if
incomplete. Covers all cases: orphaned continuation, orphaned lead, cut on
lead byte.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Finding 1: Add TrimInPlace helper; apply trim→strip-[i]→empty-check order
in SetCalibrationEntry (matching Go Normalise()), so whitespace-padded
source/signal from WS clients normalise identically to config-file loads.
Finding 2: Trim unit before truncating to kMaxUnitLen, then walk back
continuation bytes (0x80-0xBF) to avoid leaving a partial UTF-8 rune,
matching Go's utf8.DecodeLastRuneInString loop.
Finding 3: BroadcastCalibration and HandleSaveSources now emit entries
sorted by source then signal (insertion sort over an index array, no STL),
producing byte-identical calibration frames and config files to the Go hub.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add CalibrationEntry (heap-allocated array[256], char[] fields to stay within
the 133 MB struct's canonical address limit) plus calibrationMutex_ and
numCalibration_.
- Implement SetCalibrationEntry/ClearCalibration, BroadcastCalibration,
BroadcastConfigAck, HandleSetCalibration, HandleReloadConfig.
- Wire setCalibration and reloadConfig into OnWSCommand dispatch.
- Broadcast calibration to each newly connected client after triggerState.
- Fix JSON round-trip bug: replace JsonGetString/JsonGetBool helpers with a
shared whitespace-tolerant JsonFindValue (tolerates "key" : "value" as
written by HandleSaveSources); add JsonIsFinite (no <cmath>).
- Extend LoadSourcesFile(bool skipActive) to also parse calibration blocks;
SourceIsActive checks live sessions before starting a duplicate.
- Extend HandleSaveSources to persist calibration blocks; emit configSaved ack.
- Reload semantics: calibration replaced wholesale, sources added only.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wire five new WebSocket frames into hub.go: setCalibration (client→hub),
calibration (hub→client broadcast), reloadConfig (client→hub), configSaved and
configReloaded (hub→client acks with ok/path/error). Extend hubCmd with cal
field, add buildCalibrationMsg and buildConfigAckMsg builders, send calibration
on client connect, and run Save/Reload on separate goroutines to avoid blocking
the Run() select loop.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Finding 1: strip trailing [digits] array-element suffix from Signal so one
calibration entry covers an entire array signal, matching the C++ strchr
truncation and the JS equivalent.
Finding 2: after the 16-byte Unit truncation, drop any trailing partial UTF-8
rune so json.Marshal never emits replacement characters; keeps byte limit in
sync with C++ strncpy(u, unit, kMaxUnitLen).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Design for a per-signal affine calibration (scale/offset/unit) applied
client-side and stored server-side alongside the source list, so signals in
raw units can be read in engineering units and the setup survives a reload.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Task 7 added --skip-coverage/--skip-stress/--skip-datasources/
--skip-recorder/--skip-debug/--skip-tcplogger but the AGENTS.md table row
was never updated to mention them; it also still listed a --stress flag
that has never existed. Sync with the script's actual --help output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The final whole-branch review found runDebugScript only checked
mc.IsConnected() after sending FORCE/TRACE/BREAK/UNFORCE -- a liveness
check that would PASS even if DebugService silently no-op'd every command
(e.g. a signal-name/wire-format bug), undercutting the design's stated
rationale for this scenario ("catching wire-format/serialization bugs the
in-process suite cannot"). This mirrors the tautology already fixed for
the tcplogger scenario in an earlier task, but had not been applied here.
Added waitForAck(), which polls the sink's recorded "text_line" events for
DebugServiceBase::HandleCommand's real "OK <TOKEN> <count>\n" reply and
requires count > 0 -- HandleCommand prints this for every one of
FORCE/UNFORCE/TRACE/BREAK regardless of enable/disable direction, and
count is always "number of signals actually matched", so count==0 means
the signal path was never resolved. Verified with a real negative control
(temporarily pointing all commands at a nonexistent signal name): the
scenario now correctly FAILs, then reverted and reconfirmed PASS against
the real signal.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The final whole-branch review (post Task 10) found two real cross-task
integration bugs:
- run_e2e.sh's coverage-instrumented scenario re-run only rebound OUT_DIR
in its subshell, not WORK. proc_perf.py/plots.py write perf_*.json and
wave_*.png into WORK, so every --cpp-coverage run (the default) silently
clobbered the primary pass's perf/waveform data with the instrumented
re-run's numbers before report_build.py read them -- defeating the
"uncontaminated performance metrics" goal of the coverage-double-run
design. Fixed by rebinding WORK the same way OUT_DIR already was.
- report_build.py's build_e2e() iterated all results["scenarios"] with no
kind filter, so the direct/recorder/debug/tcplogger scenarios (already
covered by their own dedicated report sections since Task 8) also leaked
into the chain-only Scenarios/Performance sections and headline e2e
pass/fail count as degenerate rows. Fixed by filtering to kind=="chain".
Verified end-to-end with a full ./run_e2e.sh run: coverage pass now writes
to /tmp/chain_e2e/coverage_pass/ (confirmed via log), and report_data.json's
e2e section now reports 51 (chain-only) scenarios instead of all 56.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The post-coverage restore step ran `make clean` (which also wipes
Test/GTest, Test/Integration and Test/Components/*) but only rebuilt
`core apps`, leaving MainGTest.ex/IntegrationTests.ex deleted after
every --cpp-coverage run instead of restored to their plain (non-gcov)
form.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
README.md and Docs/StreamHub-Developer.md still pointed at the deleted
run_e2e_test.sh / Test/E2E/streamhub Go client after their removal in the
previous commit; repoint at Test/E2E/suite/run_e2e.sh.
Extends report_build.py with build_by_kind() (per-scenario-kind pass/fail
rollup) and a ported build_stress()/stress_headline()/stress_plots() (scaling
curves per stress axis), wires both into the headline KPIs, regression
tracking, and report_data.json. E2E_Report.typ renders the four new
per-kind tables plus a Stress Tests section (per-axis case tables + scaling
plots, gracefully degrading to placeholders when a kind/stress data is
absent). run_e2e.sh now passes --stress-results so the report actually
receives real stress data instead of silently omitting it.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds a trimmed debug_e2e.cfg (DebugService on 8080/8081, TcpLogger on
9090) and two new scenarios (s55_debug_force_trace_break, kind=debug;
s56_tcplogger_delivery, kind=tcplogger) reusing it, with matching
run_e2e.sh scenario-list/dispatch wiring and debugclient build steps.
Also fixes a real bug found while wiring s56: debugclient's tcplogger
check was tautological (it matched MarteController's own local
"CMD"-level echo of the outgoing command, which contains the same text
as the triggered event, instead of a line actually delivered over the
real TCPLogger TCP socket) and its trigger command (an invalid FORCE)
never reaches DebugServiceBase's REPORT_ERROR at all. Switched the
trigger to a MSG-to-missing-destination command (which does call
REPORT_ERROR) and the match to require the real log text
("not found in ORD"), recorded only after a connect-settle baseline —
verified with a positive run (real Warning-level TCPLogger line
received) and a negative control (LogPort=0 disables TcpLogger and the
scenario correctly FAILs).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Standalone headless client that drives a running MARTeApp.ex's
DebugService (TCP 8080 commands, UDP 8081 trace) and TCPLogger (TCP
9090) via marte2debugger/controller's NewHeadlessMarteController, for
the upcoming Test/E2E/suite "debug"/"tcplogger" scenario kinds. Scripts
FORCE/TRACE/BREAK for -mode debug, and triggers+waits for a TCPLogger
log event for -mode tcplogger; reports PASS/FAIL as
result_<scenario>.json/status_<scenario>.txt in -out.
Client/debugger was entirely package main, which Go forbids importing from
another module ("is a program, not an importable package") -- discovered
while wiring the new debugclient E2E tool against NewHeadlessMarteController.
Move martecontrol.go and its test into a new marte2debugger/controller
subpackage (package controller) and update Client/debugger/main.go to call
controller.NewMarteController/controller.DangerousCommandsEnabled. No
behavioral change to the browser-facing server.
Add a sink func(v any) field so MarteController's event stream can be
routed somewhere other than the browser WebSocket hub. NewMarteController
now sets sink to broadcast through the hub as before; a new
NewHeadlessMarteController(sink) constructor builds an instance with
hub == nil for the upcoming debugclient E2E tool. Direct m.hub.* calls
(SetSourceState/UpdateConfigForSource/PushDataForSource) are now guarded
with nil checks so a headless controller doesn't panic.
Extends the scenario-list builder and main loop to actually execute
s52_direct_unicast/s53_direct_multicast (self-contained single-MARTeApp
FileReader->UDPStreamer->UDPStreamerClient->FileWriter round trip) and
s54_recorder (StreamHub BinaryRecorder round trip, ported from
run_recorder_e2e.sh) alongside the existing chain scenarios, and tags
each results.json record with its scenario kind.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Plan derived from the approved 2026-07-01 design spec; covers scenario
kind unification (chain/direct/recorder/debug/tcplogger), instrument-first
double-run coverage, and DebugService/TCPLogger E2E via debugclient.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Consolidates the fragmented chain/stress/streamhub/datasources/recorder
E2E suites, unit-test collection, and coverage into one entry point and
one report, adds new DebugService/TCPLogger E2E coverage, and fixes the
Test/Applications/StreamHub build break blocking `make test`.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add DebugServiceGTest.cpp (TraceRingBuffer, DebugSignalInfo, BreakOp
regression coverage for the HI-4/HI-9 fixes) to Test/GTest's OBJSX so it
builds and runs as part of ./Build/x86-linux/GTest/MainGTest.ex alongside
the rest of the unit suite. 17/17 tests pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Track the existing (untracked) stress matrix, extend the signal-size axis
into the multi-fragment regime now that the UDPSClient reassembly cap is
1 MiB, and wire stress results into the E2E PDF report via an opt-in
--stress flag (table + per-axis scaling curves + regression vs prior run).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Full-chain orchestrator (run_chain_e2e.sh) now runs the starter set green
(3 pass). Fixes found bringing the chain up end-to-end:
- client: gorilla/websocket cannot survive a read deadline (next ReadMessage
panics "repeated read on failed connection"); replace the poll-with-deadline
loop with a background reader goroutine + mutex-guarded state.
- orchestrator: guard env.sh's unbound LD_LIBRARY_PATH under set -u.
- scenarios/gen_data: centralize NUM_ROWS/ROW_DT and enforce sine freq to be a
multiple of the buffer fundamental (LOOP_HZ=5 Hz) so the looped FileReader
buffer is a seamless waveform; align starter freqs (5/5/10 Hz).
- gen_cfg: FileReader allows exactly one consuming Function, so route tapped
(oracle=fed/both) sources through the DDB (ReaderGAM->DDB, then StreamGAM and
TapGAM both read DDB) instead of a second FileReader consumer.
- validate_waveform: fidelity gates correctness (bit-exact / within one quant
level); sine shape becomes a gross frequency-sanity gate (corr>=0.5) plus a
tracked corr/nRMSE quality metric, since per-sample wall-clock calibration
(Phase-A) and FULL_ARRAY packed timestamps (Phase-A4) are still pending.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
End-to-end test streaming three float32 signals via a MARTe2 UDPStreamer
app into a standalone StreamHub with the recorder enabled, then validating
the recorded FileWriter-compatible .bin against the streamed input with
validate_binary.py. All recorded rows are byte-identical to input rows.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Parse the optional +Recorder block in Initialise (Enabled/AutoStart/
Directory/MaxFileMB/KeepFiles/StagingMB/FlushIntervalSec/MinDiskFreeMB/
Signals, MB->bytes), apply it to each session before Start (static and
dynamic sources). Drive recorder disk I/O from the push loop via
RecorderFlushTick, and add recStart/recStop/recInfo WS commands plus a
recStatus broadcast/snapshot.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Each session owns a BinaryRecorder. The receive thread configures the
recorder layout at CONFIG time and captures each data packet; the push
thread performs all file I/O via RecorderFlushTick. WS subset overrides
are adopted through an epoch-deferred handoff mirroring the trigger path.
Arm/disarm/flush/info are exposed for StreamHub-level control.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add a FileWriter-compatible per-source binary recorder: native-type encode
and TypeDescriptor mapping, FileWriter header serialization, subset/quantized/
ACCUMULATE row serialization, and push-thread double-buffer flush with size-cap
rotation, keep-N pruning, fdatasync cadence, disk-free and staging-overflow
guards. Covered by 12 GTests; full suite (83) green.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Lossless packet-decode capture to FileWriter-compatible per-source binary
files with size-capped rotation, config + WS control, and double-buffered
flush on the push thread (Approach C).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>