From d9b9da3843423140b771d52142a884b49a10259c Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 18:09:21 +0000 Subject: [PATCH] =?UTF-8?q?flow:=20railway=20attempt=20combinator=20?= =?UTF-8?q?=E2=80=94=20fail-value=20short-circuit=20+=2010=20tests=20(Phas?= =?UTF-8?q?e=206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (attempt n1 n2 ...) threads like sequence but stops at the first node returning a (fail ...) value, returning that failure. Makes the fail/recover error model compose into validation/ETL pipelines (railway-oriented). 132/132 across 8 suites. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/flow/conformance.sh | 1 + lib/flow/scoreboard.json | 9 ++--- lib/flow/scoreboard.md | 3 +- lib/flow/spec.sx | 4 ++- lib/flow/tests/railway.sx | 73 +++++++++++++++++++++++++++++++++++++++ plans/flow-on-sx.md | 12 ++++++- 6 files changed, 95 insertions(+), 7 deletions(-) create mode 100644 lib/flow/tests/railway.sx diff --git a/lib/flow/conformance.sh b/lib/flow/conformance.sh index 53542736..bcdc52dd 100755 --- a/lib/flow/conformance.sh +++ b/lib/flow/conformance.sh @@ -28,6 +28,7 @@ SUITES=( "distributed flow-dist-tests-run! lib/flow/tests/distributed.sx" "api flow-api-tests-run! lib/flow/tests/api.sx" "combinators flow-cmb-tests-run! lib/flow/tests/combinators.sx" + "railway flow-rail-tests-run! lib/flow/tests/railway.sx" ) TMPFILE=$(mktemp); trap "rm -f $TMPFILE" EXIT diff --git a/lib/flow/scoreboard.json b/lib/flow/scoreboard.json index 0c32983f..cc1f1082 100644 --- a/lib/flow/scoreboard.json +++ b/lib/flow/scoreboard.json @@ -1,6 +1,6 @@ { - "total": 122, - "passed": 122, + "total": 132, + "passed": 132, "failed": 0, "suites": { "basic": { "passed": 18, "total": 18 }, @@ -9,7 +9,8 @@ "recovery": { "passed": 8, "total": 8 }, "distributed": { "passed": 19, "total": 19 }, "api": { "passed": 12, "total": 12 }, - "combinators": { "passed": 17, "total": 17 } + "combinators": { "passed": 17, "total": 17 }, + "railway": { "passed": 10, "total": 10 } }, - "phases": { "phase1": "done", "phase2": "done", "phase3": "done", "phase4": "done", "phase5": "done" } + "phases": { "phase1": "done", "phase2": "done", "phase3": "done", "phase4": "done", "phase5": "done", "phase6": "done" } } diff --git a/lib/flow/scoreboard.md b/lib/flow/scoreboard.md index 988adb5f..90e9f6d8 100644 --- a/lib/flow/scoreboard.md +++ b/lib/flow/scoreboard.md @@ -1,6 +1,6 @@ # flow-on-sx Scoreboard -**All tests pass: 122 / 122 across 7 suites. Phases 1-5 complete.** +**All tests pass: 132 / 132 across 8 suites. Phases 1-6 complete.** `bash lib/flow/conformance.sh` @@ -15,6 +15,7 @@ | distributed | 19 | Phase 4: `remote-node` (7); `remote-failover` (6); replication + handoff across instances (6) | | api | 12 | Phase 5: introspection — `flow/status`, `flow/result`, `flow/list`, `flow/pending` | | combinators | 17 | Phase 5: `tap`, `recover` (fail-value), `map-flow` fan-over-list, `flow-while`/`flow-until` bounded iteration | +| railway | 10 | Phase 6: `attempt` — fail-value short-circuiting sequence + recover rejoin | ## Architecture diff --git a/lib/flow/spec.sx b/lib/flow/spec.sx index 933c571c..2bfd5c56 100644 --- a/lib/flow/spec.sx +++ b/lib/flow/spec.sx @@ -19,6 +19,8 @@ ;; branch / fail / failed? / fail-reason / try-catch / retry / timeout / tick ;; tap (Phase 5): side-effecting pass-through (returns input unchanged). ;; recover (Phase 5): the fail-VALUE counterpart of try-catch. +;; attempt (Phase 6): railway sequence — thread nodes left-to-right but stop at +;; the first node that returns a (fail ...) value, returning that failure. ;; ;; Phase 3 suspend core (flow-suspend-src): ;; The guest Scheme's call/cc is ESCAPE-ONLY (re-invoking a captured k after it @@ -42,7 +44,7 @@ (define flow-control-src - "(define (branch pred then else)\n (lambda (input) (if (pred input) (then input) (else input))))\n (define (fail reason) (list (quote flow-fail) reason))\n (define (failed? x) (and (pair? x) (eq? (car x) (quote flow-fail))))\n (define (fail-reason x) (car (cdr x)))\n (define (recover node handler)\n (lambda (input)\n (let ((r (node input)))\n (if (failed? r) (handler (fail-reason r)) r))))\n (define (tap effect)\n (lambda (input) (begin (effect input) input)))\n (define (try-catch node handler)\n (lambda (input) (guard (e (#t (handler e))) (node input))))\n (define (flow-retry-step n node input)\n (guard (e (#t (if (<= n 1) (raise e) (flow-retry-step (- n 1) node input))))\n (node input)))\n (define (retry n node) (lambda (input) (flow-retry-step n node input)))\n (define flow-timeout-budget -1)\n (define (tick)\n (if (< flow-timeout-budget 0)\n 0\n (begin\n (set! flow-timeout-budget (- flow-timeout-budget 1))\n (if (< flow-timeout-budget 0)\n (raise (quote flow-timeout))\n flow-timeout-budget))))\n (define (timeout budget node)\n (lambda (input)\n (let ((saved flow-timeout-budget))\n (set! flow-timeout-budget budget)\n (guard (e (#t (begin (set! flow-timeout-budget saved) (raise e))))\n (let ((result (node input)))\n (set! flow-timeout-budget saved)\n result)))))") + "(define (branch pred then else)\n (lambda (input) (if (pred input) (then input) (else input))))\n (define (fail reason) (list (quote flow-fail) reason))\n (define (failed? x) (and (pair? x) (eq? (car x) (quote flow-fail))))\n (define (fail-reason x) (car (cdr x)))\n (define (recover node handler)\n (lambda (input)\n (let ((r (node input)))\n (if (failed? r) (handler (fail-reason r)) r))))\n (define (tap effect)\n (lambda (input) (begin (effect input) input)))\n (define (flow-attempt-step ns v)\n (if (failed? v)\n v\n (if (null? ns) v (flow-attempt-step (cdr ns) ((car ns) v)))))\n (define attempt (lambda ns (lambda (input) (flow-attempt-step ns input))))\n (define (try-catch node handler)\n (lambda (input) (guard (e (#t (handler e))) (node input))))\n (define (flow-retry-step n node input)\n (guard (e (#t (if (<= n 1) (raise e) (flow-retry-step (- n 1) node input))))\n (node input)))\n (define (retry n node) (lambda (input) (flow-retry-step n node input)))\n (define flow-timeout-budget -1)\n (define (tick)\n (if (< flow-timeout-budget 0)\n 0\n (begin\n (set! flow-timeout-budget (- flow-timeout-budget 1))\n (if (< flow-timeout-budget 0)\n (raise (quote flow-timeout))\n flow-timeout-budget))))\n (define (timeout budget node)\n (lambda (input)\n (let ((saved flow-timeout-budget))\n (set! flow-timeout-budget budget)\n (guard (e (#t (begin (set! flow-timeout-budget saved) (raise e))))\n (let ((result (node input)))\n (set! flow-timeout-budget saved)\n result)))))") (define flow-suspend-src diff --git a/lib/flow/tests/railway.sx b/lib/flow/tests/railway.sx new file mode 100644 index 00000000..8b90e918 --- /dev/null +++ b/lib/flow/tests/railway.sx @@ -0,0 +1,73 @@ +;; lib/flow/tests/railway.sx — Phase 6: railway-oriented composition (attempt). + +(define flow-rail-pass 0) +(define flow-rail-fail 0) +(define flow-rail-fails (list)) + +(define + flow-rail-test + (fn + (name actual expected) + (if + (= actual expected) + (set! flow-rail-pass (+ flow-rail-pass 1)) + (begin + (set! flow-rail-fail (+ flow-rail-fail 1)) + (append! flow-rail-fails {:name name :expected expected :actual actual}))))) + +(define flow-r (fn (src) (flow-run src))) + +;; ── attempt — short-circuit on the first (fail ...) ───────────── +(flow-rail-test + "attempt: threads like sequence when nothing fails" + (flow-r + "(flow/start (attempt (lambda (x) (+ x 1)) (lambda (x) (* x 10))) 4)") + 50) +(flow-rail-test + "attempt: empty is identity" + (flow-r "(flow/start (attempt) 7)") + 7) +(flow-rail-test + "attempt: returns the first failure" + (flow-r + "(failed? (flow/start (attempt (lambda (x) (fail (quote bad))) (lambda (x) (* x 10))) 4))") + true) +(flow-rail-test + "attempt: the failure carries its reason" + (flow-r + "(fail-reason (flow/start (attempt (lambda (x) x) (lambda (x) (fail (quote rejected)))) 4))") + "rejected") +(flow-rail-test + "attempt: nodes after a failure do not run" + (flow-r + "(define ran 0) (flow/start (attempt (lambda (x) (fail (quote stop))) (lambda (x) (begin (set! ran (+ ran 1)) x))) 0) ran") + 0) +(flow-rail-test + "attempt: a failed input short-circuits immediately" + (flow-r + "(define ran 0) (fail-reason (flow/start (attempt (lambda (x) (begin (set! ran (+ ran 1)) x))) (fail (quote pre))))") + "pre") +(flow-rail-test + "attempt: middle failure halts the chain" + (flow-r + "(define ran 0) (flow/start (attempt (lambda (x) (+ x 1)) (lambda (x) (fail (quote mid))) (lambda (x) (begin (set! ran (+ ran 1)) x))) 5) ran") + 0) + +;; ── attempt + recover (rejoin the happy track) ────────────────── +(flow-rail-test + "attempt + recover: recover turns a failure into a value" + (flow-r + "(flow/start (recover (attempt (lambda (x) (if (> x 0) x (fail (quote non-positive)))) (lambda (x) (* x 2))) (flow-const 0)) -5)") + 0) +(flow-rail-test + "attempt + recover: happy path passes recover through" + (flow-r + "(flow/start (recover (attempt (lambda (x) (if (> x 0) x (fail (quote non-positive)))) (lambda (x) (* x 2))) (flow-const 0)) 5)") + 10) +(flow-rail-test + "attempt: validation pipeline reports the failing stage" + (flow-r + "(defflow validate (attempt (lambda (s) (if (>= (string-length s) 3) s (fail (quote too-short)))) (lambda (s) (if (<= (string-length s) 8) s (fail (quote too-long)))) (lambda (s) (list (quote ok) (string-length s))))) (list (fail-reason (flow/start validate \"hi\")) (flow/start validate \"hello\"))") + (list "too-short" (list "ok" 5))) + +(define flow-rail-tests-run! (fn () {:total (+ flow-rail-pass flow-rail-fail) :passed flow-rail-pass :failed flow-rail-fail :fails flow-rail-fails})) diff --git a/plans/flow-on-sx.md b/plans/flow-on-sx.md index 86b9a957..278937ad 100644 --- a/plans/flow-on-sx.md +++ b/plans/flow-on-sx.md @@ -16,7 +16,7 @@ federation extension via fed-sx for remote-node execution. ## Status (rolling) -`bash lib/flow/conformance.sh` → **122/122** (Phases 1-5 complete) +`bash lib/flow/conformance.sh` → **132/132** (Phases 1-6 complete) ## Ground rules @@ -149,6 +149,16 @@ something operators and authors actually use. Accumulation, not a rewrite. value while/until pred holds, capped at `max` steps (deterministic bound) - [x] `lib/flow/tests/api.sx` (12) + `lib/flow/tests/combinators.sx` (17) +## Phase 6 — Railway-oriented composition + +Make the `(fail reason)` value channel compose into real validation/ETL pipelines. + +- [x] `attempt` — like `sequence`, but short-circuits at the first node that returns + a `(fail ...)` value, returning that failure (the railway track). Pairs with + `recover` for the rejoin. +- [x] `lib/flow/tests/railway.sx` — 10 cases: fail short-circuiting, no-run-after- + failure, recover rejoin, validation pipeline reporting the failing stage + ## Progress log - **Phase 1 (combinators + sequential runtime).** Flow built as a Scheme prelude