Skip to content

feat: interactive disambiguation — Socrates-style compile-time questions → guards/params (ask, don't guess)#74

Merged
abrichr merged 5 commits into
mainfrom
feat/interactive-disambiguation
Jul 13, 2026
Merged

feat: interactive disambiguation — Socrates-style compile-time questions → guards/params (ask, don't guess)#74
abrichr merged 5 commits into
mainfrom
feat/interactive-disambiguation

Conversation

@abrichr

@abrichr abrichr commented Jul 13, 2026

Copy link
Copy Markdown
Member

What

Implements the RFC (docs/design/WORKFLOW_PROGRAM_IR.md §3 step [3]) interactive disambiguation stage: where a single demonstration under-specifies intent, the compiler surfaces concrete multiple-choice questions and applies the chosen answer deterministically as a Phase-1 guard/param — instead of silently freezing an accidental interpretation.

Stacks on #71 (feat/workflow-program-ir-phase1) for the Guard/Predicate/ParamSpec types. Retarget base to main after #71 merges.

Ambiguity types detected (deterministic, ZERO model calls)

Kind Detection Answer → IR
parameter candidate an untagged, non-trivial typed value (Step.action==TYPE, param is None) ParamSpec + param binding (or no-op keep-literal)
absent-result handling an identity-armed entity selection after a typed search, no branch for 0/>1 matches Guard(ANCHOR_RESOLVES, on_unmet="halt") on the selection step — a 0-result run halts instead of clicking the recorded position blindly
optional dialog a click whose label dismisses a popup (dismiss/close/ok/survey…) Guard(TEXT_PRESENT, on_unmet="skip") — dismiss when present, skip when absent (guarded branch, no Phase-2 state machine)

All questions are templated (not model-generated) and grounded in the concrete demonstrated fact (the typed value, the selected entity, the dialog label). Answers map to #71's Guard/Predicate/ParamSpec types verbatim — no new IR fields.

Refuse-rather-than-guess (mirrors runtime.identity)

An unanswered ambiguity on a consequential step (the step or a downstream step is risk="irreversible") is flagged, not defaultedDisambiguationResult.certified is False until it is answered. The skill is marked NOT certified, exactly like the identity gate refuses rather than guessing. Non-consequential unanswered ambiguities fall back to a safe no-op default.

API (testable) + thin CLI

  • Core: detect_ambiguities(workflow) -> list[DisambiguationQuestion] and apply_answers(workflow, answers) -> DisambiguationResult — pure, deterministic, no I/O, no model calls. Tests drive these directly.
  • CLI: one thin openadapt-flow disambiguate <bundle> subcommand (lists questions; --interactive prompts; --answers <json>; --write). Exit 2 when a consequential ambiguity is unresolved.

Touch-points (kept minimal — stacks on #71)

  • New openadapt_flow/compiler/disambiguation.py.
  • New tests/test_disambiguation.py (21 tests).
  • openadapt_flow/__main__.py: one new disambiguate subcommand (lazy import, matches existing handler style).
  • compiler/compile.py UNCHANGED — disambiguation is an opt-in pass over an already-compiled bundle.

Tests

tests/test_disambiguation.py — 21 passing: ambiguous demo → expected question set; each answer → the right ParamSpec/Guard/optional-step; unanswered consequential → not-certified + flagged; fully-answered → certified clean; unknown answer/question keys raise; input workflow not mutated; resolved workflow round-trips through the bundle. Related subsets green locally: compiler + phase1 + policy + disambiguation (88 passed); CLI/import-guard subset (69 passed, 4 skipped).

🤖 Generated with Claude Code

https://claude.ai/code/session_01CKrVJJy5jWVCkXAqgUqtqZ

abrichr and others added 4 commits July 13, 2026 11:39
…(additive, back-compatible)

Implements the RFC's Phase 1 (docs/design/WORKFLOW_PROGRAM_IR.md): the first
additive, backward-compatible step from a linear macro IR toward a parameterized
program. Typed parameters on Workflow (substituted at replay), an optional
per-step guard (deterministic precondition; fail-safe), and wait_until (bounded
readiness predicate that subsumes the SCROLL closed-loop). A bundle with none of
these replays byte-identically to today. $0 / zero model calls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKrVJJy5jWVCkXAqgUqtqZ
…ons → guards/params (ask, don't guess)

Implements the RFC (docs/design/WORKFLOW_PROGRAM_IR.md §3 [3]) induction stage:
where a single demonstration under-specifies intent, surface CONCRETE
multiple-choice questions and apply the answer deterministically as a Phase-1
guard/param — instead of silently freezing an accidental interpretation.

New module openadapt_flow/compiler/disambiguation.py detects three ambiguity
kinds structurally (ZERO model calls):
- parameter candidate — an untagged typed value → ParamSpec + param binding
- absent-result handling — an identity-armed entity selection after a search
  with no 0/>1-match branch → Guard(ANCHOR_RESOLVES, on_unmet="halt")
- optional dialog — a once-handled popup → Guard(TEXT_PRESENT, on_unmet="skip")

Answers map to #71's Guard/Predicate/ParamSpec types verbatim (no new IR
fields). Refuse-rather-than-guess (mirrors runtime.identity): an UNANSWERED
consequential ambiguity (one gating an irreversible write) is flagged and the
resolved skill is marked NOT certified until answered — never silently
defaulted. Non-consequential unanswered ambiguities fall back to a safe no-op
default.

Core is a pure, testable API — detect_ambiguities(workflow) and
apply_answers(workflow, answers) — plus a thin `disambiguate` CLI subcommand
(interactive prompt or --answers JSON). compile.py is UNCHANGED; disambiguation
is an opt-in pass over a compiled bundle.

Stacks on #71 (feat/workflow-program-ir-phase1); retarget base to main after
#71 merges.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKrVJJy5jWVCkXAqgUqtqZ
…mbiguation

# Conflicts:
#	openadapt_flow/compiler/compile.py
#	openadapt_flow/ir.py
@abrichr abrichr changed the base branch from feat/workflow-program-ir-phase1 to main July 13, 2026 17:40
@abrichr abrichr marked this pull request as ready for review July 13, 2026 18:35
@abrichr abrichr merged commit 8c7ec41 into main Jul 13, 2026
4 checks passed
abrichr added a commit that referenced this pull request Jul 13, 2026
…oops/branches) from multiple demos, reject-if-underdetermined (#81)

* feat: workflow-program IR Phase 2 — loops, branches, subflows, exception paths (the state machine)

Evolve the compiled artifact from a linear action list into a parameterized
STATE MACHINE (RFC docs/design/WORKFLOW_PROGRAM_IR.md §2), closing the review's
"a workflow is not a list of actions" gap. Phase 1 (typed params, guards,
wait_until) added the pieces; Phase 2 adds the control flow a trajectory cannot
carry: LOOPS over a worklist, guarded BRANCHES, reusable SUBFLOWS, and
EXCEPTION paths — the program the PBD literature (Rousillon, WebRobot,
Skill-DisCo, PROLEX) says a demonstration compiler must express.

IR (openadapt_flow/ir.py), additive and backward-compatible:
- State (action | branch | loop | subflow_call | terminal) + Transition
  (guarded edge) form a ProgramGraph; an action state's payload IS a Phase-1
  Step (the unchanged hardened leaf), a transition's guard IS a Phase-1
  Predicate.
- Relation (worklist) + LoopSpec (bounded per-row body subflow); Workflow gains
  optional program / subflows / data_sources. When program is None the linear
  steps list runs exactly as today.
- lift_to_program: mechanical degenerate lift (RFC §2.6) — a linear bundle is
  the single-path graph.

Interpreter (runtime/replayer.py): a deterministic graph interpreter ($0, zero
model calls) that REUSES the linear per-action pipeline unchanged — every
action state runs through _run_step, so identity / effect / risk / heal gates
fire identically inside loop bodies and branches. Adds guarded transition
selection (first match wins, no-match HALTs fail-safe), bounded worklist loops,
subflow dispatch, and on_exception routing (graph try/except); unhandled
failures and halt/escalate terminals stop the run. Bounded against
non-terminating graphs (step budget + nesting depth). Linear path is byte-for-
byte unchanged (program=None branch).

Tests (tests/test_program_ir_phase2.py, 18): loop runs body 3x / 0x / run-time
worklist / bound enforced; branch takes each arm (param + screen predicate) and
dead-ends HALT; subflow reused as loop body AND direct call; on_exception
catches a failed action and continues; identity- and effect-gates fire inside a
loop body; the lifted linear graph replays byte-identically to the linear
replayer; program round-trips through save/load. Full non-e2e suite green in
isolation (859 passed; the concurrent-agent FileNotFoundError errors are
environmental).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKrVJJy5jWVCkXAqgUqtqZ

* feat: multi-trace induction — infer a parameterized program (params/loops/branches) from multiple demos, reject-if-underdetermined

Implements RFC docs/design/WORKFLOW_PROGRAM_IR.md §3 steps [4]+[5]: the
induction loop the PBD lineage (Rousillon, WebRobot, Skill-DisCo, PROLEX)
says a demonstration compiler must have. "One demonstration is evidence,
not specification."

openadapt_flow/compiler/induction.py:
- induce_program(traces) aligns multiple demos structurally and infers a
  Phase-2 ProgramGraph: PARAMS (values that VARY across traces at an aligned
  position; constant => literal), LOOPS (a repeated body whose count DIFFERS
  => LoopSpec over an inferred Relation), BRANCHES (a divergent step under a
  detectable condition => guarded branch, guard proposed/flagged), and
  OPTIONAL steps (present in some, absent in others, no condition => guarded
  skip). All deterministic, ZERO model calls.
- validate_held_out / reproduction_score: leave-one-out held-out validation
  (infer from N-1, check reproduction of the held trace).
- Reject-rather-than-guess: contradictory / underdetermined traces are
  QUARANTINED (no program emitted, certified=False) and routed to the
  disambiguation flow (#74), mirroring the identity gate's posture.
- The optional compile-time Proposer (the #78 StepAnnotator fits behind it)
  only PROPOSES interpretations — flagged, never silently trusted, never
  flips an underdetermined point to certified.

Touch-points kept minimal: reuses the Phase-2 IR + Phase-1 ParamSpec/Guard/
Predicate verbatim (no new IR fields), reuses disambiguation's question model,
and the emitted program replays through the EXISTING interpreter unchanged
(compile.py untouched; compiler/__init__ re-exports the new API).

Tests (tests/test_induction.py, 17 tests): a synthetic MockMed corpus of trace
variants covers (a) param, (b) loop, (c) branch/optional, (d) contradiction=>
reject; held-out scores a good induction high and an over-specialized one low;
underdetermined is flagged not guessed; the induced program round-trips through
the real Phase-2 interpreter (faked backend/vision, zero model calls).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKrVJJy5jWVCkXAqgUqtqZ

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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