Summary
In src/ndi/+ndi/+time/+fun/syncTriggerTrains.m, runRobustGlobalSync discovers a candidate
alignment from a matching inter-pulse-interval fingerprint, but then computes that candidate's
rough shift from pulse 1 rather than from the pulse where the match was actually observed.
The consequence: if a pulse is dropped within the first fingerprintSize intervals of a train,
the function returns NaN for an alignment it should find easily.
It fails closed, so this produces a missed synchronisation, never a wrong one.
The code
Candidate offsets are collected, but only the offset survives — the location of the match is
discarded:
for i = 1:(length(q_prober) - fSize + 1)
searchKey = sprintf('%d,', q_prober(i:i+fSize-1));
if isKey(mapObj, searchKey)
idx_targets = mapObj(searchKey);
potentialOffsets = unique([potentialOffsets; idx_targets(:) - i]); % <- `i` is dropped
end
end
Then the seed is reconstructed at the start of the train:
for offset = potentialOffsets'
idx_p_seed = max(1, 1 - offset); % <- pulse 1 whenever offset >= 0
idx_t_seed = idx_p_seed + offset;
...
roughShift = target(idx_t_seed) - prober(idx_p_seed);
Why that is wrong
max(1, 1-offset) assumes the constant index offset holds from the very first pulse. With a pulse
dropped mid-train that is false: the offset is 0 before the drop and +1 after it. A fingerprint
found after the drop legitimately yields offset = +1, but anchoring it at prober pulse 1 pairs
that pulse with target pulse 2 — off by one whole inter-pulse interval. Every subsequent match test
then fails and the hypothesis is rejected.
Normally this is harmless, because the offset = 0 hypothesis is also seeded from the pulses
before the drop, and that one validates. It breaks when no unbroken fingerprint precedes the drop —
i.e. when the drop lands within the first fingerprintSize intervals — because then offset = 0
is never seeded at all and the only surviving candidate is the mis-anchored one.
Reproduction
40 pulses at random times over 100 s; T2 = 12.5 + 1.0002 * T1; one pulse deleted from T2 at
the 0-based index shown. Defaults (fingerprintSize = 5).
| drop index |
result |
|
| 0 |
12.500000 / 1.000200 |
ok |
| 1 |
NaN |
should align |
| 2 |
NaN |
should align |
| 3 |
NaN |
should align |
| 4 |
NaN |
should align |
| 5 |
NaN |
should align |
| 6 |
12.500000 / 1.000200 |
ok |
| 17 |
12.500000 / 1.000200 |
ok |
| 30 |
12.500000 / 1.000200 |
ok |
The boundary sits exactly at fingerprintSize, as the mechanism predicts. Instrumenting the drop
at index 5 confirms it directly:
fingerprint matches (prober_i, target_j, offset): [(5,6,1), (6,7,1), (7,8,1), (8,9,1), ...]
distinct offsets: [1]
-> offset 0 is never seeded: the only pre-drop fingerprint, qp[0:5], spans the merged interval.
Suggested fix
Keep the prober index that produced each offset and anchor there:
potentialOffsets = [];
+ seedProberIdx = containers.Map('KeyType', 'double', 'ValueType', 'double');
for i = 1:(length(q_prober) - fSize + 1)
searchKey = sprintf('%d,', q_prober(i:i+fSize-1));
if isKey(mapObj, searchKey)
idx_targets = mapObj(searchKey);
+ for k = 1:numel(idx_targets)
+ off = idx_targets(k) - i;
+ if ~isKey(seedProberIdx, off)
+ seedProberIdx(off) = i; % remember WHERE the match was seen
+ end
+ end
potentialOffsets = unique([potentialOffsets; idx_targets(:) - i]);
end
end
for offset = potentialOffsets'
- idx_p_seed = max(1, 1 - offset);
+ idx_p_seed = seedProberIdx(offset);
idx_t_seed = idx_p_seed + offset;
if idx_t_seed > length(target) || idx_t_seed < 1, continue; end
roughShift = target(idx_t_seed) - prober(idx_p_seed);
A side benefit: dist_from_seed — which widens the matching window to absorb drift — is then
measured from a seed inside the matched region rather than from an arbitrary end of the train.
How this was verified, and the caveat that matters
I do not have MATLAB available, so the patch above is untested in MATLAB. What was tested is a
line-for-line Python port of runRobustGlobalSync (written for NDI-python, quantization, dynamic
tolerance, match-rate and missed-pulse budget, ranking and ambiguity check all mirrored, including
MATLAB's half-away-from-zero round). The port reproduces the NaN results in the table above,
and applying the change:
- recovers
12.500000 / 1.000200 at every drop index 1–5 that previously returned NaN;
- leaves every case that already worked bit-identical (drops at 6, 7, 10, 17, 22, 30, 35, and
the equal-length path).
Please treat the numbers as a description of the algorithm's behaviour rather than as output from
MATLAB itself.
Impact
Low severity, worth fixing. syncTriggerTrains exists precisely to tolerate a missing pulse — the
commit that introduced it is titled "added sync that allows for some missing pulses" — and this
is the one place where a missing pulse defeats it. It returns NaN rather than a bad mapping, so
nothing is silently misaligned; a caller simply loses a synchronisation it should have had.
Found while porting this function to NDI-python (Waltham-Data-Science/NDI-python#223). The port
mirrors the current behaviour deliberately, with a test pinning the NaN so the limitation stays
visible; it will follow whatever is decided here.
Summary
In
src/ndi/+ndi/+time/+fun/syncTriggerTrains.m,runRobustGlobalSyncdiscovers a candidatealignment from a matching inter-pulse-interval fingerprint, but then computes that candidate's
rough shift from pulse 1 rather than from the pulse where the match was actually observed.
The consequence: if a pulse is dropped within the first
fingerprintSizeintervals of a train,the function returns
NaNfor an alignment it should find easily.It fails closed, so this produces a missed synchronisation, never a wrong one.
The code
Candidate offsets are collected, but only the offset survives — the location of the match is
discarded:
Then the seed is reconstructed at the start of the train:
Why that is wrong
max(1, 1-offset)assumes the constant index offset holds from the very first pulse. With a pulsedropped mid-train that is false: the offset is 0 before the drop and +1 after it. A fingerprint
found after the drop legitimately yields
offset = +1, but anchoring it at prober pulse 1 pairsthat pulse with target pulse 2 — off by one whole inter-pulse interval. Every subsequent match test
then fails and the hypothesis is rejected.
Normally this is harmless, because the
offset = 0hypothesis is also seeded from the pulsesbefore the drop, and that one validates. It breaks when no unbroken fingerprint precedes the drop —
i.e. when the drop lands within the first
fingerprintSizeintervals — because thenoffset = 0is never seeded at all and the only surviving candidate is the mis-anchored one.
Reproduction
40 pulses at random times over 100 s;
T2 = 12.5 + 1.0002 * T1; one pulse deleted fromT2atthe 0-based index shown. Defaults (
fingerprintSize = 5).12.500000 / 1.000200NaNNaNNaNNaNNaN12.500000 / 1.00020012.500000 / 1.00020012.500000 / 1.000200The boundary sits exactly at
fingerprintSize, as the mechanism predicts. Instrumenting the dropat index 5 confirms it directly:
Suggested fix
Keep the prober index that produced each offset and anchor there:
A side benefit:
dist_from_seed— which widens the matching window to absorb drift — is thenmeasured from a seed inside the matched region rather than from an arbitrary end of the train.
How this was verified, and the caveat that matters
I do not have MATLAB available, so the patch above is untested in MATLAB. What was tested is a
line-for-line Python port of
runRobustGlobalSync(written for NDI-python, quantization, dynamictolerance, match-rate and missed-pulse budget, ranking and ambiguity check all mirrored, including
MATLAB's half-away-from-zero
round). The port reproduces theNaNresults in the table above,and applying the change:
12.500000 / 1.000200at every drop index 1–5 that previously returnedNaN;the equal-length path).
Please treat the numbers as a description of the algorithm's behaviour rather than as output from
MATLAB itself.
Impact
Low severity, worth fixing.
syncTriggerTrainsexists precisely to tolerate a missing pulse — thecommit that introduced it is titled "added sync that allows for some missing pulses" — and this
is the one place where a missing pulse defeats it. It returns
NaNrather than a bad mapping, sonothing is silently misaligned; a caller simply loses a synchronisation it should have had.
Found while porting this function to NDI-python (Waltham-Data-Science/NDI-python#223). The port
mirrors the current behaviour deliberately, with a test pinning the
NaNso the limitation staysvisible; it will follow whatever is decided here.