Skip to content

minor syncTriggerTrains: the seed for a candidate offset is anchored at pulse 1, not at the fingerprint that produced it #975

Description

@stevevanhooser

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions