Skip to content

M3a: the twin job server — the D14 wire contract (CompiledJob in, RawAcquisition out) - #30

Merged
aarontrowbridge merged 10 commits into
mainfrom
29-twin-job-server
Sep 2, 2026
Merged

aarontrowbridge merged 10 commits into
mainfrom
29-twin-job-server

Conversation

@aarontrowbridge

Copy link
Copy Markdown
Member

Closes #29.

The twin gets a WIRE presence: a Julia job server speaking the D14 contract over HTTP/JSON — CompiledJob wire form in (qick's dump_prog() through NpEncoder), envelope-level translation (base64 int16 I/Q pages decoded, the waves assignments placed on the DAC grid with freq/phase/gain applied), execution through the twin face (the same seeded confusion+binomial machinery, drift included), RawAcquisition wire form out ((n_ch, n_reads, [expts,] 2) IQ averaged over reps × soft_avgs, shaped per the acquire block).

The documented boundary: the twin models the DEVICE response at the envelope level, NOT tProc-v2 control-flow semantics — the assembly-faithful lane is Python SimulatorSoc's; the two are complementary.

Vertical-slice TDD, incremental commits. AFK (the director merges).

The director-generated golden fixture (swept-amp pi-pulse + measure, 11 expts
x 100 reps x soft_avgs 2, testbench soccfg, strumento main + qick 0.2.422) is
the D14 wire contract's ground truth; the no-sweep variant (expts null: the
calibration pi gain, no loop axis) is generated with the same two-line recipe
(Device.load -> Seq -> StrumentoProgram -> to_compiled_job -> to_wire) for the
absent-axis shaping test.

Anatomy verified against qick source (NpEncoder/decode_array, dump_prog,
cfg2reg, freq2reg): envelopes = base64 int16 (n,2) I/Q pages with addr +
next_addr addressing; waves = freq/phase/env/gain/length/conf assignments;
prog_list/labels = the tProc lane (not interpreted — the envelope-level
boundary); loop_dims/avg_level/labels = the declared loop structure; acquire
= the output shape contract.
…N triggers)

The server rides its OWN extension — triggers Piccolo AND JSON (the payload
is JSON by contract; JSON.jl 1.7 is the light pure-Julia wire codec) — never
StrumentoPiccoloExt, whose triggers adding JSON would break the piccolo-only
load configuration the config checks pin. Nothing new in base: a weakdep, an
extensions entry, a compat pin, and the test-target injection (Pkg.test runs
the full configuration; CI unchanged).

The module docstring states the boundary up front — the twin models the
DEVICE response at the envelope level, never tProc-v2 control-flow semantics
(the assembly-faithful lane is Python SimulatorSoc's; complementary lanes) —
and the two wire-stack decisions (stdlib-Sockets HTTP; lazy sibling-extension
reach for TwinSoc). Slice 1 is the surface only: the TwinJobServer name and
its extension attachment, reached via Base.get_extension, never leaking onto
the parent module.
read_payload(soccfg, job_wire) decodes qick's dump_prog() through NpEncoder
semantics: envelope pages (base64 int16 (n,2) I/Q via decode_array's spec,
per-gen addressing with the next_addr watermark), the wave table (freq/phase
registers via int2freq/deg2reg's inverse, gain/length codes, the conf bits via
cfg2reg's layout), the played port plan (the static WPORT_WR pairing — which
wave plays on which generator, read as DATA, never as control flow), and the
declared loop structure + acquire block with the shape integrity check
(expts realized from loop_dims/avg_level, validated against the acquire
block's declaration — the same refuse-on-mismatch rule as the reference
board-side agent).

Wire numbering is 0-based throughout (generator channels, tProc ports, wave
indices in the plan are the payload's own). The golden fixture decodes
end-to-end: the 1152-sample gaussian page, the gain-0 wave at 4000 MHz, the
[100, 11] loop with the averaged axis at level 0, expts 11.
The reader names the defect and its location: missing contract keys (the
{overlay_id, program, acquire} form, dump_prog's central keys), envelope
malformations (truncated base64, non-(n,2) shapes, non-int16 dtypes), wave
entries missing fields, tmux conf bits (muxed generators are outside v1's
envelope level), port-plan references past the wave table, avg_level outside
the loop structure, and the full readout-surface integrity trio — the
acquire block's ro_chs and reads_per_shot are validated against the
program's own declarations (ro_chs keys, per-channel trigs) exactly like the
reference board-side agent: a declared shape that disagrees with the shipped
program is a failed job, never a silently mis-shaped buffer.
…tructed

translate_drive(payload): per generator channel, the port plan's waves in
play order reconstruct the analog drive at the envelope level. Envelope
samples scale from DAC codes to fractions of full scale (code/maxv), the gain
applies as the amplitude scale (gain_code/maxv, qick's -1..1 contract), the
carrier phase rotates the baseband quadratures, outsel is honored per
cfg2reg's semantics (product | dds | input | zero — the const-pulse path
included), and the segments concatenate in play order (the flat-top shape;
inter-wave TIMING is the tProc's lane). The envelope word addressing resolves
through the pages (wave.env is a word address INTO the page space — the
flat-top ramp-down resolves the same way), with the extent validated against
the page. The carrier frequency is validated against the declared Nyquist
zone and carried as the drive's frame definition — the v1 boundary stated in
the module docstring: the family systems are rotating-frame models at the
drive frequency; detuning modeling needs a family that carries absolute
transition frequencies (future record surface, honestly out of v1).
The soc-level actor over one twin face (family + confusion + seeded response),
the overlay personality refusal (D25: one overlay, one snapshot), and the
server-owned clock (drift advances ACROSS jobs via advance!, dt=0 per acquire
— job k measures truth aged (k-1)·dt).

execute_job runs the wire payload through the twin face and shapes the
RawAcquisition wire form: (n_reads, [expts,] 2) IQ as JSON-safe nested lists —
the expts level ABSENT when the payload declares no sweep — exactly Python's
RawAcquisition.to_wire (tolist)/from_wire (asarray) round trip. Conventions
documented in the docstring: quantum time in ns (dac_rate = fs in samples per
ns), the averaging depth (reps × soft_avgs batched into one accumulated draw
from the twin's single seeded rng), the 2-outcome IQ packing, and the v1
amplitude scale (a full-scale DAC drive is 1.0 family unit — a calibrated
rad/ns-per-full-scale mapping is future record surface).

The CloseLoop sweep ladder (qick's encoding-A: read_wmem → literal wave-field
increments → write_wmem, compiled inside the expts loop) is decoded in
read_payload as static DATA — never tProc register simulation (the expts AXIS
still rides the declared loop structure). translate_drive gains the per-expt
parameter: the stepped wave's gain code offsets by step × (expt − 1); a
payload with no ladder replays the identical assignment. v1 realizes gain
steps only; anything else (orphan WMEM_WR, register arithmetic, non-gain
fields, one ladder writing a wave twice) is named, not guessed.
…expt

A committed real-span fixture (compiled_job_realspan.json, generated from the
fresh Python stack — loopback device, testbench soccfg, reps 100, soft_avgs 2,
overlay testbench-v2 — the same recipe that reproduces the zero-span golden
byte-for-byte with Sweep("amp", 0, 0, 11); the real span is
Sweep("amp", 0, 8191, 11)).

qick compiles the real span to the CloseLoop encoding-A ladder: read_wmem →
literal wave-field increments → write_wmem — and, discovered here, a SECOND
restore ladder AFTER the expts loop (the compiler leaving wave memory as it
found it, once per reps iteration). The reader anchors the ladder split on the
expts loop's back-edge (the TEST whose literal counter equals the DECLARED
expts count − 1, followed by the conditional JUMP — a consistency check
against the declared loop structure, not register simulation): the in-loop
ladder realizes per-expt steps, the out-of-loop restore is decoded and
validated but never realized. Wave-field registers are qick's own map
(QickProgramV2.REG_ALIASES); v1 realizes gain steps only, one ladder per wave,
and refuses orphan WMEM_WRs, register-arithmetic steps, and non-gain fields by
name.

The IQ-trend AC rides the fixture: the excited-state frequency rises with the
ladder-stepped gain (the rising edge pinned; the toy's detuned response is a
resonance lobe — the contract is the trend, not monotonicity). The ladder
decode is pinned as exact integers: [(1, "gain", 819)] — the declared
0..8191 span register-rounded to the realized axis, what the tProc steps.
…tocol

The queue mirrors the reference agent (examples/jobserver/server.py): submit!
enqueues incrementing-string ids FIFO; poll is the single worker's turn (one
board, one worker — hardware exclusivity is structural) and returns the status
dicts the Python JobServerClient promises — {"status": "pending"} while
queued, {"status": "done", "acquisition": {...}} on success,
{"status": "error", "error": "<type>: <message>"} on failure, the
payload never riding the status dict. A failed job is a failed JOB, not a
server crash — the next submit still runs.

serve_http speaks the two wire routes over stdlib Sockets (HTTP/1.1, one
request per connection, JSON bodies): POST /jobs → {"job_id"}, GET
/jobs/<id> → the status dict with unknown ids mapped to 404 (a failed job is
still a KNOWN job — 200 with the error as data). The accept loop is an @async
task; each connection is handled independently — a malformed request costs its
own connection, never the server. stop_http closes the listener and joins the
task; queued jobs stay pending (a stopped server is a stopped board — draining
the queue is the operator's act).

Sockets rides base [deps] as the stdlib edge it is (the Base64 precedent:
stdlibs load everywhere, no new package dependency edge, base-only consumers
unchanged) — the HTTP-via-stdlib decision the issue asks to be documented.

The contract test drives the server exactly the way the Python
JobServerClient would: submit → poll → the RawAcquisition wire form, over a
real socket, with the malformed-payload survival and the 404 mapping pinned.
…ment

The seeded-replay AC at the wire: two FRESH Julia processes (fresh twins,
fresh rngs, the same seed) produce BIT-IDENTICAL server responses — the JSON
strings compare equal, a replay pin (==), not a captured-value golden (the
campaign CI rule). Sampled mode: the shot draws are the seeded surface this
pins. Different seeds differ.

The README gains the twin-job-server section: the D14 wire diagram, the
explicit envelope-level boundary (the twin models the DEVICE response; tProc
control flow is SimulatorSoc's complementary lane), the reference agent's
queue shape, the stdlib HTTP routes, the soc-level actor's across-job drift
clock, and the documented conventions — and the stale Status line ("the wire
server [is a] later slice") now says what landed.
@aarontrowbridge
aarontrowbridge marked this pull request as ready for review September 2, 2026 10:03
@aarontrowbridge
aarontrowbridge merged commit 1e30532 into main Sep 2, 2026
1 check passed
@aarontrowbridge
aarontrowbridge deleted the 29-twin-job-server branch September 2, 2026 10:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

M3a: the twin job server — the D14 wire contract (CompiledJob in, RawAcquisition out)

1 participant