Skip to content

Feat/factors - #20

Merged
jay-m-dev merged 64 commits into
mainfrom
feat/factors
Oct 13, 2026
Merged

jay-m-dev merged 64 commits into
mainfrom
feat/factors

Conversation

@jay-m-dev

Copy link
Copy Markdown
Collaborator

Summary

Factor controls for every canvas connector, plus a rework of how results scale. A user with a 15,786-run experiment couldn't open the Runs or Results tabs: the request ran for 5+ minutes and ended in a Bad Gateway. Both tabs now open in under two seconds at that size, and every bulk path (Run all, Stop, export, design generation, summaries) was fixed for the same scale.

Pairs with Motoro v0.12.0 (EpistasisLab/motoro#12), which is required.

Why the tabs timed out

Runs and Results loaded the whole experiment in one request, every run with its full outputs and traces, and re-polled every 3–5 seconds. Fixing that exposed the same "load everything" pattern in design generation, the Cells tab, CSV export and batch planning, so each of those changed too.

What changed

Factorial design

  • Factor controls for Skills, Datasets, Knowledge, Tools, Scripts, Sub-Agents, Agents, Models and Patterns; shared resources are varied together across connected agents; the picker is grouped and alphabetical.
  • Unbound factor declarations are kept and validated before a batch runs; each run pins the agent configuration it used.

Results reads

  • Runs/Results use paged, cursor-based reads (/execution-results/...) plus a precomputed summary, maintained by a new results worker. Database triggers mark which summaries are stale.
  • Attempt details, prompts, traces and step evidence load on demand, in bounded chunks.
  • Cells tab: server-side per-cell aggregates (GET /experiments/{id}/cell-summaries).

Bulk operations as durable background jobs

  • Run all / Run cell: preview first (exact count, sealed), then confirm. Stop all / Stop cell create a scoped cancellation. Completed and iteration-limited work is re-run only when selected; failed/cancelled work is retried.
  • Download CSV runs in the background and streams the same analysis-ready CSV as the synchronous endpoint, a chunk of replicates at a time (byte-identical on all 102 published dev experiments).
  • Design generation writes all replicates in one pass (15,786 in ~1.4s, previously ~4.5 min).

Performance fixes found at scale

  • Batch previews step 1,000 replicates at a time, back to back (15,786: ~10s, previously ~159s).
  • On/off conditions in result SQL are written as constants, not bound parameters. Once a reused connection switched to a generic plan, Postgres could no longer simplify them and scanned every run of the design per slot (summary rebuild 0.37s → 64s).
  • Protocol rows are locked FOR NO KEY UPDATE, which removes a deadlock between the batch planner and runs finishing mid-batch.

Motoro boundary

  • ASAREE no longer queries or indexes Motoro's tables; it uses Motoro v0.12.0's runner functions (list_run_summaries, list_run_step_summaries, read_run_input_chunk, read_run_step_chunk).

Operations

  • New db-backup compose service: before migrations, dumps each database with a pending migration to backups/{db}-{revision}-before-v{release}-{UTC}.dump (newest 5 kept; owned by the directory's owner).
  • Every migration, old and new, can be re-run safely in both directions; tests/test_migration_rerun.py enforces it.
  • CI runs the opt-in DB suites (ASAREE_RESULTS_TEST_DATABASE_URL, ASAREE_MIGRATIONS_TEST_DATABASE_URL) and vitest.

Smaller fixes

  • Runs tab crash over plain HTTP (crypto.randomUUID); Stop buttons shown only while something is active; export jobs never starting (_keep_result); job endpoints commit before responding (read-after-create 404); one-off scripts no longer leave inline-*.py in the working directory; factor picker tests updated.

Behaviour changes

  • "Run all" skips iteration-limited (limit_reached) executions unless they're ticked to run again, the same as completed ones.
  • Download CSV is a background job; the UI shows progress and downloads when it's ready.

Testing

  • Backend: 1,400 passed, including the result-read DB tests, the migration re-run test, and opt-in scale tests at 15,786 / 100,000 / 1,000,000 runs (first page plus summary < 2s).
  • Frontend: 364 passed; tsc -b, ruff and oxlint clean.
  • Migrations: main → head verified on fresh databases and on copies of real dev data; first-run schema identical to main's.
  • Manual QA with real claude-haiku-5-5 runs on the "Branch QA" experiments: Run all → preview → confirm, Stop all mid-run, Run cell, per-row dataset runs, iteration-limited re-run selection, lock from the run dialog, CSV export, and Run all on a 15,786-run experiment.

Deploy notes

  • Requires Motoro v0.12.0.
  • docker compose up -d --build --remove-orphans. Order: db-backup → Motoro migrations → ASAREE migrations (5 new) → app and workers.
  • New required service asaree-results-worker. Without it, summaries stay "preparing", and Run all / Download CSV never finish.
  • After upgrading, existing experiments show "preparing" briefly while their summaries are built.

Review guide

  • Highest risk: services/result_jobs.py (what gets planned and billed), services/result_reads.py (slot_query), and services/result_exports.py.
  • Operational detail and design notes: RESULTS_OPERATIONS.md.

Jay Moran added 30 commits October 6, 2026 15:35
Jay Moran added 27 commits October 13, 2026 08:49
Row-level result triggers re-updated the same result_refreshes row for every
changed row, so bulk writes slowed quadratically (100k rows: 8+ min -> 28s).
The Cells tab loaded every replicate (with artifacts) to average them in the
browser. GET /experiments/{id}/cell-summaries returns one row per cell with
metric sum/count, so the payload scales with factor combinations.
…rowser

DatasetRowResults/Runs/Cells and StopRowRuns had no importers left and read the
whole-experiment run-results payload; their tests only exercised that path.
ASAREE queried core's tables directly and created its own indexes on them via a
one-off compose job. Motoro now owns those indexes and exposes
list_run_summaries/list_run_step_summaries; use them and drop
asaree-results-index. Requires the Motoro release containing them.
…ro's runner

read_prompt, read_step and the compatibility usage lookup queried core's tables
through system_session. Use read_run_input_chunk, read_run_step_chunk and
list_run_summaries instead; ASAREE no longer queries Motoro tables directly.
Follow migrations/guards.py's convention: guard every create/drop with
IF [NOT] EXISTS, CREATE OR REPLACE the functions, and drop each trigger before
recreating it. Verified: main -> head on fresh and real data, a full re-run
leaves the schema identical, and each downgrade survives a lost stamp.
Provides list_run_summaries, list_run_step_summaries, read_run_input_chunk and
read_run_step_chunk, which ASAREE's results reads now depend on.
…ping tests

Shared-resource groups were labelled in connection order ("Zebra, Alpha") while
every other picker level sorts by name; the same label feeds each factor
dialog's "Applies to" line. The tests still expected the grouping from before
shared resources got their own group and the agent connector was renamed.
Opt-in (ASAREE_MIGRATIONS_TEST_DATABASE_URL) check, modelled on Motoro's, that
each revision re-runs cleanly over its own result, in both directions, with an
identical schema. Eight upgrade and nine downgrade revisions that predate it
are listed as known exceptions and must keep failing until they're guarded.
Nine revisions failed when re-run over their own result (upgrade or
downgrade): unguarded DDL, and data steps reading what the first run had
already dropped. Add guards.create_unique_constraint, has_table and
has_column, guard the DDL, condition each backfill on the shape it reads, and
let the a7b8 rename-and-normalize detect that it already ran. A fresh
upgrade to main's head produces a byte-identical schema; the re-run test now
enforces every revision with no exceptions.
ASAREE_RESULTS_TEST_DATABASE_URL and ASAREE_MIGRATIONS_TEST_DATABASE_URL were
never set, so the result-read and migration re-run checks always skipped, and
the frontend job never ran vitest.
A one-off db-backup compose service runs before motoro-migrate. When a
database has a pending migration it writes
backups/{db}-pre-v{release}-{revision}-{UTC}.dump (pg_dump -Fc, owner-only,
written atomically), keeps the newest five per database, never touches manual
dumps, and fails the deploy if the dump fails. Also keep backups/ out of the
Docker build context.
An attempt that hit its iteration limit finished with a (truncated) result,
so "Run all" now skips it like a completed one instead of re-billing it.
Each one gets a "run again" checkbox in the batch dialog and is covered by
"run every completed execution again". One rule (RERUN_ON_REQUEST_STATUSES)
now drives the batch planner, replicate planning, row claims and the retry
endpoint, which previously disagreed between per-row and per-replicate runs.
Code passed as a one-off with no dataset workspace was written to
inline-<hash>.py in the server's cwd and never removed, so it piled up in
the deployed container and the test suite left one in the repo root on every
run. Write it to a temporary directory removed after the run; the script
still runs in the same working directory, so files it leaves for its next
call are unaffected.
randomUUID only exists in secure contexts, so the Runs tab crashed when the
app was opened over plain HTTP via a LAN IP or hostname. Build the key from
crypto.getRandomValues, which has no such restriction, and keep it fully
random since a repeated key returns an earlier job.
They rendered on every experiment, so an idle one offered to stop runs that
didn't exist. Follow the summary's has_active and each cell's
pending/running/finalizing counts, like the per-replicate stop button already
does, and keep a clicked stop visible while its cancellation runs.
{db}-{revision}-before-v{release}-{UTC}.dump reads as what the file holds
(that schema revision) and when it was taken (before that release migrated
it), instead of pre-v{release}, which looked wrong when the release was
already installed.
…plicate

generate_design_cells called upsert_replicate per planned replicate, and each
call reloaded every replicate already in its cell, so a 6 x 2,631 design took
about 4.5 minutes and timed out through the SDK and UI. materialize_replicates
applies the same rules (cell by factors, then label; merges; published
revisions frozen) after reading the revision once, and writes every new row in
one flush: 15,786 replicates generate and regenerate in about 1.4s. The
endpoint also takes include_replicates=false, which the canvas uses, to skip
listing every replicate back; the SDK default still returns the list.
Also import protocol_run in test_design_revisions so the file runs alone.
db-backup ran as a fixed UID 1000, so on a Linux host whose user has any
other UID it couldn't write ./backups and, by design, stopped the deploy.
Run it as root and chown each dump to the owner of the mounted directory;
Docker Desktop on macOS maps ownership itself, so a refused chown is ignored.
ASAREE_BACKUP_UID/GID are gone.
arq 0.28 has no _keep_result enqueue option, so it passed it to
execute_result_export as a keyword argument and every export crashed on
start; the UI sat on "preparing..." forever. The worker already sets
keep_result=0. The test checks every enqueue option against arq's own
signature.
The background export (added on this branch) wrote a raw dump: one column per
stored field, with tokens, cost, duration and metrics inside JSON. Researchers
lost the analysis CSV main produced. The job now writes that CSV: the Results
projection runs a chunk of replicates at a time (new replicate_ids filter),
pass one lays out columns from compact stubs, pass two writes rows in
factor-level order, all in one REPEATABLE READ snapshot. The CSV writer is split
into a shared layout so both downloads are produced by the same code;
byte-identical to the synchronous CSV on all 102 published dev experiments.
15,786 runs: 22s, ~110 MB peak instead of 740 MB for the whole projection.
get_db commits when FastAPI tears the dependency down, which happens after the
response is sent (FastAPI 0.141), so creating a batch preview and polling it
straight away could return job_not_found -- the SDK hit it on Run cell. Create,
confirm and cancel now commit before answering.
…g batches

FOR UPDATE also blocks foreign-key checks on the row. Runs finishing during a
large Run all write the results trigger's refresh row (which references the
protocol) while the planner held the protocol row and waited on that refresh
row: 2 of 485 runs died with a deadlock. FOR NO KEY UPDATE still serializes
planners, Stop and publishing, but lets those checks through.
A preview step checked 100 replicates (per row: one replicate) and the worker
ran one step per one-second tick, so previewing a 15,786-replicate experiment
took 2.7 minutes and a per-row design with thousands of replicates would take
most of an hour; queueing confirmed runs crawled the same way. Steps now check
up to 1,000 replicates (per row: as many whole replicates as fit in 1,000 rows),
and each tick keeps advancing a job for up to 0.9s. Every step is still its own
short transaction, so progress, cancellation and locking are unchanged.
slot_query and the rollup joined each slot's latest run on
replicate_result_id = x OR (<legacy> AND ...), with <legacy> a bound parameter.
Once a reused connection switched to a generic plan, Postgres couldn't fold the
OR and scanned every run of the design for each slot: rebuilding a 15,786-run
experiment's summary took 64 s instead of 0.37 s, mid-batch and repeatedly.
legacy_flag() and true()/false() render constants, which generic plans fold.
A test rejects bound booleans in these statements.
@jay-m-dev
jay-m-dev merged commit bb61d45 into main Oct 13, 2026
2 checks passed
@jay-m-dev
jay-m-dev deleted the feat/factors branch October 13, 2026 23:50
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.

1 participant