Skip to content

Cross-repo policy: gate on MATLAB drift, and state the rule in one place #211

Closed

Description

@stevevanhooser

Cross-repo proposal. Extends VH-Lab/NDR-python#21, which settled the bridge status vocabulary across NDI-python, NDR-python and DID-python. That one answered what the labels mean. This one answers what happens when the MATLAB side moves underneath them — the question #21 did not touch.

Filed here because #21 named NDI-python as the vocabulary's home repo, and this is the same class of rule.

Timing: not urgent for #210 (merged). It should land before anyone acts on #191, which is the NDI-python-shaped half of this question.


The problem: three repos, three different answers, and one of them is silence

repo where it is documented what it does
NDR-python spec §5 + §5a "Detecting Upstream MATLAB Changes" gates — CI red when a bridged MATLAB file moves
DID-python PORTING_INSTRUCTIONS.md, "Checking for Drift" + check table detects, deliberately does not gate
NDI-python nothing nothing. #209 settled commit-vs-blob and stopped there

DID states its reasoning plainly, and it is a fair argument:

CI runs file, hash, status, member and missing as gating checks, and drift non-gating: drift turns red when DID-matlab moves, which no commit here causes and none can fix until someone does the port.

There is also a coupling defect. NDR's §5 hardcodes a description of a different repo's policy — "This is stricter than NDI-python, which accepts any commit" — which goes stale the moment NDI-python changes. That is the same restate-it-in-three-places pattern that produced 35 blob hashes and cost us #209.

The decision: gate

These repos exist to mirror MATLAB. status is a claim about correspondence, not a task tracker: "ported" asserts this Python matches that MATLAB. When MATLAB moves, that assertion is no longer known to be true, and it stays unknown until a human looks at the diff. Red is the honest state.

DID's objection — "no commit here caused it" — is true and does not change the conclusion. The bridge's job is not to report whose fault a discrepancy is; it is to stop the repo asserting a correspondence nobody has verified.

But state it as DRIFT, not as "equals the latest commit"

NDR's §5a prose describes drift ("when MATLAB edits a bridged file CI goes red"), while its normative sentence states equality: "must be the latest commit touching its MATLAB file." Those are different rules. Measured on NDI-python main (f241b4e) against NDI-matlab main:

entries with a matlab_path:
    361  carry a matlab_last_sync_hash  (360 resolvable)
    147  carry NO hash  ->  can never show drift

of the 360 checked:
     60  DRIFTED   (git log <hash>..HEAD -- <path> is non-empty)
    163  fail an equals-latest rule       (+103 beyond drift)

Those 103 are batch-sync entries: the hash is a repo-wide commit from when a batch was examined, and the file has not changed since. Nothing drifted; the claim "examined, and MATLAB has not moved" is true. Equals-latest would fail all 103 for bookkeeping rather than correctness, and the only way to clear them is to rewrite a correct record — which teaches contributors that the check is noise.

Drift is the rule that actually expresses the principle. Equality only coincides with it in a repo that never records batch-sync commits, which is why the flaw has not bitten NDR.

Proposed normative wording (drift condition, NDR's §5a resolutions, which are already right):

matlab_last_sync_hash records the commit an entry was last examined against. An entry drifts when NDI-matlab has commits touching its matlab_path after that hash: git log <hash>..HEAD -- <matlab_path> is non-empty. Drift fails CI. Clear it by either

  1. reviewing the MATLAB diff, porting the behavioural change, and bumping the hash; or
  2. bumping the hash, having read the diff and found nothing the Python side must follow (comment-only, formatting, an analyzer fix). Where the entry already carries a status, say so in its decision_log; a regular port needs no prose — see below.

Never bump a hash without doing one of those — an unexamined bump asserts an examination that did not happen, which is the failure the field exists to prevent.

The check requires a non-shallow MATLAB checkout; a shallow clone collapses history and lies about what came after.

A regular port requires no decision_log, and this must not change

Drift-gating must not turn into a blanket documentation requirement. Today decision_log is required only where a status is present (TestTheDeferralsSayWhy::test_every_status_entry_has_a_decision_log), and that scope is correct. On current main:

entries with no status (regular ports):            458
   of those, with no substantive decision_log:     142

A rule requiring prose on every hash bump would fail those 142 immediately and would keep failing every routine re-sync afterwards.

It would also be wrong on principle, not merely expensive. decision_log explains a divergence. A regular port has none to explain — that is what "regular" means. Re-examining it against a newer MATLAB commit and finding nothing to follow does not create a divergence, so there is nothing for a log to say. The bumped hash is the record: it asserts "examined as of this commit", which is exactly what the field means. Forcing 458 entries to also say "looked, nothing changed" would bury the ~100 logs that carry real reasoning under boilerplate nobody reads — the same dilution the vocabulary cleanup in #210 was undoing.

So: drift gating attaches to the hash, not to the prose. No new decision_log obligation for entries without a status.

The companion rule, or a third of the bridge opts out silently

147 NDI-python entries have a matlab_path and no hash at all. They can never drift, so they claim "ported" permanently and are immune to the check. Under the reasoning above that is worse than a stale hash: same false assurance, but silent instead of red.

DID already gates exactly this and says why:

hash | Every entry with a matlab_path has a matlab_last_sync_hash, and that hash is a real commit. An entry without one can never show drift.

So gating drift without this is half a policy. Both rules go together. Note this is a hash requirement, not a prose requirement — consistent with the section above.

Cost

NDI-python — 60 drifted, triaged by what actually changed in the MATLAB file:

what changed count what it needs
signature / arguments block 18 real port review; the contract may be wrong
body 40 read the diff; mostly bump
comments / whitespace only 2 bump

The 18 signature-changed entries all carry live Python: profile (166 changed code lines), session (69), stimulator (63), dir (58), sessiontable (48), dataset (39), dir (28), ndi_document2ndi_object (27), updateFileInfoForLocalFiles (22), ndiquery / ndiqueryAll (20 each), database (12), advanceSession, startSession, setup.lab, setup.rayolab, clocktype, RayoLabStims. That is where a stale hash is most likely hiding a real defect, so it is the right place to start.

Plus 147 entries needing a hash determined and recorded.

NDR-python: change the normative sentence in §5a from equals-latest to drift, and delete the hardcoded claim about NDI-python's policy in §5 — cross-reference instead. Its enforcement already exists.

DID-python: move drift from non-gating to gating in the CI job. Its check already exists and its docs already explain drift; only the gating decision changes.

Rollout: ratchet, not big bang

Turning the gate on in NDI-python today puts main red until all 60 are examined, and the 18 signature ones are not quick. Proposed instead:

  • The check gates, with an explicit allowlist naming the currently-drifted entries and the currently-hashless ones — each line a visible IOU in the repo.
  • Anything not on the list fails immediately, so no new unexamined drift can enter from today.
  • Every burn-down PR deletes lines. Empty list is the finish line.

The debt becomes enumerated and counted instead of invisible. The alternative — work all 60 first, then switch on — is honest but leaves the gate off for as long as that takes, which is how NDI-python accumulated 60 in the first place.

Decision needed

@stevevanhooser — one answer for all three repos:

  1. Drift, not equals-latest? (recommended: yes — the 103 batch-sync entries are correct records that equality would reject)
  2. Gate the companion "every matlab_path entry has a hash" rule too? (recommended: yes — otherwise 147 entries are exempt by omission)
  3. Ratchet with a shrinking allowlist, or clean the 60 first and then switch on? (recommended: ratchet)
  4. Confirm a regular port stays free of any decision_log requirement. (recommended: yes — 142 entries would fail otherwise, and a port with no divergence has nothing to explain)

Once settled, the rule is stated once in NDI-python's bridge spec beside the commit-vs-blob rule and the status vocabulary, and NDR-python and DID-python cross-reference it rather than restating — the discipline #210 adopted, and the one whose absence caused #209.

Activity

  1. stevevanhooser commented on Sep 7, 2026

    @stevevanhooser
    ContributorAuthor

    Decision 1 settled: drift, and it is file-scoped

    @stevevanhooser confirmed the intent — "a change is only noticed when the file changes rather than anything on the repo." That is exactly what the drift rule does, and it is worth stating plainly because the issue body could be read otherwise.

    The check is path-filtered, always:

    git log <matlab_last_sync_hash>..HEAD -- <matlab_path>
    

    The -- <matlab_path> restricts it to commits touching that one file. Unrelated activity in NDI-matlab never makes an entry drift, regardless of what form the recorded hash takes.

    The difference between the two candidate rules is the hash's FORM, not the check's scope

    Both look at the file. What separates them:

    asks markgarbage
    drift have commits touching this file landed since the hash? pass — 0 such commits
    equals-latest is the hash literally the file's own last-touching commit? fail — hash is a batch-sync merge commit

    Two real entries on main today:

    ENTRY: markgarbage   (+ndi/+app/markgarbage.m)
      recorded hash            : 0e2caa0   -> "Merge pull request #846 from VH-Lab/claude/no-cloud-test-ci-"
      file's last-touching cmt : fe64a9f53 -> "Move +ndi/ndi_common/java to src/ndi/"
      commits touching THIS FILE since the recorded hash: 0
    
    ENTRY: oridirtuning  (+ndi/+app/oridirtuning.m)
      recorded hash            : b7102250  -> "Merge pull request #833 from VH-Lab/claude/ndi-ensemble-extr"
      file's last-touching cmt : fe64a9f53
      commits touching THIS FILE since the recorded hash: 0
    

    Neither file has moved since it was examined. Both records are true. Equals-latest rejects them anyway, purely because the recorded hash is a repo-wide commit rather than the file's own — and the only way to clear the resulting red build is to rewrite a correct record with a different, equally correct value. That is how a check teaches contributors to ignore it. There are 103 such entries.

    What this means for a recorded hash going forward

    Either form stays legal, because drift is sound under both:

    • the file's own last-touching commit (git log -1 --format=%h -- <path>) — the documented default, and the more informative of the two: it says "I examined this version of this file."
    • a repo-wide commit, e.g. NDI-matlab HEAD when a batch was examined together — a broader claim ("I examined as of this repo state"), and still sound: any later change to the file is caught, and changes before it fall inside what was examined.

    No entry needs rewriting to adopt this rule. The 103 stay as they are.

    Decisions 2, 3 and 4 remain open — the companion "every matlab_path entry has a hash" rule (147 entries currently exempt by omission), ratchet vs clean-first for the 60-entry backlog, and confirming that a regular port carries no decision_log obligation.


    Generated by Claude Code

  2. stevevanhooser commented on Sep 7, 2026

    @stevevanhooser
    ContributorAuthor

    Decisions 2, 3 and 4 settled — all four are now implemented in #213

    @stevevanhooser answered the remaining three: 2 yes, 3 ratchet for now (with the drift dealt with soon), 4 yes. Recording them here so the issue carries the whole ruling rather than a quarter of it.

    2. The companion hash rule gates too — YES

    Every entry naming a matlab_path must carry a matlab_last_sync_hash. An entry without one can never drift, so it asserts its port is current forever and nothing can contradict it: the same false assurance as a stale hash, but silent instead of red.

    This check reads only this repo — no NDI-matlab checkout — so it runs in every test matrix job, not just the bridge job. I verified that from the job log rather than assuming it.

    3. Ratchet — YES, for the moment

    The user's words: "let's ratchet for the moment but we should very soon deal with the drift." The allowlist is a staging post, not a settlement.

    tests/bridge_drift_allowlist.yaml enumerates the pre-existing debt, and everything not on it is gated today, so no new unexamined drift can enter. TestTheAllowlistOnlyShrinks fails if a listed entry no longer drifts, so a fix must delist in the same PR — the file can only shrink, and its line count is the progress metric. It cannot quietly become a permanent exemption.

    4. A regular port carries no decision_log obligation — YES

    decision_log explains a divergence; a regular port has none, and re-examining one against a newer MATLAB commit and finding nothing to follow does not create one. The bumped hash is itself the record of examination. Drift gating attaches to the hash, not to the prose.

    This turned out to matter for our own spec, not just as a hypothetical: §3a of the bridge spec already told readers to "record the sync date in decision_log" — a rule that contradicts decision 4, sitting in a restatement of the mechanics. #213 replaces that restatement with a pointer to §7. Exactly the failure mode this issue is about, found in the document proposing to fix it.


    Where the rule now lives

    Section 7 of docs/developer_notes/ndi_matlab_python_bridge.yaml is the normative statement, beside the commit-vs-blob rule (#209) and the status vocabulary (#210). NDR-python and DID-python cross-reference it rather than restating.

    One number changed between this issue and the PR

    This issue said 147 hashless entries; #213 says 146. Not a miscount on either side — the filter got stricter in between. normalize_matlab_path strips only the exact string "N/A", so "n/a (Python only)" and "(inline in getData.m)" survived it and were being treated as real MATLAB files. The path filter now requires the +ndi/ package prefix, as the completeness guard already did. 507 of 528 matlab_path values are real paths.

    The allowlist-hygiene test caught this itself: the first generated list disagreed with the corrected filter and the test refused it.

    Still open

    • The burn-down. 60 drifted (18 signature-changed first — profile at 166 changed code lines, then session 69, stimulator 63, dir 58) plus 146 hashless entries. Deliberately not in Gate MATLAB drift, with a burn-down list that can only shrink #213.
    • NDR-python: §5a's normative sentence still says equals-latest rather than drift, and §5 still hardcodes a claim about NDI-python's policy that decision 1 has now changed. Both need the edit described above.
    • DID-python: move drift from non-gating to gating. The check and the docs already exist; only the gating decision changes.

    Generated by Claude Code

  3. claude commented on Sep 8, 2026

    @claude

    Workstream B (no_hash:) progress — overnight batch summary.

    no_hash: went 146 → 87 on main, with seven more PRs open that take it to ~46.

    Merged

    PR Scope Effect
    #216 23 non-ported decisions (+fun, +gui, +util, top level) 146 → 123
    #221 python_path guard + 9 entries pointing at nothing —
    #217 11 +ndi/+validators/ ports → 112
    #218 strict-mode skip backstop —
    #219 8 +ndi/+util/ hex/time ports → 104
    #222 8 +ndi/+gui/+component/ progress entries → 96
    #227 4 ngrid/image entries → 92

    Open, all green and mergeable except the newest

    #228 (5 +fun/+table) · #229 (7 +fun/+doc) · #230 (4 +docTable) · #231 (4 +docTable builders) · #233 (13 +database/+fun) · #235 (9 small +fun) · #236 (5 top level)

    What the exercise turned up

    The point was never the hashes. Defects found by reading the Python against the MATLAB, roughly in order of severity:

    Functions that could not work at all

    Silent wrong answers

    Whole halves never ported, silently

    Records that were wrong

    Composition failures

    Also: a mechanical class-rename pass had corrupted ordinary English in error messages and docstrings in five places, and — load-bearingly — in nine python_path values (#221).

    Deliberately not fixed, and why

    Recorded in decision_logs rather than changed: the hexDiffBytes StartOffset bug (MATLAB has it too — diverging would break the correspondence the bridge exists to record); ubit1 bit-packing (won't guess MATLAB's bit order without a MATLAB to test); element's metadata aggregation and openminds' dependent-document expansion (need populated sessions); dateCreated on Linux (matching MATLAB's NaT would make a test platform-dependent — a product decision).

    Still open

    ~46 entries after the current PRs: +gui top level (8 large GUI classes), +mock (5), +util summaries (4), +app (3), +calc (3), +file (4), ndi.document itself, and assorted +fun subpackages. Plus 18 drifted: rows, all +ndi/+cloud/.


    Generated by Claude Code

  4. claude commented on Sep 8, 2026

    @claude

    Overnight progress: eight more PRs, no_hash 92 -> 11

    Everything below is open, green and clean against main. Nothing is merged -- that stays yours.

    PR Entries The headline
    #237 ndi.document to_table named its columns differently in three ways; get_fuid was never ported
    #238 the four +util summaries the symmetry comparison itself diverged: order-independence, empty-string, NaN
    #239 the five +mock the reference-collision bug MATLAB wrote pickFreeReference to fix was still here
    #240 the eight +gui the Database View was empty on every session
    #241 three +file mfdaq_epoch_channel's two halves could not compose
    #242 seven +fun plot/stimulus bar3 could not run at all
    #243 five +fun doc/epoch/probe epochid2element read a field that does not exist
    #244 ndi.app.appdoc ReplaceIfDifferent kept documents MATLAB replaces

    Together with #228-#236, that is 81 of the 92 no_hash entries examined. Eleven remain: spikeextractor, spikesorter, ndi.file.navigator, the three +calc, evaluate_fitcurve, SyncIndex, setup, and the session/dataset diff pair -- which want #237's get_fuid under them first, since that is the lookup they are built on.

    Functions that could not work at all

    • bar3 raised AttributeError on every call. plt.cm.get_cmap was removed in matplotlib 3.9.
    • mfdaq_epoch_channel: create_properties derived none of the three fields it exists to derive, so every channel came out number=0, group=0, dataclass='' -- and channelgroupdecoding, which looks channels up by number, then raised Channel number 0 found multiple times in record for any request at all. The two halves of the class could not be used together.
    • epochid2element read element.epoch_table. That field appears in no document schema and nothing writes it, so the lookup was empty for every epoch id ever passed in. Its type filter read document_class.class_list, also absent, so every element was skipped whenever a type was given. filename2epochid has the same defect and its record (on Examine 9 small +fun entries; two records were swapped, timestamp broke composition #235) understates it.
    • gui_v2's Database View was empty on every session: the query was built with the operator spelled regex, which ndi_query rejects, inside a bare except Exception.

    Silent wrong answers

    • ndi.document.to_table never read ndi_document2table_abbreviations.json -- which ships in this repo -- so every abbreviated field's column had a different name in the two languages. It also deleted nested fields named files/depends_on at every depth where MATLAB's rmfield reaches only the top, and collapsed a struct array into one raw-list column.
    • compareSessionSummary sorted three field names where MATLAB sorts every array, treated '' as non-empty where MATLAB's isempty covers it, and compared with == where MATLAB uses isequaln. This is the function the symmetry harness uses to decide whether Python and MATLAB agree.
    • compareDatasetSummary matched session summaries positionally where MATLAB looks each up by the sessionId inside it, and read a missing numSessions as zero -- so a summary with no such field compared equal to an empty dataset.
    • ndi.gui.data / docViewer read name/id/type/datestamp out of the legacy document_properties.ndi_document, so every table row came out with four empty columns -- and docViewer's dependency graph matched on that same missing id, giving no edges at all.
    • findMixtureName required the entry and the mixture to be the same length, skipped any single-component entry outright, and compared every field as a string so 1 never equalled 1.0.
    • appdoc.add_appdoc returned as soon as any existing document matched, and swallowed a failed removal before writing a replacement beside it.

    Whole halves never ported

    • f0_f1_responses: F0 and F1 live in separate documents; MATLAB finds the partner. The port returned one value and None for the other, so the pair it exists to compare could never be compared.
    • get_fuid: not ported and not recorded, and it is what findFuid and both diff functions are built on.
    • gui_v2 never drew a single connection between icons. Lab has no mouse routing at all -- nothing sets drag, so dragging never starts and clicking an icon never opens its details.
    • subject_stimulator_neuron built three loose documents the session never saw.
    • datasetSummary could not produce documentCounts; compareDatasetSummary could not compare them.

    Records that asserted things that were not so

    doc_unique_id recorded as an "exact match" for a method that did not exist. stimulus_response recorded as DISCREPANCY FIXED for a stub returning three Nones -- now porting_deferred. navigator_epochdir's epochid recorded as "Added to match MATLAB" when the two languages give different epoch ids for the same data (t00001 vs epoch_db4fad24..., plus a hidden sidecar file the symmetry tests already filter around). probeLocations4probes wrote probe_location.ontology; the schema declares ontology_name.

    Left for you, deliberately

    Two things worth deciding separately

    ndi.gui is entirely untested in CI. PySide6 is an optional extra and ndi_install.py installs .[dev,tutorials], so every Qt test in the package skips on every job -- which is why four of the eight +gui defects survived. Adding gui to the CI extras is one line, but it pulls Qt into every job.

    tests/test_ndi_install_exit_codes.py is not hermetic. Three cases fail in my container and pass in CI: _run() stubs everything "up to step 4" but not pip_install_dependency, so step 2 shells out to a real pip install. A one-line addition to that stub list fixes it. Not touched -- it has nothing to do with the bridge.

    Two to report to VH-Lab

    Both are MATLAB-side and I have no API access there:

    • ndi.fun.probe.location hangs on any element chain deeper than one level: the descent loop reads e.underlying_element rather than current_element.underlying_element, so it assigns the same object forever.
    • ndi.gui.Data / docViewer read document_properties.ndi_document, the legacy schema field, so MATLAB's own document tables will come out blank on any document built today.

    Generated by Claude Code

  5. claude commented on Sep 8, 2026

    @claude

    Merging these: one thing to know

    Nineteen open PRs all edit the same line. Every burn-down PR removes rows from tests/bridge_drift_allowlist.yaml and updates the # no_hash: N header count, so the first merge makes the other eighteen conflict on that count line. That is expected and mechanical, not a sign anything is wrong.

    Merge them in any order you like; after each merge I can reconcile the rest with /tmp/resolve_allowlist.py, which takes the union of removed rows and recomputes the count from the rows that actually survive -- the bridge tests themselves then check the arithmetic. A check-in is armed to do exactly that whenever main moves.

    Current state

    That is the whole no_hash section: 92 → 3, with the last three waiting on merges rather than on examination.


    Generated by Claude Code

  6. stevevanhooser commented on Sep 8, 2026

    @stevevanhooser
    ContributorAuthor

    Closing: all three repos have landed their half, verified in each repo rather than assumed

    The four decisions are implemented as settled — drift rather than equals-latest, the companion hash rule gating too, a ratchet rollout, and no new decision_log obligation on a regular port.

    NDI-python

    tests/test_matlab_bridge_hashes.py, named explicitly by the CI job and run under NDI_BRIDGE_CHECK_STRICT=1 against a fetch-depth: 0 checkout. 11 tests, green.

    The ratchet has been burnt down:

    when the gate went up now
    drifted entries 60 1
    entries with a matlab_path and no hash 147 0

    The one remaining line is downloadNdiDocuments, held open deliberately: it drifts on cd3c11673, which reconstructs a file series' ingest_locations on cloud download, and this side has no file-series support in the cloud path at all. That is #215. Bumping its hash would assert an examination whose entire finding is that the port is missing.

    Roughly one entry in three turned out to be hiding a real defect rather than bookkeeping — startSession missing a now-required organizationId, setup.lab duplicating every DAQ system on a second run, waitForAllBulkUploads ported and never called, a transfer URL never checked for https, a document archive with nothing in it returning an empty list. Each is recorded in its entry's decision_log.

    NDR-python (VH-Lab/NDR-python#27, merged)

    Verified in the repo: docs/developer_notes/ndr_matlab_python_bridge.yaml §5a now states the drift condition, and §5 no longer carries the hardcoded claim about NDI-python's policy — replaced by a note explaining that the old sentence "characterised NDI-python's rule in a passing sentence; the sentence was a copy", with a cross-reference to this issue and to NDI-python's spec as the single home of the rule.

    It also records the batch-sync point that motivated choosing drift over equality: "NDI-python had 103 entries of that shape. A check that demands [equality] fails all of them."

    DID-python (VH-Lab/DID-python#76, merged)

    Verified in the repo: .github/workflows/python-package.yml reads "All six checks gate, drift included" and passes --check drift among them. PORTING_INSTRUCTIONS.md §Drift gates states the rule, records that "Until 2026-09-07 this check ran non-gating here", and notes that nothing was drifted when the gate went on — so DID needed no burn-down, and its allowlist mechanism exists for the future rather than for a backlog.

    The discipline the issue asked for

    The rule is stated once, in NDI-python's bridge spec, and the other two repos cross-reference it rather than restating it — which is what #210 adopted and what #209 cost us for lack of.

    Closing. #215 tracks the single remaining allowlist line; #191, which asked for this check, is satisfied by the same work.


    Generated by Claude Code

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