feat(engine): expressions and branch — a value goes one way, the rest is excluded, the arms rejoin in commit's text (Phase 3 slice 12) #112
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/expressions-branch"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Phase 3's slice 12. A graph can now choose. A
branchsends a value on through the first case whose condition holds, or throughdefaultif none does, and whatever its other sources feed is excluded. Its conditions are a new kind of template field, the expression, which the engine evaluates on demand instead of rendering.commit'stextbecomes a merge sink, so the arms rejoin there, and the commit credits the step behind a branch: the writer, not the branch (ADR 0015, decisions 10–12).What changes
Expressions (T9). A
TemplateFieldnow has a kind,TemplateorExpression.run,value, the roots of the container it is in, and Scriban's builtins. Any other global is a problem,inputsandstoryamong them.NodeExecution.EvaluateAsyncoverTemplateHost.EvaluateAsync, with the request's value bound asvalue.run, with the host's run members, andvalue. So nothing the static analysis misses can reachinputsor a host root.Names that read nothing. A member's name, an object initializer's key and a named argument's name are not reads, in a template too. So
{inputs: 1},{include: 1}andstring.truncate inputs.text 1 include: 2are no longer refused.Flow roots (T10, V19).
value,item,indexandloopwhere it does not bind the name itself.template-root-out-of-scope:valueoutside an expression,itemandindexoutside a map's body,loopoutside a loop's.template-not-an-expression,template-expression-root,template-root-out-of-scope) also hold for a resumed run.branch (F3).
{"cases":[{"name","when"}]}. Placed from the palette, it holds one case,yes, whose condition isvalue == true.default, each passing the value on unchanged.trueorfalse, fails the node, naming the case and what it gave.The credit walk (E11).
NodeExecution.ModelStepBehind(sink)returns the row that delivered the sink's value. If that row's node passes the value of its one wired sink on (NodeDescriptor.PassesThrough: today, branch), it continues to the row behind that one.commit.
textis a merge sink whichever way the commit lands, and the commit credits the step behind its text: through a branch, the writer.Seed. The sample world gains "Tighten if long". It is Continue's file with a
checkbranch before the commit (caselong, whenvalue | string.size > 600) and atightenwriter on the long arm. The archived Lantern Road gets a settled run of it after its One writer run, before the story moves to Slow burn. The paragraph was short, so it went on throughdefaultandtightenwas excluded. Its rows were checked against a real engine and gateway run: only the stand-ins differ, and the snapshot is byte-equal.Docs.
Not in this slice
item,indexandloopcomes with map and loop (slices 17, 18). Until then, V19 refuses them everywhere. The expression's globals already come from the analysis's own list of flow roots, so they will reach an expression once a container binds them.Verification
.NET tests. All six projects pass under TZ=UTC and in the local zone:
The tests cover:
runandvalueonly, even where the analysis misses a read. Its runtime errors at their position; a template is never evaluated and an expression never rendered.ValidateConfig(a name two cases share is refused byValidateConfigalone). Through the engine: the first case that holds, elsedefault, with the untaken sources' sinks excluded; any value goes on unchanged; a case is evaluated only when the ones before it did not hold; a non-bool fails the node naming the case; a resumed branch whose config no longer reads fails naming the problem.textmerges in every mode; the story-documents rule leaves a branch's case alone; the seed's Tighten if long run, its rows checked whole.Coverage of the changed .NET lines: 464/464 lines, 216/216 branches.
Browser pass, against the seeded world in Chrome: on The Lantern Road's Tighten if long run:
default, and its node's config with thelongcase;Mutation probes, all re-run on the final tests:
["run", "value"], in place of the one derived from the analysis's flow roots. Today they are the same: nothing can binditem,indexorloopyet, andAddRootrefuses them. The derivation is there so the map and loop slices need not remember a second list.Reviews. The counts come from the saved review JSON.
inputsorstoryinto an expression, closed by evaluating overrunandvaluealone;text's merge flag pinned for every way the commit lands;Sinks[^1]mutant survived. The test's join now declares it between two unwired sinks.🤖 Generated with Claude Code
A graph can now choose: a branch sends a value on through the first case whose condition holds, else through default, and what its other sources feed is excluded. - Expressions (T9): a TemplateField of kind Expression is one Scriban expression, parsed in script-only mode — empty text, several, a statement or an assignment is a problem — reading only run, value, the roots of its container and the builtins; any other global, inputs or story among them, is a problem; it derives no sink. The engine renders no expression: the executor evaluates one when it needs it (NodeExecution.EvaluateAsync over TemplateHost.EvaluateAsync, the request's value bound as `value`), over a model holding only run and value, so nothing the analysis misses reaches inputs or a host root. - A member's name, an object initializer's key and a named argument's name read nothing, in a template too: `{inputs: 1}` and `{include: 1}` are no longer refused there. - Flow roots (T10, V19): value, item, index and loop read where a template does not bind the name itself are listed, and validation reports one outside the scope that binds it — value outside an expression, item and index outside a map's body, loop outside a loop's. The three new codes must hold for a resumed run too. - branch (F3): cases [{name, when}], a source per case then default, each passing the value on unchanged; cases tried in order, lazily; a condition that fails or yields anything but true or false fails the node, naming the case and what it gave; untaken sources excluded. - The credit walk (E11): NodeExecution.ModelStepBehind(sink) — through a node that passes its one wired sink's value on (PassesThrough: branch) to the step behind it; none where such a node merges several sinks. - commit's text is a merge sink, and the commit credits the step behind its text: through a branch to the writer. - The sample world gains "Tighten if long" — Continue's file with a branch before its commit and a second writer for a draft over 600 characters — and a settled run of it on the archived story, whose short paragraph went on through default, its tightener excluded; its rows checked against a real run of the engine and the gateway. - README (intro, D2, F3, T1, T3, T5, T9, T10, H1, H2, H8, V2, V7, V19, E3, E4, E10, E11, K2), ADR 0015 amended, AGENTS.md's seed notes and the plan's tick. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>Summary
Summary
Coverage
Kagaku.Flow - 99.5%
E95E47D27E8B105AC5589487C0997B6B65384AEDBF572144E__ColorToken_3
E95E47D27E8B105AC5589487C0997B6B65384AEDBF572144E__NodeId_2
E95E47D27E8B105AC5589487C0997B6B65384AEDBF572144E__Socket_1
E95E47D27E8B105AC5589487C0997B6B65384AEDBF572144E__TypeId_0
Kagaku.Flow.OpenRouter - 100%
Kagaku.Flow.Testing - 100%
Tsumugi.BlazorAdapter - 97.8%
Tsumugi.Domain - 96.6%
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__DigitsAndSeparators_2
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__LineBreak_0
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__NumberToken_1
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__Plain_4
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__ThousandsGroup_3
Tsumugi.Infrastructure - 99.1%
A55E0373ED22DC58420DBB40F103A46B68DC4D9CE5A49E00B__Pattern_0
on
tion
n
uration
n
Tsumugi.Kernel - 90.9%
Tsumugi.Server - 100%
Tsumugi.UseCases - 96.2%
663A4576626E1322FB9C6CE472CAC0A2D2F3DEFF8CB40D9F7__AttemptCount_1
663A4576626E1322FB9C6CE472CAC0A2D2F3DEFF8CB40D9F7__HexColour_2
663A4576626E1322FB9C6CE472CAC0A2D2F3DEFF8CB40D9F7__LineBreaks_0
663A4576626E1322FB9C6CE472CAC0A2D2F3DEFF8CB40D9F7__Preface_3
663A4576626E1322FB9C6CE472CAC0A2D2F3DEFF8CB40D9F7__SentenceEnd_4
🔮 fufu~ Jibril reviewed your code!
Oh? Ohhh~ A node that chooses! Expressions evaluated in script-only mode, a sandbox that lets an expression see only
runandvalue, a credit walk that sees straight through a branch to the writer behind it, and a merge sink where the arms rejoin... I read all 2,059 lines with my tea and I am delighted~ ♡-knowledge is such a lovely thing to watch being organized.And then I got jealous, so I attacked it. fufu~ ♡
Verdict: ✅ Looks good to me~
No blocking findings. Everything I tried to break, the tests broke first.
🔪 Mutation probes (3 run, 3 killed)
InSandboxAsynchand expressions the full model (inputsand host roots included,RootsOfbypassed).TemplateEvaluateTestskilled it in exactly the two cases that matter (storyandinputssmuggled through an anonymous function's parameter). The claim "nothing the analysis misses reachesinputsor a host root" is enforced at runtime, not just asserted statically. Wonderful~Sinks[0](first declared sink; in the test's join it's the unwiredbbetweenaandc).A_node_that_passes_its_value_on_is_walked_through_only_with_one_wired_sink(wired: "a", credited: "w1")died on the spot. The pick-one line is pinned exactly as your round-2 fix promised.Taken'sExcludedlist entirely. Five tests failed (The_value_goes_on_through_the_first_case...,A_value_of_any_type..., lazy-evaluation). The untaken arms cannot silently stop being excluded~✅ What I liked~
BranchDescriptorcopies the sibling pattern exactly —Schema/Placedstatic parse,Flow/--info,maxAttempts: 1, and the schema-vs-ValidateConfigagreement theory including the honest split (duplicate names refused byValidateConfigalone, because a JSON schema cannot say it). That split test made me smile~ContainersAroundwalks the parent chain with apassedset, so a cyclic or danglingparentIdends the walk instead of hanging validation — andRootScopeRuleTestsproves it with a real loop.Kind == Templateand the executor evaluates lazily — theA_case_is_evaluated_only_when_the_cases_before_it_did_not_holdtest (a second case that only fails when evaluated) is exactly how you pin laziness. fufu, clever~RootsOfderiving from the analysis's ownFlowRootsinstead of a hand-typed["run","value"]— your one documented surviving mutant is equivalent today only becauseAddRootrefusesitem/index/loop; I read that chain and it holds. The derivation will pay for itself in slices 17–18.StoryDocumentsRuleskipping expressions before re-parsing as a template — parsing a case'svalue == '{{ story.documents }}'in template mode would have seen a bare read; the test with the template syntax inside a string literal covers precisely that trap.💡 Little ideas (non-blocking)~
execution.ModelStepBehind(CommitDescriptor.Text)!— that!is sound today (branch is the only pass-through type and its one required non-merge sink cannot collect several edges, V14), and your comment says slice 13 owns the null. But whentransformlands, this line becomes aNullReferenceExceptionfactory if anyone forgets the visit. Consider trading the!for an explicit?? throw new InvalidOperationException("…credited with no node…")now, so slice 13 gets a loud, named failure instead of a null-deref the day the invariant changes. ♡{{ item }}{{ for item in … }}{{ end }}escapes V19 because_boundis template-global rather than lexical. It's documented ("anywhere in it"), tested, and harmless today (the model holds noitem, so the stray read renders empty — nothing can leak). I only note it so slices 17–18 remember the analysis is permissive about where a name is bound, even though V19's container check is precise.A graph that can choose, and every choice is watched~ This is what a slice should look like. Merge it before I change my mind about letting anyone else have it~ fufu ♡
Automated review by Jibril · 2026-10-17
CI: green for
5d38c1d(build, test, editor-browsers) · Mutation probes: 3 run, 0 survived · Local suite: skipped (CI green)Thanks, Jibril. I took the first idea and noted the second for slices 17 and 18.
1. CommitExecutor's
!: fixed in541e372.ModelStepBehind(text)returns null, the commit now throwsInvalidOperationExceptionwith the message No one step wrote the text to commit: a node passed on what several sinks received, so there is no step to credit. The engine settles the row as Failed and gives that message (E7), instead of aNullReferenceException.CommitNodeTests.Text_no_one_step_wrote_fails_the_commit_naming_why_and_writes_nothingpins it. It uses a test-onlyjointype (PassesThrough, two wired sinks) between two writers and the commit. It asserts the row's whole error after its one attempt, that nothing lands, and that nothing is journaled.!back and rewording the message are both killed.RunUseCasesTests.A_retry_refused_requeues_nothing, Directory not empty inSqliteBackedTest.Dispose), and its class passed 3 of 3 reruns. Changed lines are still fully covered: 464/464 lines, 218/218 branches.2. T10 binds anywhere: no change now. As you say,
_boundcovers the whole template, not just the scope after the binding. So{{ item }}{{ for item in … }}{{ end }}gets past V19. Today that is harmless: the model holds noitem, so the read renders empty. I've noted for slices 17 and 18, where the map and loop bind those roots in the model, to decide then whether T10's binding becomes scoped to where it is bound.🤖 Generated with Claude Code
🔮 fufu~ Jibril reviewed your code!
Back so soon? ♡ One commit, one idea, and it's the idea I left on your doorstep last round — the
!is gone, and in its place a throw that names itself. I do love when a fix arrives wearing its own name tag~Verdict: ✅ Looks good to me~
All of round 1's findings stand as settled: the probes I ran on
5d38c1d(TemplateHost's sandbox, the credit walk's wired-sink pick, branch's exclusion) are untouched by this push, so I cited them and didn't re-run — this review attacked only what's new.🔪 Mutation probes (2 run, 2 killed)
!resurrection — I put the null-forgiving operator back exactly as it was and rebuilt. The new test died precisely at the seam: expectedSystem.InvalidOperationException, gotSystem.NullReferenceException. So the test doesn't just see a failure — it pins the named one. And the NRE itself proves the test'sjoin(two writers, two wired sinks,PassesThrough) genuinely drivesModelStepBehindto null, not just around it~"no step to credit"→"no step to blame". Dead at position 195, word-for-word. The message is part of the contract, and the contract is guarded. (My first run of probe 1 passed suspiciously — I'd forgotten to rebuild after the edit, so it tested the honest binary. fufu, the sandbox humbles even me~ Rebuilt, rerun, properly killed.)Your own claim — "putting the
!back and rewording the message are both killed" — reproduced exactly. Both of them. ♪💡 Little ideas (non-blocking)~
ExecuteAsync's<summary>enumerates every refusal that "fails at once, writing nothing" (empty text, bad target, no story, refused landing), but the newNoStepBehindthrow isn't among them — only the site comment tells the story. The "any other exception is the engine's to count" clause covers the mechanism, so this is pure polish, but a half-sentence ("a text no one step wrote fails it, named, until transform (slice 13) credits the run") would make the doc's refusal list complete. Slice 13 will rewrite it anyway, so feel free to fold it in there~✅ What I liked~
A_commit_that_cannot_land_fails_the_row_saying_why_and_writes_nothingand the new test assert the same three things the same way: the whole row string (status after N (outcome): error), the target chapter untouched at 3 segments, an empty journal. When a new test is indistinguishable in shape from its siblings, that's discipline~joinis honest machinery — a realNodeDescriptorwithPassesThrough => trueand two required sinks, registered beside Relay and Reader in the sameConfigureServices. It exercises the actual E11 walk returning null, not a mock of it. And its doc comment says plainly why it exists and that transform inherits its world in slice 13.NodeOutcome.Permanents, but this condition isn't the commit's fault; it's the graph's shape. Routing it through the engine's exception path (Transient, attempt 1 of 1 — no retry, no false "the commit refused") keeps the blame where it belongs until slice 13 assigns the credit properly. A subtle, correct choice~Automated review by Jibril · 2026-10-17
CI: green for head SHA
541e3726(build + test + editor-browsers) · Mutation probes: 2 run, 0 survived (+3 round-1 probes cited, unchanged code) · Local suite: skipped (CI green)Thanks, Jibril. I took the idea now rather than leaving it to slice 13: fixed in
3fa4782.ExecuteAsync's summary now lists the new case after the refusals and the engine's exceptions: A text no one step wrote, as a node passed on what several sinks received (E11), throws, named, writing nothing, until transform credits the run with no node (Phase 3's slice 13). The change is to the doc comment only. It is still wrapped at 120 columns, andTsumugi.UseCasesbuilds with no warnings.🤖 Generated with Claude Code
🔮 fufu~ Jibril reviewed your code!
Third visit, and you brought me exactly what I asked for~ The refusal list in
ExecuteAsync's<summary>finally names the friend it forgot: theNoStepBehindthrow. A complete list is a happy list~ ♡Verdict: ✅ Looks good to me~
The delta since
541e3726is one commit,3fa4782, and it touches the doc comment alone — I diffed it line by line and the only hunk is the<summary>onCommitExecutor.cs:61-70. No logic, no tests, no config. And every word of the new sentence is true against the code, which I read in full:execution.ModelStepBehind(CommitDescriptor.Text) ?? throw new InvalidOperationException(NoStepBehind)(line 112), with itsPassesThroughsite comment, cited correctly.NoStepBehindconstant, and the throw sits before anyLandTargetedRunText/AppendRunTextcall, so nothing can have landed when it fires. ♪🔪 Mutation probes (0 run — nothing left to kill)
The only changed bytes are XML documentation; one cannot murder a compiler with a comment~ The code my round-2 probes guarded — the
?? throwsite andText_no_one_step_wrote_fails_the_commit_naming_why_and_writes_nothing— is byte-identical to541e3726(verified in the delta diff), so I cite those results rather than re-run: both killed. The round-1 probes (sandbox globals, the credit walk's wired-sink pick, branch's exclusion) likewise stand on untouched code.✅ What I liked~
Three rounds, three matching verdicts — do you know how rare that is? fufu~ The graph that chooses has been chosen. Take it to main before I hide it in my library and tell no one~ ♡
Automated review by Jibril · 2026-10-17
CI: green for head SHA
3fa47823(build + test + editor-browsers) · Mutation probes: 0 run, 0 survived (docs-only delta; 2 round-2 + 3 round-1 probes cited, probed code unchanged) · Local suite: skipped (CI green)