Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ dist/
*.vsix
.vscode-test/
*.log
!packages/extension/demo/run/run.log
packages/extension/vendor/
packages/extension/bin/
7 changes: 7 additions & 0 deletions packages/amico-run/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ export async function main(argv: string[]): Promise<number> {
if (!script) { console.error(`amico-run: no script given\n${USAGE}`); return 64 }
if (executor !== 'local') { console.error(`amico-run: only --executor local is supported in β`); return 64 }

// NOTE: `--sysimage <path>` is honored (passed through to the Julia process and
// recorded in the manifest) but amicode does NOT build one — the local
// PackageCompiler build (~25-50 min, CairoMakie-dominated) wasn't worth it. The
// intended fast-path is a prebuilt sysimage distributed like Piccolissimo's
// (CI build on self-hosted runners → R2 → manifest → download), pointed at via
// this flag. Until that exists, solves pay the cold start (inspector warms up).

let handle
try {
handle = await new LocalExecutor().submit(script, opts)
Expand Down
20 changes: 12 additions & 8 deletions packages/extension/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,22 @@ and the Run Inspector renders the live solve.

1. Read the bundled template `solve_template.jl` at its absolute path:
`{{TEMPLATE_PATH}}`.
2. Copy it to a working file (e.g. `solve.jl`) and fill in the `# FILL IN`
parameter block from the user's request: transmon frequency `ω` (GHz),
anharmonicity `δ` (GHz), `levels`, the target gate, gate time `T` (ns),
timesteps `N`, `max_iter`. **Parameters live in the script — never in this
file.** If the user gives a `lab.toml` path, read it in the script.
2. Copy it to `/tmp/amicode-work/solve.jl` (the exact path step 3 runs) and fill
in the `# FILL IN` parameter block from the user's request: transmon frequency
`ω` (GHz), anharmonicity `δ` (GHz), `levels`, the target gate, gate time `T`
(ns), timesteps `N`, `max_iter`. **Parameters live in the script — never in
this file.** If the user gives a `lab.toml` path, read it in the script.
```bash
mkdir -p /tmp/amicode-work && cp {{TEMPLATE_PATH}} /tmp/amicode-work/solve.jl
# …edit /tmp/amicode-work/solve.jl's FILL IN block…
```
3. Run it **detached** so the chat doesn't block on the ~minutes-long solve:
```bash
mkdir -p /tmp/amicode-work
( nohup amico-run --project <JULIA_PROJECT> /tmp/amicode-work/solve.jl \
( nohup amico-run --project <JULIA_PROJECT> --lab default /tmp/amicode-work/solve.jl \
> /tmp/amicode-work/solve.log 2>&1 < /dev/null & )
```
(use the project path provided below). The outer subshell returns in <1s.
(use the project path provided below; `--lab default` tags the run's lab so
it's recorded under `~/.amico/runs/default/`). The outer subshell returns in <1s.
`amico-run` takes only a script path and runner flags — it parses **no**
physics options; all the physics lives in the script you wrote. Then
immediately tell the user: **"Solve launched — watch the Run Inspector
Expand Down
129 changes: 129 additions & 0 deletions packages/extension/demo/run/run.log
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
constructing SmoothPulseProblem [UnitaryTrajectory]
┌ Warning: Trajectory has timestep variable :Δt but no bounds on it.
│ Adding default lower bound of 0 to prevent negative timesteps.
│ Recommended: Add explicit bounds when creating the trajectory:
│ NamedTrajectory(...; Δt_bounds=(min, max))
│ Example:
│ NamedTrajectory(qtraj, N; Δt_bounds=(1e-3, 0.5))
│ Or use timesteps_all_equal=true in problem options to fix timesteps.
└ @ DirectTrajOpt.Problems ~/.julia/packages/DirectTrajOpt/TIf6x/src/problems.jl:66
QuantumControlProblem
├─ UnitaryTrajectory · ZeroOrderPulse · BilinearIntegrator, DerivativeIntegrator, DerivativeIntegrator
├─ System
│ dim=3 drives=2
├─ Trajectory
│ N=50 T=10.000 Δt∈[0, Inf]
│ Ũ⃗ (18) ±[1.0, 1.0, 1.0, … (18 total)] ✓ state
│ Δt ( 1) [0.0, Inf] ✓ timestep
│ t ( 1) · state
│ u ( 2) ±[0.2, 0.2] ✓ control
│ du ( 2) · control
│ ddu ( 2) ±[1.0, 1.0] ✓ control
├─ Goal
│ EmbeddedOperator on [3], subspace dim 2
├─ Objective total = 44.95 @ current x
│ KnotPointObjective w=1 44.25
│ QuadraticRegularizer(:u) w=1 2.042e-04
│ QuadraticRegularizer(:du) w=1 9.849e-03
│ QuadraticRegularizer(:ddu) w=1 0.6964
│ NullObjective w=1 0
├─ Constraints 1/14 violated at x₀
│ [dyn] BilinearIntegrator ✗ (‖c‖∞ = 0.09876)
│ [dyn] DerivativeIntegrator ✓ (‖c‖∞ = 2.776e-17)
│ [dyn] DerivativeIntegrator ✓ (‖c‖∞ = 2.220e-16)
│ [ineq] AllEqualConstraint ✓ (no eval)
│ [eq] EqualityConstraint ✓ (no eval)
│ [eq] EqualityConstraint ✓ (no eval)
│ [eq] EqualityConstraint ✓ (no eval)
│ [bnd] BoundsConstraint ✓
│ [bnd] BoundsConstraint ✓
│ [bnd] BoundsConstraint ✓
│ [bnd] BoundsConstraint ✓
│ [bnd] BoundsConstraint ✓
│ [eq] TimeConsistencyConstraint ✓ (no eval)
│ [eq] EqualityConstraint ✓ (no eval)
└─ Status
variables: 1300 (1100 bounded)
equality: 52827
inequality: 1
F (raw) = 0.557531

Hint: show_problem(qcp; detail=:full) for pulse plot + sparsity

AMICODE_ITER iter=0 f=4.427415e+01 inf_pr=2.715e+00 inf_du=4.401e+00

******************************************************************************
This program contains Ipopt, a library for large-scale nonlinear optimization.
Ipopt is released as open source code under the Eclipse Public License (EPL).
For more information visit https://github.com/coin-or/Ipopt
******************************************************************************

AMICODE_ITER iter=1 f=3.312001e+00 inf_pr=1.962e+00 inf_du=1.835e+02
AMICODE_ITER iter=2 f=1.909965e+01 inf_pr=1.305e-01 inf_du=1.641e+02
AMICODE_ITER iter=3 f=2.688086e+01 inf_pr=2.734e-03 inf_du=1.741e+02
AMICODE_ITER iter=4 f=2.499717e+01 inf_pr=2.649e-04 inf_du=1.925e+02
AMICODE_ITER iter=5 f=2.047457e+01 inf_pr=1.168e-03 inf_du=1.436e+00
AMICODE_ITER iter=6 f=1.371681e+01 inf_pr=1.334e-03 inf_du=1.135e+00
AMICODE_ITER iter=7 f=2.567280e+00 inf_pr=5.849e-03 inf_du=4.532e+00
AMICODE_ITER iter=8 f=1.243844e+00 inf_pr=1.448e-02 inf_du=5.058e+03
AMICODE_ITER iter=9 f=2.715532e+00 inf_pr=7.488e-03 inf_du=1.266e+05
AMICODE_ITER iter=10 f=2.760619e+00 inf_pr=2.607e-04 inf_du=2.939e+04
AMICODE_ITER iter=11 f=2.141433e+00 inf_pr=5.075e-05 inf_du=2.069e+00
AMICODE_ITER iter=12 f=1.090665e+00 inf_pr=3.007e-04 inf_du=1.943e-01
AMICODE_ITER iter=13 f=2.750855e-02 inf_pr=7.233e-04 inf_du=1.921e+02
AMICODE_ITER iter=14 f=7.579673e-01 inf_pr=6.602e-04 inf_du=1.915e+02
AMICODE_ITER iter=15 f=8.604789e-01 inf_pr=9.254e-07 inf_du=9.379e-01
AMICODE_ITER iter=16 f=7.798056e-01 inf_pr=4.902e-06 inf_du=1.591e-01
AMICODE_ITER iter=17 f=5.809716e-01 inf_pr=3.379e-05 inf_du=1.330e-01
AMICODE_ITER iter=18 f=2.319975e-01 inf_pr=1.465e-04 inf_du=9.073e-02
AMICODE_ITER iter=19 f=5.657439e-02 inf_pr=2.357e-04 inf_du=1.924e+02
AMICODE_ITER iter=20 f=1.418744e-01 inf_pr=1.465e-04 inf_du=1.923e+02
AMICODE_ITER iter=21 f=1.615507e-01 inf_pr=2.529e-07 inf_du=5.215e-01
AMICODE_ITER iter=22 f=1.421320e-01 inf_pr=1.716e-06 inf_du=6.246e-02
AMICODE_ITER iter=23 f=9.743153e-02 inf_pr=1.068e-05 inf_du=5.106e-02
AMICODE_ITER iter=24 f=3.100677e-02 inf_pr=3.676e-05 inf_du=3.206e-02
AMICODE_ITER iter=25 f=2.149879e-02 inf_pr=4.050e-05 inf_du=1.924e+02
AMICODE_ITER iter=26 f=2.110705e-02 inf_pr=1.111e-05 inf_du=1.924e+02
AMICODE_ITER iter=27 f=2.287865e-02 inf_pr=4.976e-08 inf_du=3.663e-01
AMICODE_ITER iter=28 f=2.057141e-02 inf_pr=2.819e-07 inf_du=1.757e-02
AMICODE_ITER iter=29 f=1.566875e-02 inf_pr=1.540e-06 inf_du=1.382e-02
AMICODE_ITER iter=30 f=9.845458e-03 inf_pr=3.980e-06 inf_du=7.545e-03
AMICODE_ITER iter=31 f=1.037462e-02 inf_pr=3.020e-06 inf_du=1.924e+02
AMICODE_ITER iter=32 f=9.708787e-03 inf_pr=1.299e-06 inf_du=1.924e+02
AMICODE_ITER iter=33 f=9.757572e-03 inf_pr=4.887e-08 inf_du=3.527e-01
AMICODE_ITER iter=34 f=9.648300e-03 inf_pr=2.990e-08 inf_du=3.208e-03
AMICODE_ITER iter=35 f=9.435164e-03 inf_pr=8.478e-08 inf_du=2.684e-03
AMICODE_ITER iter=36 f=9.281332e-03 inf_pr=1.850e-07 inf_du=1.924e+02
AMICODE_ITER iter=37 f=9.315456e-03 inf_pr=3.351e-07 inf_du=1.924e+02
AMICODE_ITER iter=38 f=9.332486e-03 inf_pr=1.542e-10 inf_du=1.467e-02
AMICODE_ITER iter=39 f=9.317899e-03 inf_pr=1.125e-09 inf_du=2.186e-03
AMICODE_ITER iter=40 f=9.283247e-03 inf_pr=7.451e-09 inf_du=2.047e-03
AMICODE_ITER iter=41 f=9.226014e-03 inf_pr=3.074e-08 inf_du=1.767e-03
AMICODE_ITER iter=42 f=9.195011e-03 inf_pr=6.195e-08 inf_du=1.924e+02
AMICODE_ITER iter=43 f=9.136038e-03 inf_pr=3.153e-07 inf_du=1.924e+02
AMICODE_ITER iter=44 f=9.137616e-03 inf_pr=4.528e-11 inf_du=7.841e-03
AMICODE_ITER iter=45 f=9.134479e-03 inf_pr=3.146e-10 inf_du=1.453e-03
AMICODE_ITER iter=46 f=9.126828e-03 inf_pr=2.038e-09 inf_du=1.389e-03
AMICODE_ITER iter=47 f=9.111456e-03 inf_pr=8.248e-09 inf_du=1.377e-03
AMICODE_ITER iter=48 f=9.088690e-03 inf_pr=5.431e-08 inf_du=1.924e+02
AMICODE_ITER iter=49 f=9.001065e-03 inf_pr=4.205e-07 inf_du=1.924e+02
AMICODE_ITER iter=50 f=9.000330e-03 inf_pr=7.382e-10 inf_du=3.516e-02
AMICODE_ITER iter=51 f=8.998946e-03 inf_pr=2.533e-10 inf_du=1.200e-03
AMICODE_ITER iter=52 f=8.994278e-03 inf_pr=1.160e-09 inf_du=1.212e-03
AMICODE_ITER iter=53 f=8.982299e-03 inf_pr=9.944e-09 inf_du=1.924e+02
AMICODE_ITER iter=54 f=8.943076e-03 inf_pr=8.767e-08 inf_du=1.924e+02
AMICODE_ITER iter=55 f=8.942956e-03 inf_pr=6.080e-12 inf_du=3.079e-03
AMICODE_ITER iter=56 f=8.942226e-03 inf_pr=2.578e-11 inf_du=1.137e-03
AMICODE_ITER iter=57 f=8.940084e-03 inf_pr=2.319e-10 inf_du=1.141e-03
AMICODE_ITER iter=58 f=8.933903e-03 inf_pr=2.064e-09 inf_du=1.924e+02
AMICODE_ITER iter=59 f=8.917972e-03 inf_pr=1.428e-08 inf_du=1.924e+02
AMICODE_ITER iter=60 f=8.917966e-03 inf_pr=7.926e-13 inf_du=1.106e-03
DONE fidelity=0.9999788203047787
76 changes: 68 additions & 8 deletions packages/extension/src/file_watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { getInspector } from "./run_inspector";
import type { StatusBarManager } from "./status_bar";
import type { RunStatus } from "./types";
import {
AMICODE_ITER_RE, ITER_PNG_RE, ingestRunDir, readTomlSafe, parseAmicoNum,
AMICODE_ITER_RE, ITER_PNG_RE, ingestRunDir, readTomlSafe, parseAmicoNum, SinkDedup,
type IterRecord, type RunCompletion, type PromoteInfo, type RunSink,
} from "./run_dir_reader";

Expand Down Expand Up @@ -34,7 +34,9 @@ export interface RunsRootWatcherOptions {
/** Live sink: routes to the Inspector + status bar, carrying newest-wins and
* promote-once guards so replay-then-incremental never double-fires. */
class LiveRunSink implements RunSink {
private latestIter = -1;
/** Newest-wins guard: frame display vs log-line iters tracked separately so the
* log high-water mark can't suppress lagging frames (see SinkDedup). */
private readonly dedup = new SinkDedup();
constructor(
private readonly opts: RunsRootWatcherOptions,
private readonly runId: string,
Expand All @@ -44,12 +46,11 @@ class LiveRunSink implements RunSink {
) {}

image(fsPath: string, iter: number): void {
if (iter <= this.latestIter) return;
this.latestIter = iter;
if (!this.dedup.acceptFrame(iter)) return; // dedup on FRAMES only — see SinkDedup
getInspector()?.setImageSource(fsPath, iter);
}
iter(rec: IterRecord): void {
if (rec.iter > this.latestIter) this.latestIter = rec.iter;
this.dedup.noteIter(rec.iter);
getInspector()?.postIterationRecord(rec);
// Live status-bar update — show "running · iter N" as it solves, not only at
// completion (#5 AC3).
Expand All @@ -65,7 +66,7 @@ class LiveRunSink implements RunSink {
getInspector()?.postCompletion(c.status, c.fidelity);
this.opts.statusBar?.setRun({
runId: c.runId, outputDir: c.runDir, startedAt: 0,
status: c.status, latestIter: this.latestIter >= 0 ? this.latestIter : undefined,
status: c.status, latestIter: this.dedup.high >= 0 ? this.dedup.high : undefined,
fidelity: c.fidelity,
});
this.opts.channel.appendLine(`[runs] ${c.runId} ${c.status}${c.fidelity !== undefined ? ` F=${c.fidelity.toFixed(6)}` : ""}`);
Expand Down Expand Up @@ -100,20 +101,65 @@ export class RunsRootWatcher implements vscode.Disposable {
* promote prompt fires at most once per run, never re-popping on re-switch /
* launch-follows-latest. */
private readonly promotedRuns = new Set<string>();
/** Polling backstop. macOS fs.watch (FSEvents) coalesces and silently drops
* events — especially under load — so the symlink-follow + per-frame watches
* miss `latest` swings and `iter_*.png` creations, leaving the inspector
* stuck (no live frames). A cheap periodic rescan guarantees delivery; the
* fs.watch paths stay for low latency. All sinks are idempotent (frame dedup
* by iter, finishedSeen, log byte-offset), so double-delivery is harmless. */
private poll?: NodeJS.Timeout;
private static readonly POLL_MS = 700;

constructor(private readonly opts: RunsRootWatcherOptions) {}

start(): void {
fs.mkdirSync(this.opts.runsRoot, { recursive: true });
const latest = path.join(this.opts.runsRoot, "latest");
if (fs.existsSync(latest)) this.followLatest();
if (fs.existsSync(latest)) {
// On launch, stay IDLE for a previous, already-finished run — don't re-display
// its last plot. Only resume a still-running run. A run that starts AFTER
// launch is picked up normally (idle → warming → frames). To baseline a
// finished run we set activeRunDir WITHOUT a sink, so the poll won't render it.
try {
const target = fs.realpathSync(latest);
if (fs.existsSync(path.join(target, "FINISHED"))) { this.activeRunDir = target; this.finishedSeen = true; }
else this.followLatest();
} catch { /* noop */ }
}
this.rootWatcher = fs.watch(this.opts.runsRoot, { persistent: false }, (_e, filename) => {
if (filename === "latest") this.followLatest();
});
this.opts.channel.appendLine(`[runs] watching ${this.opts.runsRoot}`);
this.poll = setInterval(() => this.tick(), RunsRootWatcher.POLL_MS);
this.opts.channel.appendLine(`[runs] watching ${this.opts.runsRoot} (fs.watch + ${RunsRootWatcher.POLL_MS}ms poll)`);
}

/** fs.watch backstop: re-resolve `latest`, then rescan the active run for new
* frames / FINISHED and drain the log — catching anything FSEvents dropped. */
private tick(): void {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[important] This poll backstop — plus the idle-on-finished baseline in start() and the warming-up transition — is the most intricate new code in the release, and none of it has a test. The new SinkDedup unit test covers the dedup primitive, not this state machine. One test that appends iter_*.png + AMICODE_ITER lines to a temp run dir over time and asserts the sink/inspector calls (incl. a finished run at launch staying idle, and warming → first frame) would close it. Fine as a tracked follow-up if you don't want to block the unit — but it's the riskiest untested code in the stack.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in e83d76a — added test/watcher_statemachine.test.ts covering exactly this. It drives RunsRootWatcher over a temp run dir and asserts the inspector calls: (a) a run already FINISHED at launch stays idle (no stale plot rendered, even after a poll tick); (b) a fresh run shows warming-up, then the poll delivers the newest frame, then completion fires once. Infra to make the vscode-coupled watcher testable: a minimal vscode stub aliased in vitest.config.ts, the inspector mocked to capture calls, and tick() driven directly so the poll path is deterministic (no 700ms-timer race). 49 tests green.

try {
if (fs.existsSync(path.join(this.opts.runsRoot, "latest"))) this.followLatest();
const runDir = this.activeRunDir;
if (!runDir || !this.sink) return;
// Deliver only the NEWEST frame this tick — frames produced between two
// ticks are intentionally skipped. The inspector shows the latest pulse,
// not an animation, so a coalesced frame is no loss (and the fs.watch path
// still catches most frames at low latency). Not a dropped-frame bug.
let newest = -1, newestPath: string | undefined;
for (const f of fs.readdirSync(runDir)) {
const m = ITER_PNG_RE.exec(f);
if (m) { const k = parseInt(m[1], 10); if (k > newest) { newest = k; newestPath = path.join(runDir, f); } }
}
if (newestPath) this.sink.image(newestPath, newest); // deduped by lastFrameIter

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] The poll delivers only the newest frame per tick, so frames produced between two ticks are skipped on this path. Correct for "show the latest pulse" (not an animation) — worth a one-line comment saying so, since otherwise it reads like a dropped-frame bug.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — added a comment in e83d76a: the poll intentionally delivers only the newest frame per tick (latest pulse, not an animation; the fs.watch path still catches most frames at low latency), so a coalesced frame is no loss. The new state-machine test also asserts the newest-wins behavior explicitly (two frames between ticks → only iter_18 delivered).

if (!this.finishedSeen && fs.existsSync(path.join(runDir, "FINISHED"))) {
this.finishedSeen = true; this.onFinished(runDir);
}
this.logTailer?.poke(); // drain appended AMICODE_ITER lines
} catch { /* transient fs race — next tick retries */ }
}

dispose(): void {
if (this.poll) clearInterval(this.poll);
this.poll = undefined;
try { this.rootWatcher?.close(); } catch { /* noop */ }
try { this.activeRunWatcher?.close(); } catch { /* noop */ }
this.logTailer?.dispose();
Expand Down Expand Up @@ -146,6 +192,7 @@ export class RunsRootWatcher implements vscode.Disposable {

this.sink = new LiveRunSink(this.opts, runId, runDir, this.promotedRuns);
getInspector()?.reveal();
getInspector()?.setRunLabel(runId);

// Replay everything already on disk (late-join safe). Returns the run.log
// bytes consumed so the tailer attaches exactly there (no skipped iters).
Expand All @@ -154,6 +201,11 @@ export class RunsRootWatcher implements vscode.Disposable {
catch (err) { this.opts.channel.appendLine(`[runs] replay failed: ${(err as Error).message}`); }
this.finishedSeen = finishedAtSwitch;

// Fresh run (manifest but no frames/FINISHED yet) → Julia/Makie warming up;
// show that instead of an idle panel so the ~minute cold start isn't read as frozen.
const hasFrame = fs.readdirSync(runDir).some((f) => ITER_PNG_RE.test(f));
if (!finishedAtSwitch && !hasFrame) getInspector()?.setWarmingUp();

// Incremental: new iter PNGs + FINISHED.
this.activeRunWatcher = fs.watch(runDir, { persistent: false }, (_e, filename) => {
if (!filename) return;
Expand Down Expand Up @@ -207,9 +259,16 @@ class LogTailer implements vscode.Disposable {
private buf = "";
private pollTimer?: NodeJS.Timeout;
private disposed = false;
private attached = false;

constructor(private readonly opts: LogTailerOptions) {}

/** Backstop drain (called by the watcher's poll). No-op until attach() has set
* the start offset, so it never re-reads lines ingestRunDir already replayed. */
poke(): void {
if (this.attached && !this.disposed) this.drain();
}

start(): void {
const tryAttach = () => {
if (this.disposed) return;
Expand All @@ -231,6 +290,7 @@ class LogTailer implements vscode.Disposable {
// Start where ingestRunDir stopped reading (startOffset), not at current EOF —
// otherwise lines appended between the replay read and this attach are lost.
this.offset = this.opts.startOffset ?? 0;
this.attached = true;
try {
this.watcher = fs.watch(this.opts.path, { persistent: false }, (event) => {
if (event === "change") this.drain();
Expand Down
Loading
Loading