spec-to-sequence
Skill9 starsChanged 58 days agoApache-2.0View on GitHub
Generate a Pulser sequence builder for a neutral-atom (Rydberg) experiment from an experiment_spec.json. Output is a *_sequence.py with two standardised functions — build_sequence() and compute_observable() — consumed by validate-emu, qpu-submit and harvest-and-analyze. Triggered by phrases like "generate the Pulser sequence", "write the sequence file for this spec", "spec to sequence", "turn the spec into Pulser code".
In this repository11 other files it gives its agents.
- AGENTS.mdAGENTS.md
- application-to-ideaSkill
- apply-genci-tgcc-ceaSkill
- choose-access-methodSkill
- create-pasqal-cloud-accountSkill
- harvest-and-analyzeSkill
- idea-to-specSkill
- noise-emulateSkill
- qpu-submitSkill
- submit-to-ceaSkill
- validate-emuSkill
The file
We split this skill into the rules it gives an agent
11 rules. About 0 are common, 6 are distinctive, and 5 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈6
Distinctive · estimate
This author's own choices. Worth a look.
5
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 32
how the spec's geometry maps onto a Pulser register: when the spec is ambiguous (which sites, which layout, what happens at the boundary)
- Distinctive: line 39
If the sequence will not build or the smoke test will not pass after about
- Distinctive: line 117
Put this at module level, with the imports:
- Very specific: line 192
Write a private _build_register(device=None, params) function.
- Distinctive: line 223
Always add the layout guard (required by real FC1/Ruby device, skipped for VirtualDevice):
- Distinctive: line 353
Add at the bottom of the file:
- Very specific: line 392
Run python experiments/<name>/<name>_sequence.py — every line must read ok.
- Distinctive: line 397
State the disordered floor: if the observable has one.
- Very specific: line 401
Assert what the observable silently assumes: If it reconstructs atom coordinates by bitstring index, check that the register's qubit_ids …
- Very specific: line 412
Read the size off the counts instead, take the modal bitstring length so a few truncated detections cannot redefine the register, and …
- Very specific: line 441
Next step: run validate-emu with this spec and sequence file
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
spec-to-sequence
Generate experiments/<name>/<name>_sequence.py from
experiments/<name>/<name>_spec.json — beside the spec, in the experiment's own
tree, not in the working directory's root.
RUNS ON: this machine. Writing and smoke-testing a sequence file costs nothing and contacts nothing.
The file must export two required public functions with standardised
signatures, and may export two more (build_parametric_sequence and
observable_floor, below).
Everything else (register helpers, intermediate constants) can be private.
Decisions that are not yours
A geometry or an observable has more than one defensible encoding, and picking one silently makes the result unreviewable. Ask, with the alternatives named:
- how the spec's geometry maps onto a Pulser register when the spec is ambiguous (which sites, which layout, what happens at the boundary)
- the observable's exact definition — the wavevector, the sign convention, whether the mean occupation is subtracted
- any deviation from the spec you had to make for the device to accept the
sequence, which belongs in the spec's
_notestoo
If the sequence will not build or the smoke test will not pass after about three attempts, stop and report: the error, what you changed, and what you now believe is wrong (the spec, the device limit, or the observable). Then offer changing the spec, reducing the register, or a different observable. Do not keep editing the same file hoping the constraint moves.
The functions this file exports
build_sequence(device=None, **params) -> pulser.Sequence
Returns a non-parametric (fully built) Pulser sequence — no declare_variable,
no .build() call required downstream.
def build_sequence(device=None, **params) -> pulser.Sequence:
"""
Build a non-parametric sequence with all parameter values baked in.
device : live Pulser Device object from the cloud SDK, or None for local
validation (falls back to AnalogDevice / MockDevice).
params : merged dict of spec["scan"]["fixed_params"] and
{spec["scan"]["variable"]: current_value}.
Example: {"tau_ns": 4000, "delta_f_mhz": 6.0}
"""It must also accept omega_offset=1.0 (multiplies Ω) and delta_offset=0.0
(added to δ, in MHz): qpu-submit passes its calibration through them, and
refuses a builder without them before anything is spent.
build_parametric_sequence(device=None, **fixed) -> (Sequence, (var_name,))
Optional, and worth writing. Returns a Pulser sequence with the spec's scan variable left as a declared variable, plus the tuple of variable names — exactly one, the scan variable:
def build_parametric_sequence(device=None, **fixed):
# Same physics as build_sequence, with the scan variable left free.
# Lets qpu-submit send one batch-level sequence and bind one value per job,
# which is what the cloud is built for. Without it, the same scan still goes
# out as one batch, with each job carrying its own serialized sequence.
seq = pulser.Sequence(_register(device, **fixed), device)
seq.declare_channel("ising", "rydberg_global")
t = seq.declare_variable("t_ns", dtype=int) # the spec's scan variable
... # same schedule, t in place
return seq, ("t_ns",)Everything else — fixed_params, the calibration offsets — is baked in as it is
in build_sequence. Write it when the scan variable maps cleanly onto a Pulser
variable (a duration, an amplitude, a detuning); skip it when it would change
the register or the number of pulses, and say why in the file.
compute_observable(counts: dict[str, int]) -> float
Compute the target observable. Must close over experiment-specific constants (lattice coordinates, wavevector, N) defined at module level.
def compute_observable(counts: dict[str, int]) -> float:
"""
Compute the target observable from bitstring counts.
counts maps bitstring -> integer count.
Returns a scalar.
"""Provenance stamp — required
Pulser records, inside every serialized sequence, which packages produced it.
Stamping the toolkit there is what later identifies the sequence as
agent-generated: it travels with the abstract representation into the cloud, the
QPU batch and any saved JSON, so a submission stays attributable long after the
experiments/ directory is gone.
Put this at module level, with the imports:
# Provenance: marks every serialized sequence as produced by this toolkit.
try:
from pulser.sequence.metadata import store_package_version_metadata
except ImportError: # older pulser has no sequence metadata
pass
else:
store_package_version_metadata("neutral-atom-toolkit", "<toolkit version>")Two things to get right:
- Read
<toolkit version>from the toolkit manifest, do not recall it. Theversionfield ofplugin.json, two directories above this skill's own directory. Write the value you read as a literal — the generated file is copied into HPC bundles and run where no manifest exists, so a lookup at run time would either crash or report the wrong version. - Keep the
try/except. Sequence metadata is a recent Pulser addition and there is no repo-wide floor that guarantees it, so a bare import would take the whole sequence file down over a provenance tag. The stamp is optional; the sequence is not.
One call, package_versions only. Nothing else belongs in the metadata.
The reference implementations and the examples carry the same header with the
version fixed at 0.0.0. That is a sentinel, not a release: those files are
committed rather than generated, so they claim no version. Copy the header from
them and substitute the real one — the Step 5 smoke test rejects 0.0.0, which
is what catches a header pasted across unchanged.
One ordering trap, latent today: the metadata is a process-wide mapping and the
last write for a key wins. A script that imported an example and a generated
sequence file would serialize both under whichever stamp ran second. Nothing
does — a skill may not import examples/ — so this only matters if you write
something that loads both.
observable_floor(n_atoms: int) -> float
Optional. What the observable reads on a disordered register of n_atoms
— e.g. ⟨|m|⟩ ≈ sqrt(π/2N). validate-emu measures retention above it; without
it the floor counts as retained signal, worst at the small local sizes.
Reference implementations
Study these before writing. They are the ground truth for FC1-compatible code.
| What you need | File |
| Triangular rhombus register | references/guo_triangular_sqrt3_sequence.py |
| Ring register | references/chiral_potts_n21_sequence.py |
| Adiabatic ramp pulse stack | guo_triangular_sqrt3_sequence.py::build_guo_sequence |
| S(K) structure factor | references/guo_triangular_analysis.py |
| Ring S(k) structure factor | chiral_potts_n21_sequence.py::structure_factor_k |
with_automatic_layout guard | chiral_potts_n21_sequence.py::build_ring_register |
The references/ directory is bundled inside this skill's own directory.
Step 1 — Read the spec
Load experiments/<name>/<name>_spec.json. Note:
register.geometry,register.builder_paramschannel.eom_mode(true = EOM, false = adiabatic ramp)pulse.typeand all pulse parametersscan.variable,scan.fixed_paramsobservable.typeandobservable.description
Step 2 — Register builder
Write a private _build_register(device=None, **params) function.
Geometry patterns:
# square L×L (use SquareLatticeLayout)
from pulser.register.special_layouts import SquareLatticeLayout
coords = SquareLatticeLayout(L, L, spacing_um).coords
reg = pulser.Register.from_coordinates(coords, prefix="q")
# chain: N atoms on x-axis, centred
xs = (np.arange(N) - (N-1)/2.0) * spacing_um
qubits = {f"q{i}": (float(xs[i]), 0.0) for i in range(N)}
reg = Register(qubits)
# ring: N atoms on circle, chord = spacing_um
R_ring = spacing_um / (2.0 * np.sin(np.pi / N))
theta = 2*np.pi*np.arange(N)/N
qubits = {f"q{i}": (R_ring*np.cos(theta[i]), R_ring*np.sin(theta[i])) for i in range(N)}
reg = Register(qubits)
# triangular rhombus: L×L on Bravais lattice, centred
a1 = np.array([spacing_um, 0.0])
a2 = np.array([spacing_um*0.5, spacing_um*np.sqrt(3)/2.0])
pts = np.array([i*a1 + j*a2 for i in range(L) for j in range(L)])
pts -= pts.mean(axis=0)
qubits = {f"q{i}_{j}": (float(pts[i*L+j,0]), float(pts[i*L+j,1]))
for i in range(L) for j in range(L)}
reg = Register(qubits)Always add the layout guard (required by real FC1/Ruby device, skipped for VirtualDevice):
if device is not None:
from pulser.devices import Device, VirtualDevice
if isinstance(device, Device) and not isinstance(device, VirtualDevice):
reg = reg.with_automatic_layout(device)The guard is the whole layout requirement. with_automatic_layout builds a
layout for whatever geometry the spec asks for, and Sequence(reg, device) is
the validity check: if it does not raise, the register is allowed. Do not
restrict the geometry to device.pre_calibrated_layouts or swap it for a
triangular one — on a device whose accepts_new_layouts is True (the Fresnel
devices) those are a convenience, not a constraint.
Step 3 — Pulse schedule
Units convention: Ω and δ in rad/µs; time in ns.
2 * np.pi * 2.0 = 2 MHz in rad/µs.
Adiabatic ramp (pulse.type == "adiabatic_ramp")
from pulser import Pulse, Sequence
from pulser.waveforms import RampWaveform, ConstantWaveform
omega_max = 2 * np.pi * params["omega_max_mhz"]
delta_i = 2 * np.pi * params["delta_i_mhz"]
delta_f = 2 * np.pi * params["delta_f_mhz"]
tau_ns = int(params["tau_ns"])
ramp_up = spec["pulse"]["ramp_up_ns"] # typically 100
ramp_down = spec["pulse"]["ramp_down_ns"] # typically 100
seq = Sequence(reg, device or AnalogDevice)
seq.declare_channel("ising", "rydberg_global")
seq.add(Pulse(amplitude=RampWaveform(ramp_up, 0.0, omega_max),
detuning=ConstantWaveform(ramp_up, delta_i), phase=0.0), "ising")
seq.add(Pulse(amplitude=ConstantWaveform(tau_ns, omega_max),
detuning=RampWaveform(tau_ns, delta_i, delta_f), phase=0.0), "ising")
seq.add(Pulse(amplitude=RampWaveform(ramp_down, omega_max, 0.0),
detuning=ConstantWaveform(ramp_down, delta_f), phase=0.0), "ising")
seq.measure("ground-rydberg")EOM quench (pulse.type == "eom_quench")
C6 = device.interaction_coeff if device else AnalogDevice.interaction_coeff
ci = (L//2)*L + (L//2) # central atom index (square lattice)
diff = coords - coords[ci]
dist = np.linalg.norm(diff, axis=-1); dist[ci] = np.inf
delta = C6 * np.sum(1.0 / dist**6) # mean-field detuning at centre
omega = 2 * np.pi * params["omega_max_mhz"]
t_ns = int(params["t_ns"])
seq = Sequence(reg, device or AnalogDevice)
seq.declare_channel("ising", "rydberg_global")
seq.enable_eom_mode("ising", amp_on=omega, detuning_on=delta)
seq.add_eom_pulse("ising", duration=t_ns, phase=0.0)
seq.disable_eom_mode("ising")
seq.measure("ground-rydberg")Step 4 — Observable function
Define module-level constants first (these are closed over by compute_observable):
Structure factor S(K)
# Module-level constants (set once from spec)
_L = spec["register"]["builder_params"]["L"]
_SPACING = spec["register"]["builder_params"]["spacing_um"]
_COORDS = <lattice coords as (N,2) array>
_N = len(_COORDS)
# Wavevector from observable.description:
# "sqrt3" → K = (4π/3a)(1,0)
# "pi" → K = (π/a)(1,1) [2D Néel]
# "2pi/3" → handled as ring index below
_K = np.array([4.0*np.pi/(3.0*_SPACING), 0.0]) # example: sqrt3
def compute_observable(counts: dict[str, int]) -> float:
total = sum(counts.values())
if total == 0:
return float("nan")
sk = 0.0
for s, c in counts.items():
bits = np.array([int(ch) for ch in s[:_N]], dtype=float)
n_bar = bits.mean()
phases = np.exp(1j * (_COORDS @ _K))
sk += c * abs((phases * (bits - n_bar)).sum()) ** 2
return float(sk / (total * _N))Ring structure factor S(k) at k = 2π/3
_N = <n_atoms>
_K_RING = 2.0 * np.pi / 3.0 # Z₃ ordering wavevector in index space
def compute_observable(counts: dict[str, int]) -> float:
total = sum(counts.values())
if total == 0:
return float("nan")
nn = np.zeros((_N, _N))
for s, c in counts.items():
bits = np.array([int(ch) for ch in s[:_N]], dtype=float)
nn += c * np.outer(bits, bits)
nn /= total
ij = np.arange(_N)
diff = ij[:, None] - ij[None, :]
return float((nn * np.cos(_K_RING * diff)).sum() / _N**2)Step 5 — Smoke test
Compare the observable against states whose value you can derive by hand, and exit non-zero when one moves. A printed value nobody compares is not a test: it catches a crash and nothing else, so a sign error or a dropped term passes while still looking plausible.
Add at the bottom of the file:
if __name__ == "__main__":
import json
from pathlib import Path
spec = json.loads(
Path(__file__).with_name("<experiment_name>_spec.json").read_text())
scan = spec["scan"]
mid = scan["values"][len(scan["values"])//2]
params = {**scan["fixed_params"], scan["variable"]: mid}
seq = build_sequence(device=None, **params)
N = len(seq.register.qubit_ids)
print(f"Sequence OK: {seq.get_duration()} ns, {N} atoms")
# The provenance stamp only reaches the serialized form, so check it there.
# 0.0.0 means the header was copied from an example without substituting
# the real version — see the module header.
stamped = json.loads(seq.to_abstract_repr()).get("metadata", {})
version = stamped.get("package_versions", {}).get("neutral-atom-toolkit")
if version in (None, "0.0.0"):
raise SystemExit(f"✘ provenance stamp is {version!r}, expected a version")
failures = []
for label, counts, want, tol in [
("vacuum", {"0" * N: 100}, 0.0, 1e-12),
# add one perfectly ordered pattern, whose value you derived by hand
]:
got = compute_observable(counts)
ok = abs(got - want) <= tol
print(f" {'ok ' if ok else 'FAIL'} {label}: {got:+.4f} "
f"(expect {want:+.4f} ± {tol:g})")
if not ok:
failures.append(label)
if failures:
raise SystemExit(f"✘ compute_observable wrong for: {', '.join(failures)}")Run python experiments/<name>/<name>_sequence.py — every line must read ok.
The spec is resolved beside the file, so the smoke test works from anywhere.
Two things to get right in the comparisons:
- State the disordered floor if the observable has one. An order parameter built from a modulus does not go to zero on random data: ⟨|m|⟩ on an uncorrelated array reads √(π/2N), which is 0.18 on 49 atoms. Print that floor next to the measurement, or a null result reads as weak order.
- Assert what the observable silently assumes. If it reconstructs atom
coordinates by bitstring index, check that the register's
qubit_idsorder and coordinates survivedwith_automatic_layout— a reordering returns a plausible wrong number rather than failing. Derive the register size from the shots, never from a module constant.
compute_observable(counts)receives no geometry, so it is tempting to close over the spec's N. Do not: the same file is emulated at reduced size on the way to hardware — locally, where 49 atoms are out of reach, or on a device that caps lower — and every shot then fails the length check and is skipped. The function returnsnan, which reads as no signal rather than wrong size, so a downsized run looks like failed physics and the real defect is invisible. Read the size off the counts instead, take the modal bitstring length so a few truncated detections cannot redefine the register, and returnnanonly when the length is genuinely inconsistent with any register you could have built:def register_size_from_counts(counts: dict[str, int]) -> int: weight: dict[int, int] = {} for bitstring, count in counts.items(): weight[len(bitstring)] = weight.get(len(bitstring), 0) + count return max(weight, key=weight.get) if weight else 0Then add a smoke-test case at a second size — a 3×3 patch beside the 7×7 — so the inference is exercised rather than assumed. Quantities that scale together often cancel and need no adjustment: for a wavevector K ∝ 1/a and positions r ∝ a, K·r is spacing-independent. Say which ones you checked.
Step 6 — Report
- Confirm the file is written beside the spec, at
experiments/<name>/+spec["sequence_file"] - Show smoke test output — the actual lines, not a summary of them
- Print Rb/a = (C6/Ω)^(1/6) / spacing to verify blockade regime
- Say whether
build_parametric_sequencewas written, and if not, why — it decides which shapeqpu-submituses for the batch - Append the block to
experiments/<name>/NOTEBOOK.md: what was generated, which reference it followed, the smoke-test result, Rb/a - Next step: run
validate-emuwith this spec and sequence file
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

