diff --git a/Gemfile.lock b/Gemfile.lock index c6a5ef9..130c334 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,7 +3,9 @@ PATH specs: agentic (0.2.0) async (~> 2.0) + cgi dry-schema + logger ostruct pastel (~> 0.8) ruby-openai @@ -29,6 +31,7 @@ GEM traces (~> 0.15) base64 (0.2.0) bigdecimal (3.1.8) + cgi (0.5.2) concurrent-ruby (1.3.4) console (1.30.2) fiber-annotation diff --git a/README.md b/README.md index 2e2af84..e80ec88 100644 --- a/README.md +++ b/README.md @@ -158,21 +158,22 @@ end ```ruby # Create an orchestrator -orchestrator = Agentic::PlanOrchestrator.new( - concurrency_limit: 5, - continue_on_failure: true -) - -# Add tasks to the orchestrator -tasks.each do |task| - orchestrator.add_task(task) -end +orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 5) -# Create an agent provider -agent_provider = Agentic::DefaultAgentProvider.new +# Add tasks (each with its own agent - see the next section for providers) +collect = Agentic::Task.new( + description: "collect entries", + agent_spec: {"name" => "Collector", "instructions" => "collect"} +) +report = Agentic::Task.new( + description: "write the report", + agent_spec: {"name" => "Reporter", "instructions" => "report"} +) +orchestrator.add_task(collect, agent: ->(_task) { %w[alpha beta gamma] }) +orchestrator.add_task(report, [collect], agent: ->(task) { "#{task.previous_output.size} entries reported" }) # Execute the plan -result = orchestrator.execute_plan(agent_provider) +result = orchestrator.execute_plan # Process the results if result.successful? @@ -194,6 +195,15 @@ You don't need an agent provider to run a plan. Tasks carry an arbitrary outputs of the tasks they depend on: ```ruby +# Stand-ins for your app's objects: +module OrderApi + def self.fetch(id) = {id: id, status: "shipped"} +end +module Mailer + def self.deliver(order) = "notified customer for order #{order[:id]}" +end +order_id = 42 + orchestrator = Agentic::PlanOrchestrator.new fetch = Agentic::Task.new( @@ -224,12 +234,20 @@ Dependencies can be **named**, so they're declared and consumed under one word, and single-dependency chains have a shorthand: ```ruby +orchestrator = Agentic::PlanOrchestrator.new +new_task = ->(name) { Agentic::Task.new(description: name, agent_spec: {"name" => name, "instructions" => name}) } +commits, debt, digest = new_task["commits"], new_task["debt"], new_task["digest"] + +orchestrator.add_task(commits, agent: ->(_t) { 12 }) +orchestrator.add_task(debt, agent: ->(_t) { 3 }) orchestrator.add_task(digest, needs: {shipped: commits, owed: debt}, agent: ->(task) { "#{task.needs.shipped} commits shipped, #{task.needs.owed} TODOs owed" }) +previous_verse, next_verse = new_task["first verse"], new_task["second verse"] +orchestrator.add_task(previous_verse, agent: ->(_t) { "an old pond" }) orchestrator.add_task(next_verse, [previous_verse], agent: ->(task) { - answer(task.previous_output) # the sole dependency's output + "#{task.previous_output} / a frog leaps in" # the sole dependency's output }) ``` @@ -357,17 +375,22 @@ The Plugin Manager handles third-party extensions and their lifecycle: # Get the plugin manager manager = Agentic::Extension.plugin_manager -# Register a plugin -plugin = MyPlugin.new -manager.register("my_plugin", plugin, { version: "1.0.0" }) +# A plugin is any object with #initialize_plugin and #call +class GreeterPlugin + def initialize_plugin(context = {}) = true + def call(name) = "hello, #{name}" +end + +# Register a plugin (register! replaces an existing registration) +manager.register!("greeter", GreeterPlugin.new, { version: "1.0.0" }) # Get and use a plugin -if plugin = manager.get("my_plugin") - result = plugin.call(arg1, arg2) +if (plugin = manager.get("greeter")) + result = plugin.call("agentic") end # Disable a plugin -manager.disable("my_plugin") +manager.disable("greeter") ``` ## Agent Specification and Task Definition @@ -398,6 +421,10 @@ agent_spec_from_hash = Agentic::AgentSpecification.from_hash(hash) The TaskDefinition defines a task to be performed by an agent: ```ruby +agent_spec = Agentic::AgentSpecification.new( + name: "ResearchAgent", description: "Researches topics", instructions: "Research thoroughly" +) + # Create a task definition task_def = Agentic::TaskDefinition.new( description: "Research AI trends", @@ -416,6 +443,11 @@ task_def_from_hash = Agentic::TaskDefinition.from_hash(hash) The ExecutionPlan represents a plan with tasks and expected answer format: ```ruby +agent_spec = Agentic::AgentSpecification.new( + name: "ResearchAgent", description: "Researches topics", instructions: "Research thoroughly" +) +task_def = Agentic::TaskDefinition.new(description: "Research AI trends", agent: agent_spec) + # Create an expected answer format expected_answer = Agentic::ExpectedAnswerFormat.new( format: "PDF", @@ -517,6 +549,7 @@ agent = Agentic.assemble_agent(task) Agents can be stored and retrieved for future use: + ```ruby # Store an agent for future use agent_id = Agentic.agent_store.store(agent, name: "report_generator") @@ -536,7 +569,14 @@ agents = Agentic.agent_store.all Capabilities can be composed into higher-level capabilities: ```ruby -registry = Agentic.agent_capability_registry +registry = Agentic::AgentCapabilityRegistry.instance + +# Register the two base capabilities (bare lambdas are providers) +[["text_generation", ->(i) { {response: "report on: #{i[:prompt]}"} }], + ["data_analysis", ->(i) { {summary: "avg sales #{i[:data][:sales].sum / i[:data][:sales].size}"} }]].each do |name, impl| + spec = Agentic::CapabilitySpecification.new(name: name, description: name, version: "1.0.0") + Agentic.register_capability(spec, Agentic::CapabilityProvider.new(capability: spec, implementation: impl)) +end # Compose multiple capabilities into a new one registry.compose( @@ -548,16 +588,15 @@ registry.compose( { name: "data_analysis", version: "1.0.0" } ], ->(providers, inputs) { - # Implementation that uses both capabilities analysis = providers[1].execute(data: inputs[:data]) report = providers[0].execute(prompt: "Generate a report on: #{analysis[:summary]}") { report: report[:response], analysis: analysis } } ) -# Use the composed capability -agent.add_capability("comprehensive_report") -result = agent.execute_capability("comprehensive_report", { data: { sales: [120, 90, 143] } }) +# Use the composed capability like any other +provider = registry.get_provider("comprehensive_report") +result = provider.execute(data: { sales: [120, 90, 143] }) ``` ## Learning System @@ -597,6 +636,12 @@ avg_tokens = history_store.get_metric(:tokens_used, { agent_type: "research_agen The PatternRecognizer analyzes execution history to identify patterns and optimization opportunities: ```ruby +history_store = Agentic::Learning::ExecutionHistoryStore.new(storage_path: "history") +15.times do |i| + history_store.record_execution(task_id: "t#{i}", agent_type: "research_agent", + duration_ms: 1000 + i * 20, success: i % 4 != 0, metrics: {tokens_used: 1500 + i}) +end + # Create a pattern recognizer recognizer = Agentic::Learning::PatternRecognizer.new( history_store: history_store, @@ -618,11 +663,17 @@ recommendations = recognizer.recommend_optimizations("research_agent") The StrategyOptimizer generates improvements for prompts, parameters, and task sequences: ```ruby -# Create a strategy optimizer +history_store = Agentic::Learning::ExecutionHistoryStore.new(storage_path: "history") +15.times do |i| + history_store.record_execution(task_id: "t#{i}", agent_type: "research_agent", + duration_ms: 1000 + i * 20, success: i % 4 != 0, metrics: {tokens_used: 1500 + i}) +end +recognizer = Agentic::Learning::PatternRecognizer.new(history_store: history_store) + +# Create a strategy optimizer (add llm_client: for LLM-enhanced optimizations) optimizer = Agentic::Learning::StrategyOptimizer.new( pattern_recognizer: recognizer, - history_store: history_store, - llm_client: llm_client # Optional, for LLM-enhanced optimizations + history_store: history_store ) # Optimize a prompt template @@ -649,18 +700,22 @@ The Learning System can be automatically integrated with the PlanOrchestrator: ```ruby # Create the learning system learning_system = Agentic::Learning.create( - storage_path: "~/.agentic/history", - llm_client: llm_client, + storage_path: "history", auto_optimize: false ) -# Create a plan orchestrator -orchestrator = Agentic::PlanOrchestrator.new +# Hooks are a construction-time seam: pass the learning system's hooks +# to the orchestrator (chaining any hooks you already have) +orchestrator = Agentic::PlanOrchestrator.new( + lifecycle_hooks: Agentic::Learning.lifecycle_hooks(learning_system) +) -# Register the learning system with the orchestrator -Agentic::Learning.register_with_orchestrator(orchestrator, learning_system) +# The orchestrator now records execution metrics automatically +task = Agentic::Task.new(description: "work", agent_spec: {"name" => "w", "instructions" => "w"}) +orchestrator.add_task(task, agent: ->(_t) { :done }) +orchestrator.execute_plan -# The orchestrator will now automatically record execution metrics +learning_system[:history_store].get_history.size # => 1 ``` ## Configuration @@ -692,6 +747,7 @@ You can configure the OpenAI API key in several ways: You can customize the LLM behavior: + ```ruby config = Agentic::LlmConfig.new( model: "gpt-4o-mini", diff --git a/agentic.gemspec b/agentic.gemspec index fff0744..bd4cde5 100644 --- a/agentic.gemspec +++ b/agentic.gemspec @@ -44,6 +44,8 @@ Gem::Specification.new do |spec| spec.add_dependency "tty-cursor", "~> 0.7" spec.add_dependency "pastel", "~> 0.8" spec.add_dependency "ostruct" + spec.add_dependency "logger" # bundled gem as of Ruby 3.5 - declare what you require + spec.add_dependency "cgi" # trimmed to a bundled gem in Ruby 3.5; used for CGI.escape # For more information and examples about making a new gem, check out our # guide at: https://bundler.io/guides/creating_gem.html diff --git a/docs/perspectives/README.md b/docs/perspectives/README.md index e39cbb3..bccb3b6 100644 --- a/docs/perspectives/README.md +++ b/docs/perspectives/README.md @@ -420,6 +420,176 @@ third cast of ten prolific Rubyists took the bench: replay mode for audit tools vs the tolerant recovery default (flavorjones). +## Round 13 — a fourth cast, and the docs go on trial + +The round-12 asks shipped as a release: journal replay is +tolerant-by-default (whole lines salvaged, damage reported with line +and reason on `state.damage`), a strict mode raises +`JournalDamagedError` for audit tools, and `fsync_every:` makes group +commit an explicit constructor choice with its durability trade +named. The hostile-inputs probe flipped green; the write-path profile +benches the real knob. Then a fourth cast of ten took the bench: + +| # | Persona | Built with the gem | Run it | Field notes | +|---|---------|--------------------|--------|-------------| +| 1 | Piotr Murach | TTY status board — badge, gauge, tree, frame, composed | `examples/tty_status.rb` | [round-13/01-piotrmurach.md](round-13/01-piotrmurach.md) | +| 2 | John Nunemaker | Feature flags — the experimental step is a plan shape, not an if | `examples/feature_flags.rb` | [round-13/02-jnunemaker.md](round-13/02-jnunemaker.md) | +| 3 | Akira Matsuda | Journal tail pager — page 1 costs 16KB of a 2.5MB file | `examples/journal_tail.rb` | [round-13/03-amatsuda.md](round-13/03-amatsuda.md) | +| 4 | David Bryant Copeland | CLI contract — four channels, EX_USAGE distinct from failure | `examples/cli_contract.rb` | [round-13/04-davetron5000.md](round-13/04-davetron5000.md) | +| 5 | Hiroshi Shibata | Stdlib census — logger and cgi caught before the 3.5 wave | `examples/stdlib_census.rb` | [round-13/05-hsbt.md](round-13/05-hsbt.md) | +| 6 | Noel Rappin | Money discipline — integer cents as a tripwire type | `examples/money_discipline.rb` | [round-13/06-noelrap.md](round-13/06-noelrap.md) | +| 7 | Tom Stuart | Plans as automata — completion proved total by exhaustion | `examples/plans_as_automata.rb` | [round-13/07-tomstuart.md](round-13/07-tomstuart.md) | +| 8 | Chris Oliver | Job adapter — retry_on/discard_on in forty lines | `examples/job_adapter.rb` | [round-13/08-excid3.md](round-13/08-excid3.md) | +| 9 | Kasper Timm Hansen | API riffs — three shapes for fsync_every, judged at the call site | `examples/api_riffs.rb` | [round-13/09-kaspth.md](round-13/09-kaspth.md) | +| 10 | Steve Klabnik | Doctest runner — 11 of 30 documented examples are alive | `examples/doctest_runner.rb` | [round-13/10-steveklabnik.md](round-13/10-steveklabnik.md) | + +### What round 13 surfaced + +1. **Two more live hazards fixed in-round**: the stdlib census caught + `logger` (bundled-gem promotion in Ruby 3.5) and `cgi` (trimmed in + 3.5) required-but-undeclared — both now in the gemspec with + reasons. Same law as round 11's "time" bug: a transitive require + is a loan, and rubies refinance. +2. **The docs went on trial and lost**: the doctest runner executed + all 30 documented examples (YARD @example blocks + README fences) + in sandboxes — 11 run, 19 are dead from missing setup or API + drift. Dead docs cluster around dead-ish code corners. +3. **The release's own features were immediately load-bearing**: + `rewire_task` spliced flag-gated steps (Nunemaker), `fsync_every` + made the pager's 20k-event fixture affordable and got its API + shape riffed and vindicated (kaspth), and `hopeless?` backstopped + `discard_on` in the job adapter. +4. **The theory seat earned its keep**: enumerating the diamond's + six-state space proves completion totally rather than sampling + it, and exhibits the cycle as an empty machine — giving precise + content to two earlier rounds' cycle intuitions. Know which + regime you're in: enumeration for small machines, invariant + provers past forty tasks. +5. **Next asks**: runnable-or-annotated docs — every README fence + and @example either executes in CI via the doctest runner or + carries a deliberate "illustrative" marker (Klabnik); and revive + or retire the learning-system corner whose examples all died with + LoadErrors (the census-adjacent smell). + +## Round 14 — the docs go green, and a fifth cast arrives + +The round-13 asks shipped as a release: the doctest runner is now a +referee (every doc example runs or carries a deliberate +"illustrative" annotation — 26 run, 4 annotated, 0 dead), the drifted +README fences were fixed against current APIs, and the learning +corner was **revived**, not retired: three more missing stdlib +requires, a double-counting history store (memory cache + files now +deduped by id), and the never-functional `register_with_orchestrator` +replaced by `Learning.lifecycle_hooks` — the same construction-time +seam the journal uses. Then a fifth cast took the bench: + +| # | Persona | Built with the gem | Run it | Field notes | +|---|---------|--------------------|--------|-------------| +| 1 | Evan Phoenix | Plan server — thread pool, shared quota, a drain with dignity | `examples/plan_server.rb` | [round-14/01-evanphx.md](round-14/01-evanphx.md) | +| 2 | André Arko | Capability resolver — the dependencies: field, finally resolved | `examples/capability_resolver.rb` | [round-14/02-indirect.md](round-14/02-indirect.md) | +| 3 | Soutaro Matsumoto | RBS export — shape from contracts; the validator keeps the law | `examples/rbs_export.rb` | [round-14/03-soutaro.md](round-14/03-soutaro.md) | +| 4 | Benoit Daloze | Behavior spec — six boundary choices, pinned in a 30-line mspec | `examples/behavior_spec.rb` | [round-14/04-eregon.md](round-14/04-eregon.md) | +| 5 | Yuki Nishijima | Did you mean — three error seams finish your sentence | `examples/did_you_mean.rb` | [round-14/05-yuki24.md](round-14/05-yuki24.md) | +| 6 | Sam Saffron | Always-on profiler — a badge per plan, budgets that assign work | `examples/always_on_profiler.rb` | [round-14/06-samsaffron.md](round-14/06-samsaffron.md) | +| 7 | Ryan Tomayko | Unix workers — fork, pipe, kill, wait; 9/9 served, 3/3/3 | `examples/unix_workers.rb` | [round-14/07-rtomayko.md](round-14/07-rtomayko.md) | +| 8 | Marc-André Lafortune | Ractor audit — send facts, keep machines | `examples/ractor_shareability.rb` | [round-14/08-marcandre.md](round-14/08-marcandre.md) | +| 9 | Rosa Gutiérrez | Concurrency key — one per tenant, tenants in parallel, judged | `examples/concurrency_key.rb` | [round-14/09-rosa.md](round-14/09-rosa.md) | +| 10 | Janko Marohnić | Attachment pipeline — cache instantly, promote via journaled plan | `examples/attachment_pipeline.rb` | [round-14/10-janko.md](round-14/10-janko.md) | + +### What round 14 surfaced + +1. **The learning corner's autopsy generalized round 11's lesson**: + three more files used stdlib without requiring it, a store + double-counted its own records, and a public API called a method + that never existed — dead docs had been pointing at dead-ish code + all along, and reviving the docs revived the code. +2. **The dormant metadata kept paying**: the `dependencies:` field + (unused since round 1) became a Bundler-style resolver; + contracts became RBS signatures with the shape/law boundary drawn + on principle; `try_acquire` became cron-guard semantics + (`skip_if_running`); the journal became an upload promotion log. +3. **Process-grade patterns joined the catalog**: a real socket + server with a measured graceful drain, preforked workers reaped + by pid, per-tenant serialization with judged interleavings, and a + Ractor audit whose one refusal (the mutex-holding limiter) is + load-bearing: send facts, keep machines. +4. **Seventh consecutive round of tools correcting authors**: the + burst-fed pipe wasn't fair (9/0/0 until arrivals were paced), the + Ractor auditor froze its own evidence, and the Unix example + committed the exact missing-require sin the census preaches + against. The streak is the methodology. +5. **Next asks**: thread did-you-mean suggestions into + ValidationError and the rewire/remove errors (the candidate lists + are already in scope at every raise site — yuki24); and pin + fiber-vs-thread safety guarantees per public method as behavior + specs, not just drills (eregon). + +## Round 15 — the closing release + +The final round delivers the round-14 asks and closes the loop with +no new asks outstanding: + +- **Did-you-mean became infrastructure** (`Agentic::Suggestions`): a + conservative Levenshtein engine threaded into the framework's own + errors. `ValidationError` now diagnoses renamed keys from + missing-plus-similar-extra ("You sent :weight_kilo - did you mean + :weight_kg?", in the message and as structured `hints`), and the + rewire/remove errors suggest close task names. The engine stays + silent past a length-scaled threshold - a wrong suggestion is worse + than none. `did_you_mean.rb` flipped from retrofit to native + demonstration. +- **The concurrency contract is pinned, not just drilled**: + `spec/agentic/concurrency_contract_spec.rb` promises per-method + guarantees (journal writes thread-safe and cross-process safe, + windowed limiter thread-safe, concurrency-mode limiter + fiber-scoped, registry thread-safe), and the methods themselves + carry `@note Concurrency contract:` documentation. + +Suite at 610 examples, 0 failures; 131 runnable example programs; +every doc example runs or says why it doesn't. + +## The series, closed out + +Fifteen rounds. Fifty personas across five casts. Thirteen releases, +each built from the previous round's in-character field notes. What +the experiment demonstrated: + +1. **The loop worked.** Personas building *with* the gem generated + asks; shipping the asks made the next builds possible; the new + builds found the next seams. Features that emerged this way - + the graph API, the journal's percentiles and tolerant replay, + relation rules, resize/try_acquire, remove/rewire, suggestions - + all arrived pre-validated by a consumer that already existed. +2. **Examples are a defect detector.** The builds surfaced real bugs + the suite never touched: a truncated test run, a scheduler + deadlock, canceled plans that billed anyway, relation rules + crashing in the wrong error class, a torn journal tail denying + all recovery, six files using stdlib without requiring it, a + history store double-counting itself, and a public API calling a + method that never existed. Every one was found by *using* the + thing, and fixed with the finding example as the acceptance test. +3. **Declarations compound.** The single biggest return came from + making things data: contract declarations bought validation, + docs, schemas, fixtures, semver, RBS, 422s, polite forms, and a + resolver; relation rules bought enforcement, generation, + projection, and diffing; the graph bought drawing, testing, + merging, linting, and flogging. Code keeps secrets; data makes + friends - the series' most-repeated lesson because it kept being + re-earned. +4. **Tools correct their authors.** Eight consecutive rounds ended + with an example's own output overruling the prose its author had + drafted. Measurement-over-narrative wasn't a value we asserted; + it was a pattern the artifacts enforced. +5. **The referee pattern scales.** Exit-code-gated honesty tools + (probers, provers, drills, the doctest runner) turned findings + into acceptance tests: each round's sharpest complaint became the + next round's green check, and stays in the repo re-running. + +The catalog stands at 131 offline example programs, ten field-note +directories, and a framework whose contracts, journals, limiters, +and plans all testify about themselves. The bench is cleared; the +asks list is empty; everything the room asked for was shipped. + ### What round 6 surfaced 1. **Plans became artifacts**: narratable (tour), serializable with an diff --git a/docs/perspectives/round-13/01-piotrmurach.md b/docs/perspectives/round-13/01-piotrmurach.md new file mode 100644 index 0000000..017e979 --- /dev/null +++ b/docs/perspectives/round-13/01-piotrmurach.md @@ -0,0 +1,71 @@ +# Round 13 field notes — Piotr Murach composes the status board + +*Built: `examples/tty_status.rb` — a plan's live state rendered as +composed terminal components (badge, gauge, tree, frame), driven +entirely by lifecycle hooks. Three frames, zero dependencies.* + +## What I built and why + +I've published a few dozen tty-* gems, and the reason there are +dozens instead of one is the entire philosophy: **terminal output is +a user interface, and interfaces are built from components.** A +spinner is not a progress bar is not a table is not a box — each is +one small thing with one job, and applications *compose* them. The +alternative — `puts` sprinkled wherever the mood struck — is how +CLIs end up with output nobody can read, redirect, or test. + +``` ++--------------------------------+ +| after parse entries | ++--------------------------------+ +| [x] fetch feeds | +| |-- [x] parse entries | +| |-- [ ] rank stories | +| `-- [ ] publish digest | +| | +| |============ | 2/4 | ++--------------------------------+ +``` + +Four components, each testable alone: `badge` (state → glyph), +`gauge` (counts → bar), `tree` (depth → indent, using the graph's +own `stats[:depth]`), `frame` (lines → box). The StatusBoard only +composes. When the gauge needs to become a spinner, you swap one +component and no rendering code learns about it — that's the whole +toolbox discipline in one sentence. + +## The seams were ready + +What made this a pleasure to build on: the two data sources are +exactly UI-shaped. The **hooks** hand over state transitions with +names and timing — precisely what a live view consumes, no polling, +no diffing. The **graph** hands over structure — depth for +indentation, order for row sequence — precomputed by round 8's +stats. A UI layer should never have to *derive* the model; it should +only have to *dress* it, and here the model arrives dressed-ready. + +One design note on frames versus streaming: the board captures +snapshots at milestones rather than repainting live, which makes the +example testable and redirectable (frames are just arrays of +strings). A real TUI would repaint — but the component layer is +*identical* in both worlds; only the presenter loop changes. Design +the components first and the paint cadence becomes a deployment +detail. + +## Notes + +- No escape codes, no curses, no gems — deliberately. The discipline + is the demonstration; color and cursor movement are twenty minutes + of decoration once the structure is right (and pastel would like a + word about the colors). +- The `tree` glyphs follow the `|--`/`` `-- `` convention because + eyes trained on `tree(1)` parse it for free. Terminal UI has + conventions the way the web does; break them only on purpose. + +## Verdict + +The plan already knew its structure and announced its transitions; +four tiny components turned that into an interface that was +designed, not accreted. Build the components, compose the board, +and let the terminal be what it's always been: a UI toolkit wearing +a typewriter costume. diff --git a/docs/perspectives/round-13/02-jnunemaker.md b/docs/perspectives/round-13/02-jnunemaker.md new file mode 100644 index 0000000..16cb89f --- /dev/null +++ b/docs/perspectives/round-13/02-jnunemaker.md @@ -0,0 +1,64 @@ +# Round 13 field notes — John Nunemaker flips the plan + +*Built: `examples/feature_flags.rb` — a Flipper-shaped gate (boolean, +actor, percentage) deciding per run whether the experimental +fact-check step joins the plan, spliced in with `rewire_task`.* + +## What I built and why + +Flipper exists because "should this code run?" and "is this code +deployed?" are different questions, and teams that conflate them do +their product experiments with `git revert`. Plans have the same +conflation one level up: shipping a new pipeline *step* shouldn't be +a deploy decision. So — gates: + +``` +phase 1, flag off: fetch -> summarize -> publish (everyone) +phase 2, actor acme: fetch -> summarize -> fact_check -> publish +phase 3, 50% rollout: acme, globex checked; umbrella not yet +``` + +The Flipper essentials survive miniaturization intact: **boolean** +for kill-switch, **actor** for design partners (acme ran fact-checked +for a whole phase before anyone else — that's how you learn whether +the step is good *before* arguing about the rollout), **percentage** +with deterministic bucketing. That last adjective is load-bearing: +the bucket is a pure function of the actor, so the same tenant gets +the same verdict every run. Flapping flags — enabled this request, +disabled the next — are worse than no flags, because they turn every +bug report into a coin-flip archaeology dig. + +## The step is a plan shape, not an if + +The design decision I care most about: the experimental step is +**not** an `if FLAGS.enabled?` *inside* a task. It's a different +*plan*, built per run — the flag consults once at build time, the +step is added, and `rewire_task` (round 12's refactoring seam, +turning out to be a *runtime composition* seam) splices `publish` +onto the new step. Two payoffs: the graph is honest (`graph[:order]` +shows exactly which shape ran — every observability tool from +rounds 5-12 sees the flag's effect for free), and the off state has +*zero residue* — no branch, no dead code path, no "flag check" in +the hot loop. The plan either has the step or it doesn't. + +And rollback is `disable`, not deploy. When fact-check misbehaves at +50%, you turn the dial to zero and the next run is the old plan — +while the code stays shipped, warm, and ready for the fix. + +## Notes + +- My first 50% phase put all three tenants in the bucket — small + demo, small sample, embarrassing chart. Deterministic bucketing + means you can *pick* demo tenants on both sides of the line, which + is itself the operational point: with Flipper you always know + which side an actor is on. +- Real Flipper adds groups and per-flag adapters; the 30-line + version keeps gate-check-order (boolean, then actor, then + percentage) because that ordering IS the semantics people rely on. + +## Verdict + +Flags decouple shipping code from running it; plans-as-data extend +that to shipping *steps* without running them. One flag, three +gates, three phases, and the rollout of a pipeline stage became a +dial instead of a deploy calendar. diff --git a/docs/perspectives/round-13/03-amatsuda.md b/docs/perspectives/round-13/03-amatsuda.md new file mode 100644 index 0000000..d253508 --- /dev/null +++ b/docs/perspectives/round-13/03-amatsuda.md @@ -0,0 +1,69 @@ +# Round 13 field notes — Akira Matsuda paginates the journal + +*Built: `examples/journal_tail.rb` — a tail pager that reads pages of +a 20,000-event journal backwards in 16KB chunks, with byte-offset +cursors. Page 1 costs 0.6% of the file and runs ~9,500x faster than +full replay.* + +## What I built and why + +Kaminari taught me that pagination looks like a UI problem and is +actually a *cost model* problem: `OFFSET 19950` reads and discards +19,950 rows to show you 50, and the page numbers shift under your +feet whenever rows append. Production journals are production +tables wearing a filesystem costume, and the question asked of both +is almost always "what happened *recently*?" Answering that with +`ExecutionJournal.replay` is `SELECT *`: + +``` +last page (50 events): 0.3ms, 16KB read (t19950 .. t19999) +full replay (control): 3245.4ms, 2542KB read + +page 1: t19950 .. t19999 +page 2: t19900 .. t19949 +page 3: t19850 .. t19899 +``` + +The pager seeks to EOF and reads *backwards* in fixed chunks until +it has a page of complete lines. Page 1 cost 16KB of a 2.5MB file +and the incident responder is looking at the last fifty events +before the full replay would have finished parsing March. + +## Cursors, not page numbers + +The `prev_cursor` is a **byte offset**, not a page number, and this +is the kaminari scar tissue speaking: numbered pages shift when rows +append — page 3 of a growing journal is a different fifty events +every time someone's plan completes, which makes "look at page 3 +again" a lie between colleagues. Byte offsets are stable under +append (an append-only file never moves old bytes), so a cursor +pasted into an incident channel means the same events tomorrow. +Append-only files are secretly the easiest pagination target there +is — no vacuum, no reorder, no gaps — and it would be a shame to +paginate them badly out of habit. + +Boundary care: a chunk read may start mid-line, so the pager drops +the first fragment unless it reached byte zero — the same off-by-one +that plagues every backwards-reader, handled once, in one place, +with a comment. + +## Notes + +- Division of labor stated plainly: full replay remains correct for + *resume* (you need every completion, and round 13's tolerant mode + besides); the pager is for *looking*. Different questions, different + I/O shapes — don't make the browser pay the restorer's bill. +- The pager tolerates torn lines by skipping them (filter_map with a + rescue) — inherited politeness from this morning's release; a tail + reader meets the torn tail more often than anyone. +- Built on `fsync_every: 500` for the 20k-event setup, because + writing the fixture at per-event durability would have spent three + minutes proving byroot's point from last round. + +## Verdict + +The journal now has an index-friendly way to answer its most common +question. Page 1 in a third of a millisecond, cursors that survive +append, and full replay reserved for the job that actually needs it +— pagination is a cost model, and this one finally charges the +reader for what they read. diff --git a/docs/perspectives/round-13/04-davetron5000.md b/docs/perspectives/round-13/04-davetron5000.md new file mode 100644 index 0000000..19fc053 --- /dev/null +++ b/docs/perspectives/round-13/04-davetron5000.md @@ -0,0 +1,68 @@ +# Round 13 field notes — David Bryant Copeland signs the CLI contract + +*Built: `examples/cli_contract.rb` — a plan wrapped in a CLI that +honors all four channels (stdout, stderr, exit code, --format json), +proven by invoking itself the way its real consumers would.* + +## What I built and why + +I wrote a whole book about command-line applications because the +industry keeps treating CLIs as the *casual* interface — and then +wiring them into cron, CI, and shell pipelines, which are the least +casual consumers in computing. A CLI is an API whose clients are +scripts and a tired human at 2am, and each client reads a different +channel: + +``` +$ digest -> stories on stdout, progress on stderr, exit 0 +$ digest --format=json --quiet -> pure JSON, silence, exit 0 +$ digest --quiet --fail -> diagnosis + HINT on stderr, exit 1 +$ digest --formt=json -> usage on stderr, exit 64 +``` + +Four invocations, four consumers, and every channel doing exactly +one job. The human sees progress without polluting `digest > +out.txt`. The pipe gets JSON that jq will never choke on, because +chatter went to stderr and `--quiet` killed even that. Cron stays +silent until something matters — the discipline that keeps ops from +mail-filtering your tool into oblivion — and when it fails, the +error comes *with a hint*, because a diagnostic without a next +action is half a diagnostic. + +## Exit 64 is the tell + +The detail that separates tools people script against from tools +they script around: the typo'd flag exits **64** (`EX_USAGE`, from +sysexits.h), not 1. "You called me wrong" and "the work failed" are +different facts. A deploy script wants to retry a transient exit 1; +retrying an exit 64 means retrying your own typo forever. The +framework helped here more than it knows: `PlanExecutionResult` +distinguishes success from partial failure, and the failure object +carries a typed, messaged cause — so mapping outcomes onto the exit +code taxonomy was a case statement, not archaeology. + +Testing note: the whole CLI runs through `run(argv, stdout:, +stderr:)` with injected streams — which is why the example can +invoke itself four ways and assert on the channels. A CLI whose +entry point writes to global `$stdout` is a CLI you can only test +with subprocess gymnastics; inject the streams and your CLI becomes +a function. (This is the one trick from the book I will teach anyone +who stands still long enough.) + +## Notes + +- `--format=json` should be the *contract* format: shell text output + is for eyes and may change; JSON output is for machines and gets + semver discipline. Say so in --help. +- Not built, noted: `--verbose` tiers and a real option parser. The + hand-rolled loop is fine at two flags and a liability at five — + the moment options interact, reach for OptionParser and keep the + stream injection. + +## Verdict + +Data to stdout, diagnostics to stderr, verdicts in exit codes, +machine format on request — none of it glamorous, all of it the +difference between a tool that joins pipelines and one that breaks +them. The plan supplied the outcomes; the CLI's job was just to +route four facts to four channels without crossing the wires. diff --git a/docs/perspectives/round-13/05-hsbt.md b/docs/perspectives/round-13/05-hsbt.md new file mode 100644 index 0000000..420d0b1 --- /dev/null +++ b/docs/perspectives/round-13/05-hsbt.md @@ -0,0 +1,68 @@ +# Round 13 field notes — Hiroshi Shibata takes the stdlib census + +*Built: `examples/stdlib_census.rb` — every `require` in lib/ +classified: gemspec-declared, safely-default, promoted-to-bundled, +or covered by nobody. First run caught two live hazards; both are +fixed in this round's gemspec.* + +## What I built and why + +"It's in the standard library" is a statement with a shelf life, and +I spend a good part of my ruby-core time managing exactly that +shelf: default gems become bundled gems on a published schedule +(3.4 warned about ostruct and friends; 3.5 promotes logger, csv, +and trims cgi, among others). A gem that requires these without +declaring them works perfectly — until its users upgrade Ruby, at +which point `LoadError` arrives wearing the gem's name, not mine. +So: census, cross-check, verdict. + +``` +!! cgi UNCOVERED - works today by accident + ! logger PROMOTED to bundled - declare it or the upgrade breaks + 26 requires total; 14 declared, 10 safely default +``` + +The first run drew blood twice. `logger` — required by the gem's own +logging — joins the 3.5 bundled wave. And `cgi` (used for +`CGI.escape` in web search) is the sharper case: the cgi gem is +being *trimmed* in 3.5, exactly the kind of NEWS-file detail that +never reaches application developers until the bundle fails. Both +are now declared in the gemspec, each with a comment saying *why* — +because a bare `add_dependency "logger"` will look like cargo cult +to whoever reads it in two years, and dependency lines deserve +commit-message-quality reasons. + +## The pattern the gemspec already knew + +The census also found evidence of prior discipline: `ostruct` was +already declared — someone met the 3.4 warning wave and did the +right thing. And the round-11 `time` incident (Time#iso8601 used, +"time" never required, worked only via async's transitive require) +is this census's lesson at file scope. Same law both times: **a +transitive require is a loan, and rubies refinance.** Declare at +the gemspec what your gem requires; require in each file what that +file uses. + +The mapping table (`dry/schema` → `dry-schema`, `openai` → +`ruby-openai`) is small but load-bearing: require paths and gem +names diverge exactly often enough to make naive cross-checking +lie in both directions. + +## Notes + +- The census belongs in CI more than any tool this experiment has + produced, because its failure mode is *scheduled*: we know 3.5's + promotion list today. A red census in March is a one-line PR; a + green-but-wrong census discovered in December is an issue tracker + full of LoadErrors filed by strangers. +- What I'd add upstream: `gem "logger"` in the census's GEMIFIED + list will need updating each release cycle. That's not a flaw — + that's the job. Release engineering is reading the NEWS file as + if your install matrix depends on it, because it does. + +## Verdict + +Twenty-six requires, two live hazards, both fixed with commented +gemspec lines before any user met them. The unglamorous truth of +gem maintenance: the NEWS file is upstream of your issue tracker, +and sixty lines of census keep it that way. diff --git a/docs/perspectives/round-13/06-noelrap.md b/docs/perspectives/round-13/06-noelrap.md new file mode 100644 index 0000000..77e6e2e --- /dev/null +++ b/docs/perspectives/round-13/06-noelrap.md @@ -0,0 +1,67 @@ +# Round 13 field notes — Noel Rappin counts it in cents + +*Built: `examples/money_discipline.rb` — one invoice priced two +ways: floats (the demo arithmetic) versus integer cents with a Money +value object, a named rounding policy, and a books-must-balance +rule. The contract can only be signed by one of them.* + +## What I built and why + +I wrote a whole book about payment code because every money bug in +production is the same three bugs wearing different tickets: floats +for currency, rounding decided by whoever's line got there first, +and totals that "should" add up instead of being *made* to. The +invoice here isn't contrived — API calls at $0.10, storage at +$29.99 — and the float column confesses immediately: + +``` + float version ledger version +subtotal 230.19999999999999 $230.20 +tax 18.99150000000000 $18.99 +total 249.19149999999999 $249.19 +``` + +That `...999999` tail is IEEE 754 paying out interest: 0.1 × 3 is +not 0.3 in binary and never was. Today it rounds to the right +penny; at some other quantity or tax rate it won't, and the +discrepancy will surface in a reconciliation report eleven months +from now, assigned to whoever touched the code last. Money bugs +are the *slowest* bugs — that's what makes them expensive. + +## The three sentences of discipline + +1. **Money is integer cents — and cents are a type.** The contract + declares `total_cents: {type: "integer"}`, which means the float + version *cannot sign it*. That's not pedantry; it's a tripwire. + A validator that accepts "number" would wave 249.19149999 through + and the lie becomes load-bearing; "integer" makes the type system + an accountant. +2. **Rounding is a named policy applied at declared points.** The + Money object rounds banker's-style (`half: :even`) at + multiplication — one place, one policy, greppable. The float + version rounds wherever printf happens to be standing, which + means the invoice PDF, the database row, and the payment gateway + can each round differently. Three documents, three totals, one + angry customer. +3. **The books balance by rule, not by hope.** The `adds_up` + structured rule — total equals subtotal plus tax, to the penny — + runs on every output. It sounds tautological until the day + separate rounding paths make it false, which is exactly the day + you want a ValidationError instead of a reconciliation project. + +## Notes + +- The Money struct is deliberately tiny — closed arithmetic (`+` + returns Money, `*` rounds by policy), one formatter. The moment + money leaks out as bare numerics, every call site re-decides the + three questions the value object exists to answer once. +- Real systems add currency codes and allocation (splitting $10 + three ways without losing a cent). Both slot into the same value + object; neither rescues you if the foundation is floats. + +## Verdict + +The same plan ran both arithmetics; only one could sign the +contract. Integer cents, named rounding, balance-by-rule — three +sentences of discipline, enforced by a schema instead of a code +review memory. Take my money — but count it in cents. diff --git a/docs/perspectives/round-13/07-tomstuart.md b/docs/perspectives/round-13/07-tomstuart.md new file mode 100644 index 0000000..c9d1f33 --- /dev/null +++ b/docs/perspectives/round-13/07-tomstuart.md @@ -0,0 +1,72 @@ +# Round 13 field notes — Tom Stuart enumerates the machine + +*Built: `examples/plans_as_automata.rb` — plans treated as transition +systems: the full state space enumerated by breadth-first search, +completion proved by exhaustion, and a cycle's pathology exhibited +as an empty machine.* + +## What I built and why + +Understanding Computation's whole method is: take a thing people +discuss with adjectives, and replace the adjectives with a small +machine you can run. Strip the agents and LLMs from a plan and +what remains is a transition system — a *state* is the set of +completed tasks; a *step* completes any task whose dependencies are +satisfied. That's the operational semantics, and it fits in one +method: + +```ruby +def steps(graph, done) + tasks.reject { done? } .select { deps.all? { done? } } +end +``` + +Everything else is just looking: + +``` +the diamond: 6 reachable states (of 16 conceivable) + 1 terminal state -> complete; 0 stuck +the cycle: 1 reachable state: {} - not one task can ever fire +``` + +## Exhaustion beats sampling, where it's affordable + +The claim "the diamond always completes" is usually an empirical +one — CI ran it, both orders happened at least once, ship it. The +enumeration makes it a *theorem about a finite object*: all six +reachable states, every scheduler choice included, converge on +{a,b,c,d}. Not "observed"; **total**, by exhaustion. Note also +what the state count itself says: 6 reachable of 16 conceivable +subsets — the dependency structure has already eliminated ten +states of nonsense, which is what structure *is*. + +The cycle is the same rigor pointed the other way. Its reachable +space is one state — the empty set — and that state is terminal: +no task can ever fire. This gives precise content to two earlier +rounds' intuitions: the scheduler's cycle handling ("appended after +the sorted portion") is a policy about tasks *outside the machine*, +and round 9's depth invariant excusing itself on cycles wasn't +squeamishness — there is no altitude in a building with no floors. + +And the honest boundary, stated in the output: at 40 tasks the +state space outgrows the universe. Enumeration is for small +machines; invariant provers (round 9's) are for big ones. The +mistake isn't choosing either tool — it's not knowing which regime +you're in. + +## Notes + +- `max choice: 2` — the widest state has two ready tasks, which is + the diamond's true parallelism ceiling, derived rather than + configured. Samuel's lane arithmetic and this number will always + agree; one comes from the machine, the other from the meter. +- The state space is a lattice (states ordered by inclusion, + converging at the top for DAGs). I resisted a digression into + confluence theory by a margin best described as narrow. + +## Verdict + +A plan is a small machine wearing a workflow costume. For small +machines, don't argue about behavior — enumerate it, and let +"always completes" mean what it says: every reachable path, checked, +because there are six of them and you have a computer. diff --git a/docs/perspectives/round-13/08-excid3.md b/docs/perspectives/round-13/08-excid3.md new file mode 100644 index 0000000..b3e42fa --- /dev/null +++ b/docs/perspectives/round-13/08-excid3.md @@ -0,0 +1,68 @@ +# Round 13 field notes — Chris Oliver ships the familiar costume + +*Built: `examples/job_adapter.rb` — an ActiveJob-shaped adapter: +`retry_on` maps to the retry policy, `discard_on` maps to the +hopeless convention, and the team's existing vocabulary just works.* + +## What I built and why + +After a decade of GoRails episodes, I can tell you the moment a +tutorial loses people: it's not the hard concept — it's the *third +new word*. A team adopting an agent framework already knows how they +talk about background work: `perform_later`, `retry_on`, +`discard_on`. The fastest adoption path isn't teaching them Agentic's +vocabulary; it's letting Agentic speak theirs: + +```ruby +class DigestJob < PlanJob + retry_on Agentic::Errors::LlmRateLimitError, attempts: 3 + discard_on Agentic::Errors::LlmAuthenticationError + def build_plan(orchestrator, user:) ... end +end +``` + +``` +DigestJob(user: rosa) -> {status: :ok} +DigestJob(user: sam, flaky: 2) -> {status: :ok} (healed in-plan) +DigestJob(user: kim, revoked: true) -> {status: :discarded} +``` + +## The mapping is the example + +Forty lines, because both vocabularies were already talking about +the same three ideas: try again, give up, or ask a human. + +- **`retry_on` → `retry_policy`**, with ActiveJob's accounting + preserved (`attempts: 3` means two retries — get this off-by-one + wrong and every runbook on the team silently lies). The payoff is + *where* the healing happens: sam's double-429 recovered inside the + plan, without bouncing off the queue and re-running the tasks that + already succeeded. Queue-level retry re-does work; plan-level + retry resumes it. +- **`discard_on` → failure type OR `hopeless?`**. The macro lists + what the team knows about; the round-10 nil convention backstops + what they forgot. Kim's revoked key discards even if nobody had + listed AuthenticationError, because the error's own testimony + outranks the allowlist. Belt from the team, suspenders from the + framework. +- **`perform_later` → an array**, because the example's queue isn't + the point — in your app it's Sidekiq or SolidQueue, and this class + slots in as an actual ActiveJob with `execute` called from + `perform`. + +## Notes + +- The adapter deliberately returns outcome hashes instead of raising + through the queue — real job backends interpret raises their own + way, and an adapter's job is to hand each backend the verdict in + the form it expects. +- What I'd teach in the follow-up episode: the journal as the job's + idempotency layer (descriptions are resume keys — reruns skip paid + work), which is the part ActiveJob never gave anyone. + +## Verdict + +Meet your team where they are; the framework doesn't mind the +costume. Three macros, forty lines, zero new vocabulary on day one — +and the good stuff (in-plan healing, testimony-backed discards, the +journal) arrives underneath the words they already knew. diff --git a/docs/perspectives/round-13/09-kaspth.md b/docs/perspectives/round-13/09-kaspth.md new file mode 100644 index 0000000..698bd5e --- /dev/null +++ b/docs/perspectives/round-13/09-kaspth.md @@ -0,0 +1,71 @@ +# Round 13 field notes — Kasper Timm Hansen riffs the knob + +*Built: `examples/api_riffs.rb` — three runnable API shapes for the +journal's group-commit knob (constructor kwarg, policy object, +per-call override), judged where users actually live: the call +site.* + +## What I built and why + +The way I design APIs on stream is embarrassingly simple: write the +call site three ways, read each out loud, and listen for the one +that sounds like what it does. The design work happens in the +*comparing*, not the committing — and the subject here is live, +because `fsync_every:` shipped this very round. So: the riffs that +could have been. + +```ruby +# riff 1 (shipped) +ExecutionJournal.new(path:, fsync_every: 20) +# riff 2 +ExecutionJournal.new(path:, durability: Durability.grouped(20)) +# riff 3 +journal.record(event, payload, durable: false) +``` + +**Riff 1** puts the trade at construction: visible, greppable in the +diff that chose it, and immutable — nobody weakens durability +mid-flight three files away. Cost: it's a magic integer ("20 of +*what*?" is a docs-lookup away). **Riff 2** reads like a sentence — +`Durability.strict` is self-documenting, and future policies +(flush-after-100ms) get names without new kwargs. Cost: a whole +constant wardrobe for what is, today, one integer. **Riff 3** is +maximal flexibility, and that's the indictment: durability becomes a +per-*call-site* opinion, so the invariant "this journal survives +crashes" stops being a property of the object and becomes a property +of every author's judgment, forever. Flexibility is where invariants +go to die. + +## The verdict, and the meta-verdict + +Shape 1 deserved to ship: a durability contract belongs to the +*object* (riff 3 dissolves it), and one integer hasn't yet earned a +policy wardrobe (riff 2 can arrive later, *wrapping* the kwarg, +the day time-based flushing becomes real — nothing about shipping +the kwarg forecloses the sentence-shaped API). + +The meta-verdict matters more than this knob: the exercise cost +forty lines and ten minutes, against the years a shipped API lives +and the majors it costs to change. Every riff here *executes* — +sketches that run tell you things sketches in a gist don't, like +riff 3's keyword colliding with the payload hash the moment a real +caller showed up (Ruby's kwargs had opinions; the call site always +knows more than the class definition). + +## Notes + +- Riffing needs a subject with real constraints — I riffed against + the actual journal, inherited its real signature, hit its real + argument-passing semantics. Riffs against imaginary classes only + produce imaginary confidence. +- The three shapes here are THE three shapes of most config + decisions: construction-time value, named policy, per-use + override. Learn to hear all three before believing the first. + +## Verdict + +Call sites read differently than class definitions, and the call +site is where your users live. Riff before you commit: three shapes, +ten minutes, read aloud — and the shipped kwarg now has a written +record of why it beat its rivals, which is more than most APIs can +say. diff --git a/docs/perspectives/round-13/10-steveklabnik.md b/docs/perspectives/round-13/10-steveklabnik.md new file mode 100644 index 0000000..5d4ddfa --- /dev/null +++ b/docs/perspectives/round-13/10-steveklabnik.md @@ -0,0 +1,70 @@ +# Round 13 field notes — Steve Klabnik puts the docs on trial + +*Built: `examples/doctest_runner.rb` — every `@example` block in lib/ +and every ```ruby fence in the README harvested and executed in +sandboxed subprocesses. 11 of 30 are alive.* + +## What I built and why + +The single biggest thing Rust's documentation culture got right +wasn't tone or completeness — it was that **examples in docs +execute**. `cargo test` runs every code block in every doc comment, +which means a Rust example cannot silently rot: the API changes, the +doctest goes red, someone fixes the letter before a reader ever +opens it. I've spent years telling other ecosystems this is +portable. So: harvest, sandbox, run, report. + +``` +30 documented examples put on trial +11 RUN +19 dead: undefined local variable, uninitialized constant, + missing config, drifted method names... +``` + +Eleven alive out of thirty. Every dead one is *a reader's first +attempt at this library, failing* — because docs written as +illustration were never promoted to execution. And note what kind +of failure each is: `undefined local variable` means the example +references setup it never shows (the reader can't run it either — +the doc is a fragment posing as a program); `undefined method +'comp...'` means the API *drifted* and the README kept teaching the +old world. The second kind is the killer. Nobody chose to lie; the +lie accreted. + +## The trial's fairness matters + +Each snippet runs in its own process with its own tmpdir and the +gem on the load path — dead examples must be dead on their *own* +merits, not because a neighbor polluted the interpreter. And the +verdict column preserves the first error line, because "dead" without +a cause is a complaint, while "dead: undefined local variable +`plan`" is a diff someone can write. + +Fairness also demands this caveat, stated plainly: some of the 19 +need credentials or network by nature (a real LLM config), and +"needs setup the fence doesn't show" is a different disease from +"teaches an API that no longer exists" — but both present +identically to a newcomer, which is rather the point. Rust's answer +was annotations (`no_run`, `ignore`) that keep even the non-runnable +examples *compiled*. That's the shape of the fix here too. + +Filed as the round-14 ask: promote the README's fences and lib's +@example blocks to runnable-or-annotated — every fence either +executes in CI via this runner, or carries an explicit +"illustrative" marker chosen by a human, on purpose. + +## Notes + +- The two learning-system examples dying with a LoadError is itself + a census finding — dead docs cluster around dead-ish corners of + code. Doc health is a proxy for code health more often than + either community admits. +- Documentation is a love letter to your future self — and love + letters are better when the address still exists. + +## Verdict + +Thirty letters, eleven deliverable. The runner turns docs rot from +a newcomer-facing ambush into a red build, which is the only place +rot ever gets fixed. Arrest the examples you have before writing +more. diff --git a/docs/perspectives/round-14/01-evanphx.md b/docs/perspectives/round-14/01-evanphx.md new file mode 100644 index 0000000..385dac9 --- /dev/null +++ b/docs/perspectives/round-14/01-evanphx.md @@ -0,0 +1,60 @@ +# Round 14 field notes — Evan Phoenix serves the plans + +*Built: `examples/plan_server.rb` — plan execution over a real +loopback socket: a thread pool, one shared mutexed quota, and the +part everyone skips — a graceful drain proven with a request in +flight.* + +## What I built and why + +Puma taught me that a server is three disciplines wearing one +process: accept concurrently, share safely, and — the one that +separates production servers from demo servers — **shut down well**. +Everyone benchmarks accept loops; nobody demos the drain. So the +drain is the demo: + +``` +burst of 8 concurrent requests, 3 workers: 8 of 8 answered +graceful drain with one request in flight: + in-flight request completed: "processed 7 words" + drain took 11ms; served 9; new connections: refused +``` + +The order of operations *is* the grace, and it's worth spelling out +because every wrong shutdown gets it backwards: **close the listener +first** — the OS starts refusing new connections for you, no accept +race, no half-open socket limbo — then let workers finish what they +hold, then join, then exit. `kill -9` has none of those steps, which +is why deploys under it drop the request that was 42 seconds into a +43-second plan, and why the 43-second plan's owner files the ticket. + +## A server calls every promise at once + +The quieter demonstration: the quota is **one `RateLimit` shared +across all request threads** — real threads, preemptive, the kind +that made Puma's early years educational. It holds because round 12 +gave the windowed bookkeeping a real Mutex. This is what I'd tell +that round's skeptics: a server is where every thread-safety promise +in your dependency tree gets called at once, on the same tick, by +someone else's traffic. Frameworks don't get to choose whether +they'll be used from threads; they only choose whether it'll go +well. + +Implementation notes with server-operator fingerprints: + +- Ephemeral port (`TCPServer.new("127.0.0.1", 0)`) so the example + never collides with anything — test servers that hardcode ports + are flaky tests on a delay timer. +- Quota exhaustion answers `{error:, retry_after:}` instead of + hanging — a server that queues unboundedly when over quota has + just moved the outage into its own socket backlog. +- `@in_flight` is tracked but the drain relies on `Thread#join`, not + on polling the counter — join is the primitive that can't miss. + +## Verdict + +Three workers, one shared limiter, eight bursty clients, and a +shutdown that finished the last request before it stopped being a +server. The plan framework slotted into the request path without +ceremony — and the drain took 11ms, which is 11ms more grace than +`kill -9` will ever have. diff --git a/docs/perspectives/round-14/02-indirect.md b/docs/perspectives/round-14/02-indirect.md new file mode 100644 index 0000000..475904a --- /dev/null +++ b/docs/perspectives/round-14/02-indirect.md @@ -0,0 +1,71 @@ +# Round 14 field notes — André Arko resolves the field nobody used + +*Built: `examples/capability_resolver.rb` — Bundler-style version +resolution over the `dependencies:` field capabilities have carried +since round 1, with backtracking, highest-compatible selection, and +a conflict error built like the product it is.* + +## What I built and why + +`CapabilitySpecification` has had a `dependencies:` array and a +`compatible_with?` method since the beginning, and in fourteen +rounds nothing has ever *resolved* them — they were load-bearing +decoration. Resolution is my whole career, so: + +``` +resolve report 2.0.0: + report 2.0.0 + summarize 2.0.0 + fetch 2.1.0 <- not 3.0.0 (newest), not 2.0.0 (requested) +``` + +That fetch line is Bundler's oldest rule in one row: +**highest-still-compatible**. Newest-available breaks majors; +exactly-requested strands you on patch zero forever. The resolver +itself is thirty lines — pick a candidate, recurse into its +dependencies, backtrack on conflict — because resolution really is +just search. (Until the index gets big and the constraints get +weird, at which point it's NP-complete and you write Molinillo. The +thirty lines are honest for this index size and I said so.) + +## The error is the product + +Ten years of Bundler issues taught me exactly one thing worth +tattooing: **when resolution fails, the error message is the +product.** The algorithm's job is to find an answer; the error's job +is to transfer *understanding of why there isn't one*: + +``` +CONFLICT: could not find compatible versions for capability 'fetch' + report (2.0.0) depends on + fetch (~ 2.x) + legacy_export (1.1.0) depends on + fetch (~ 1.x) +fetch cannot be both major-1 and major-2 in one plan. +consider: upgrading legacy_export, or running exports in a separate plan. +``` + +Both demand chains, named. The impossibility, stated in one +sentence. Two suggested moves, both real. A bare "version conflict" +costs your users an afternoon of spelunking; this costs them a +minute of choosing. The difference between those two error messages +is most of the issue tracker I've ever read. + +## Notes + +- `compatible_with?` (same major, minor >=) turns out to be a clean + constraint primitive — the resolver needed zero framework changes, + which is the recurring shape of this whole experiment: metadata + declared honestly keeps cashing checks nobody wrote. +- What a production version adds, in order of pain: version ranges + (not just floors), lockfile output (resolution you don't persist + is resolution you'll re-litigate), and conflict explanation for + *transitive* chains three levels deep — the place where showing + your work stops being optional. + +## Verdict + +The dependencies field finally does what its name promised, the +happy path picks the version a maintainer would want, and the sad +path explains itself like it respects your afternoon. Resolution is +search; the error message is the deliverable. diff --git a/docs/perspectives/round-14/03-soutaro.md b/docs/perspectives/round-14/03-soutaro.md new file mode 100644 index 0000000..970c3aa --- /dev/null +++ b/docs/perspectives/round-14/03-soutaro.md @@ -0,0 +1,70 @@ +# Round 14 field notes — Soutaro Matsumoto writes the types down + +*Built: `examples/rbs_export.rb` — RBS signatures generated from +capability contracts, with optionality projected as `?`-keys and an +agreement spot-check against the validator.* + +## What I built and why + +The hardest part of bringing types to Ruby was never the type +system — it was that the truth about types lives scattered in +runtime code, and asking people to write it down *twice* (once in +checks, once in sigs) guarantees the two copies diverge. But this +framework's capability contracts already ARE the truth, validated on +every call. RBS is that same knowledge written down for tools that +*read* instead of run: + +```rbs +class QuoteShippingCapability + def call: ({ mode: String, weight_kg: Numeric, + ?express: bool, ?customs_code: String } inputs) + -> { price_cents: Integer, carrier: String } +end +``` + +Generated, not written. Steep and an IDE can now check every caller +of this capability before anything executes — misspelled keys, +wrong types, forgotten requireds all become editor squiggles instead +of 422s. + +## Shape versus law + +The design decision worth recording is what the RBS does *not* +carry. `mode`'s enum, `weight_kg`'s bounds, the cross-field rules — +none project into the signature, and the generated comment says so: +**RBS carries the SHAPE, the validator carries the LAW.** This is a +principled line, not a limitation shrug: shape is what's decidable +statically (keys, types, optionality — note `required:` projecting +as the presence/absence of `?`, which is exactly RBS record +optionality semantics); law needs *values* to judge. A signature +that tried to encode `max: 5000` would be lying about what checkers +check. The two layers are projections of one declaration, which is +why they cannot drift the way hand-written sig files against +hand-written validations always, always do. + +And because two projections of one truth is exactly the situation +where round 10 taught this repo to demand proofs, the export +spot-checks itself: omit a `?`-marked key, the validator accepts; +omit an unmarked key, the validator rejects. Two points don't prove +the projection, but they pin its corners, and the exit code makes +the pin permanent. + +## Notes + +- `Array[untyped]` for array inputs is honest poverty: the contracts + don't declare element types yet. If they ever grow + `items: {type:}` (the round-11 Avdi note about list-shaped + inputs!), the generator upgrades to `Array[String]` in one line — + declarations compound again. +- Generated class-per-capability is a naming choice for the demo; + real integration would emit one .rbs file per registered + capability into sig/, run steep in CI, and let the type checker + meet the validator at the same source. + +## Verdict + +The contracts already knew their types; now they're written down +where tools can read them, with the shape/law boundary drawn on +purpose and spot-checked by exit code. Gradual typing works when +the types come from where the truth already lives — and here, it +already lived in the right place. diff --git a/docs/perspectives/round-14/04-eregon.md b/docs/perspectives/round-14/04-eregon.md new file mode 100644 index 0000000..03f632f --- /dev/null +++ b/docs/perspectives/round-14/04-eregon.md @@ -0,0 +1,70 @@ +# Round 14 field notes — Benoit Daloze writes down what it means + +*Built: `examples/behavior_spec.rb` — a compliance file in a 30-line +self-contained mspec: six boundary behaviors of the limiter, the +relations, and the journal, pinned as executable semantics.* + +## What I built and why + +I maintain ruby/spec, which exists because of one uncomfortable +discovery: "MRI does X" is not a specification — it's an +implementation detail wearing one. When TruffleRuby needed to know +what Ruby *means*, the answer couldn't be "read the C"; it had to be +executable, implementation-neutral, and phrased as behavior. Any +library that expects to be ported — to another VM, into a Ractor, to +another language — eventually needs the same document. So: + +``` +ok RateLimit: admits exactly ceiling acquisitions, then refuses +ok RateLimit: try_acquire without a block still consumes a slot +ok RateLimit: resize applies to the NEXT admission decision +ok RelationRules: presence means key-given-and-non-nil +ok RelationRules: sum_lte treats missing as zero, boundary closed +ok Journal: a later success erases an earlier failure +6 behaviors pinned, 0 drifted +``` + +Every pinned behavior is a *choice that could have gone the other +way* — the ceiling-th+1 call could queue instead of refuse; resize +could reset the window instead of counting old stamps against the +new ceiling; a nil trigger could engage `requires`. The +implementation chose; the spec is the choices, written down, so +"what the code happens to do" and "what the code means" stop being +the same sentence. + +## Why the harness is thirty lines of nothing + +The mspec here is deliberately dependency-free — describe/it/expect +in one module. This is ruby/spec's founding constraint transplanted: +**the spec must not depend on what it specifies** (or on tooling +that does). A compliance file that needs RSpec needs everything +RSpec needs, and now the port has to bootstrap a test framework +before it can check its first boundary. Thirty lines of harness is +the price of a spec that runs anywhere the subject might be +reimplemented, and it's the cheapest thirty lines in the file. + +The relationship to the existing suites, precisely: the RSpec suite +tests *this implementation* (internals, mocks, seams); Jeremy's +round-10 prober *attacks* this implementation (hostile inputs, +oracle checks). This file *specifies the contract* any +implementation must satisfy. Three documents, three audiences, and +the third one didn't exist until a porter needed it. + +## Notes + +- The journal behavior ("later success erases earlier failure") is + the one I most expected to be accidental rather than chosen — but + the dead-letter office and breaker were *built* on it in rounds + 8-9, so it's load-bearing semantics. Now it's load-bearing AND + written down, which are different states. +- What I'd pin next: fiber-vs-thread guarantees per method — which + operations are reactor-safe, which are thread-safe, which are + both. The threads drill measures it; a spec would *promise* it. + +## Verdict + +Six boundary choices promoted from behavior to specification, in a +harness that depends on nothing it specifies. When someone ports +this limiter to a place its authors never imagined — and someone +always does — this file is the difference between a port and a +guess. diff --git a/docs/perspectives/round-14/05-yuki24.md b/docs/perspectives/round-14/05-yuki24.md new file mode 100644 index 0000000..6cd52bb --- /dev/null +++ b/docs/perspectives/round-14/05-yuki24.md @@ -0,0 +1,73 @@ +# Round 14 field notes — Yuki Nishijima finishes your sentence + +*Built: `examples/did_you_mean.rb` — a 40-line Levenshtein engine +retrofitted onto three error seams: capability lookups, contract +violations, and rewire targets. Every suggestion computed, none +hardcoded.* + +## What I built and why + +did_you_mean started from one observation: at the moment a +NameError is raised, the VM is *holding the list of every valid +name in scope* — and throwing it away. The error knew the answer and +chose to print only the question. Adding a Levenshtein pass at that +exact moment turned a stack trace into a one-keystroke fix, and it +became part of Ruby itself because kindness, it turns out, is +portable. + +The same observation holds at every layer above the VM: + +``` +get_provider("sumarize_ticket") -> nil + with suggestions: unknown capability. Did you mean? summarize_ticket + +weight_kg: is missing (you sent :weight_kilo) + with suggestions: Did you mean? weight_kg + +cannot wire to unknown task(s) fetch_order + with suggestions: Did you mean? fetch_orders +``` + +Three seams, one pattern: the registry knows its capabilities, the +contract knows its declared fields, the plan knows its tasks — each +error is raised while the framework is *holding the candidate list*. +The suggestion engine is generic (edit distance plus a threshold +that scales with word length, clamped so short words don't match +everything); only the candidate list changes per seam. + +## The contract case is the sneaky-valuable one + +Scene 2 deserves the spotlight: the validator today says +`weight_kg: is missing`, which is true and unhelpful — the user +*sent* the weight, as `:weight_kilo`. The kind error cross-references +the *extra* keys against the *missing* ones: "you sent :weight_kilo +— did you mean :weight_kg?" That's not a suggestion, that's a +diagnosis. Missing-plus-similar-extra is the signature of a typo, +and contracts see typos all day, from humans and — increasingly — +from LLMs whose outputs feed the very validators this framework +runs. A model that gets `weight_kilo` corrected in the violation +message (via round 12's self-correcting loop!) fixes itself in one +round trip instead of guessing. + +Filed as the round-15 ask: thread suggestions into ValidationError +(unknown-key hints when a sent key is close to a declared one) and +into the rewire/remove errors. The engine is forty lines; the +candidate lists are already in scope at every raise site. + +## Notes + +- The threshold matters more than the metric: unbounded + nearest-match "suggests" something for every garbage string, which + is worse than silence — a wrong suggestion sends someone down a + confident dead end. Match within a length-scaled budget or say + "(no close match; valid: ...)" and list the vocabulary. +- I named the module DidYouMean2 to avoid colliding with the real + one, which ships in every Ruby since 2.3 — the flattery of + namespacing. + +## Verdict + +The error always knew the answer; it just wasn't telling. Three +seams now finish your sentence, the engine is generic, and the ask +is filed to make kindness a framework property instead of an +example. Every typo is a question the candidate list can answer. diff --git a/docs/perspectives/round-14/06-samsaffron.md b/docs/perspectives/round-14/06-samsaffron.md new file mode 100644 index 0000000..536fa4a --- /dev/null +++ b/docs/perspectives/round-14/06-samsaffron.md @@ -0,0 +1,66 @@ +# Round 14 field notes — Sam Saffron leaves the profiler on + +*Built: `examples/always_on_profiler.rb` — a badge line on every +plan, latency budgets that name the offender, and an overhead audit +proving always-on costs ~144 microseconds.* + +## What I built and why + +rack-mini-profiler's founding heresy was that profiling belongs in +**production**, on **every request**, visible to the **person who +wrote the slow code** — not in a lab you visit twice a year with a +flamegraph and a prayer. The lab model finds regressions you already +shipped; the badge model finds them in the PR preview, because the +person who made it slow *watches the badge go red before merging*. +Plans deserve the same heresy: + +``` +[prof] completed 67ms 3 tasks top: rank (30ms) within budget +[prof] completed 141ms 3 tasks top: summarize (95ms) OVER BUDGET (120ms) + <- fix summarize first +[prof] completed 5ms 1 task top: check (5ms) within budget +``` + +Three rules made mini-profiler work at Discourse scale, and all +three transplant: + +1. **Always on.** Sampling is for whales; a plan runs dozens of + times a day, not millions, so you can afford to measure + everything. No "enable profiling" flag that nobody flips until + the incident. +2. **Visible to the author.** A badge in the output the developer + already reads — not a Grafana dashboard nobody opens until + paged. Proximity is the whole psychology: the feedback loop has + to be shorter than the attention span. +3. **Budgets with a named offender.** "Over budget, fix summarize + first" is an *assignment*; a p95 chart is a vibe. The badge + doesn't just say slow — it says where, because the hooks already + carry per-task durations and the max_by is free. + +## The overhead audit is the license + +Always-on is only defensible when it's near-free, so the example +audits itself: 30 plans with hooks, 30 without — **144 microseconds +per plan**. That number is the entire argument. When someone asks +"won't the profiler slow us down?", the answer isn't a philosophy, +it's a measurement the profiler itself produced. (byroot's rule +from round 12 applies to profilers too: weigh the layer before +having opinions about it.) + +## Notes + +- The badge prints from the `plan_completed` hook and clears its + buffer — per-plan state, no accumulation, safe for the always-on + lifetime. Profilers that leak are how "always on" gets turned off. +- What I'd add at Discourse scale: the badge writes to the journal + too (one line per plan), so palkan's round-12 group profiler gets + its raw material for free and the "who regressed last Tuesday" + question meets amatsuda's tail pager. The observability tools in + this repo keep composing because they all drink from the hooks. + +## Verdict + +Every plan now wears its cost on its sleeve, over-budget plans name +their own fix, and the whole apparatus costs 144 microseconds — +cheap enough to never turn off, which is the only kind of profiling +that catches regressions before users do. diff --git a/docs/perspectives/round-14/07-rtomayko.md b/docs/perspectives/round-14/07-rtomayko.md new file mode 100644 index 0000000..2a89171 --- /dev/null +++ b/docs/perspectives/round-14/07-rtomayko.md @@ -0,0 +1,63 @@ +# Round 14 field notes — Ryan Tomayko spells it fork, pipe, kill, wait + +*Built: `examples/unix_workers.rb` — a master preforks three plan +workers, work arrives on a shared pipe, SIGTERM means finish-then- +die-with-dignity, and every child is reaped by pid and exit status. +9/9 jobs served, 3/3/3.* + +## What I built and why + +I like Unix because the operating system already solved process +supervision, isolation, and work distribution — and nobody told the +frameworks. Every worker-pool gem is a reimplementation of fork(2) +with more YAML. So the example uses the originals: + +``` +UNIX WORKERS (master 12200, 3 preforked children) +deploy signal: SIGTERM to all workers +the reaping: + pid 12204 exit 0 served 3 job(s) + pid 12207 exit 0 served 3 job(s) + pid 12210 exit 0 served 3 job(s) +total served: 9/9 +``` + +Count what's *not* here: no supervisor gem, no heartbeat table, no +distributed lock. **fork** gives isolation — a worker segfault kills +one plan, not the fleet. **The shared pipe** is a work queue because +Unix says it's a queue. **TERM-then-wait2** is the deploy: workers +trap TERM as "finish what you hold," the master reaps each child by +pid *and exit status*, and unserved jobs stay in the pipe for the +next fleet. Every piece has a man page older than most gems' +maintainers. + +## Two honest lessons from the drill itself + +First, the example's own first run died with `undefined method +'tmpdir'` — I used `Dir.tmpdir` without requiring "tmpdir", in the +same repo where hsbt's census made that exact sermon last round. +Require what you use; the preacher is not exempt. + +Second, the burst-fed pipe taught the fairness lesson live: write +all nine jobs at once and the first reader drains everything (9/0/0) +— a pipe is a queue but not a *fair* one, because IO buffering lets +one process slurp ahead. Pacing arrivals like real work actually +arrives got the fleet lifting together (3/3/3). This is the same +reason unicorn balances on `accept` rather than on reads from a +shared stream: you want the kernel arbitrating *admission*, not +buffering. The example keeps the pipe (right size for the demo) and +documents the limit, which is the Unix way — know exactly what your +primitive promises. + +The framework's contribution slots in exactly where it should: +each worker owns a journal (flock'd — the process drill certified +that across forks), group-committed for throughput, synced before +exit. Per-process durability with kernel-arbitrated files: the 1970s +and round 13, interoperating cleanly. + +## Verdict + +Three processes, four syscalls, one signal, zero dependencies — +deploys that finish in-flight work and a reaping that accounts for +every child. The operating system is the best framework you already +have; its DSL is just spelled fork, pipe, kill, and wait. diff --git a/docs/perspectives/round-14/08-marcandre.md b/docs/perspectives/round-14/08-marcandre.md new file mode 100644 index 0000000..971e189 --- /dev/null +++ b/docs/perspectives/round-14/08-marcandre.md @@ -0,0 +1,63 @@ +# Round 14 field notes — Marc-André Lafortune audits the freeze + +*Built: `examples/ractor_shareability.rb` — every interesting +framework value judged by `Ractor.shareable?`, the strictest freeze +referee Ruby ships, with a proof-of-travel through a real Ractor.* + +## What I built and why + +I've spent years on frozen things — frozen string literals, freezing +core classes, the RuboCop cops that nag about both — and the lesson +that unifies them: `freeze` is a promise about *one object*, while +immutability people actually want is a promise about *everything it +reaches*. Ruby has exactly one honest arbiter of the second promise, +and it's not a style guide — it's `Ractor.shareable?`: + +``` +value frozen? shareable? after make_shareable +graph snapshot true false a deep-frozen copy crosses +graph[:order] true true (already crosses) +graph[:stats] true true (already crosses) +to_json_schema output false false a deep-frozen copy crosses +a Task object false false a deep-frozen copy crosses +a RateLimit false false REFUSED: holds live machinery +``` + +The graph snapshot is the teaching row: it says `frozen? == true` +and the referee says *not shareable* — a top-floor promise on a +building with unlocked doors below, because the frozen hash reaches +unfrozen Task objects. That's not a bug in the graph (its contract +is "don't mutate the snapshot," which shallow-freeze delivers); it's +the *vocabulary distinction* this audit exists to make. Meanwhile +`order` and `stats` are data all the way down and cross a Ractor +boundary as-is — the round-8 stats work quietly produced +Ractor-ready values before Ractors were anyone's requirement. + +## The refusal is the best row + +The `RateLimit` cannot be made shareable at any price: it holds a +real Mutex, and no amount of freezing turns a lock into a value. +That refusal is *correct* and clarifying — the limiter is a machine, +not a fact, and the Ractor design pattern is one line: **send facts, +keep machines.** What crosses is testimony (ids, stats, schemas); +what stays is machinery (limiters, orchestrators, semaphores). A +framework whose facts and machines separate this cleanly under the +strictest referee available is a framework whose layering was honest +all along. + +Confession, preserved in the example's comments: my first draft +deep-froze the system under audit — `make_shareable` on the +snapshot froze the *real* tasks through the shared references, and +every subsequent row reported contaminated verdicts. The fix (judge +Marshal copies; mutate nothing you're measuring) is the fix for all +instrumentation, everywhere: **referees must not tamper with the +evidence.** Seventh consecutive round of a tool correcting its +author; the streak is the methodology now. + +## Verdict + +Frozen and shareable are different promises, and now each framework +value knows which one it makes. Facts cross, machines stay, the one +refusal is load-bearing, and the auditor learned on camera not to +freeze the evidence. `Ractor.shareable?` — use it as the linter it +secretly is. diff --git a/docs/perspectives/round-14/09-rosa.md b/docs/perspectives/round-14/09-rosa.md new file mode 100644 index 0000000..0a62551 --- /dev/null +++ b/docs/perspectives/round-14/09-rosa.md @@ -0,0 +1,67 @@ +# Round 14 field notes — Rosa Gutiérrez keys the concurrency + +*Built: `examples/concurrency_key.rb` — SolidQueue's +concurrency-key idea over the framework's limiters: at most one +sync per tenant, tenants in parallel, and both overflow postures +(block vs skip) named at the call site.* + +## What I built and why + +Building SolidQueue taught me that the concurrency control teams +actually need is almost never "at most N jobs total" — it's **"at +most one per THIS thing."** One sync per tenant. One import per +account. Global limits are too blunt (one tenant's backlog throttles +everyone) and no limits are too sharp (two syncs for the same tenant +race each other's writes and the incident report blames "load"). +The middle is a key: + +``` +six concurrent requests (3 per tenant): + acme runs overlapping each other: 0 (must be 0) + globex runs overlapping each other: 0 (must be 0) + cross-tenant overlaps: 5 (parallelism preserved) +``` + +The judged interleaving is the point of the demo — not that it +"works" but that both halves of the promise are *measured*: +serialization within a key, preserved parallelism across keys. A +keyed limiter that accidentally serializes everything passes the +first check and fails the second, and nobody notices until +throughput dies. + +## Two postures, named at the call site + +Overflow policy is where keyed concurrency implementations differ, +and SolidQueue's lesson is that the policy must be **explicit**: + +- `serialized(key)` — every request eventually runs, in order, + alone. For backfills, where each request carries distinct work. +- `skip_if_running(key)` — running-now is proof enough. For crons: + a second sync would do the same work twice, so the cron firing + during a sync gets `:skipped`, not queued. (Round 11's + `try_acquire` is exactly this posture's primitive — a budget wants + to say no, and so does a cron guard.) + +The registry detail that earns its comment: `limit(key)` mints the +per-key limiter **once, under a lock** — two fibers discovering +tenant "initech" simultaneously must agree on THE mutex, not each +mint a rival. A concurrency-key registry with a race in its own +lookup is a very quiet way to have no concurrency keys at all. + +## Notes + +- Global limits ration *capacity*; keyed limits enforce + *correctness*. Compose them (round 9's `#and`) and you get both: + `keys.limit("sync/#{tenant}").and(global_pool)`. +- What production adds: key expiry (tenants churn; the registry + grows forever as written) and cross-process keys (this registry is + per-process — the SolidQueue version lives in the database + precisely because your workers don't share a heap). + +## Verdict + +At most one per tenant, all tenants at once, overflow policy chosen +by name instead of by accident — and the interleaving judged, not +assumed. Most incidents blamed on load are two workers holding the +same tenant; the key is the fix, and now it's forty lines anyone +can read. diff --git a/docs/perspectives/round-14/10-janko.md b/docs/perspectives/round-14/10-janko.md new file mode 100644 index 0000000..8692b34 --- /dev/null +++ b/docs/perspectives/round-14/10-janko.md @@ -0,0 +1,69 @@ +# Round 14 field notes — Janko Marohnić promotes carefully + +*Built: `examples/attachment_pipeline.rb` — Shrine's cache/promote +two-phase pattern as a journaled plan: a crash mid-derivatives +resumes at the exact thumbnail it died on, and a double-submit +re-derives nothing.* + +## What I built and why + +Shrine exists because file uploads look like a form field and are +actually a distributed transaction. Users double-submit. Workers +get OOM-killed mid-thumbnail. Retries arrive from two systems at +once. Every "corrupted avatar" bug report traces back to someone +treating upload as one synchronous step. The pattern that survives +production has two phases with opposite virtues: + +``` +phase 1 (request): cached team-photo.jpg - 0ms of processing +phase 2, attempt 1: crashed at derive:web:1200 + journal holds 2 paid derivatives + record NOT promoted; cache still serves the user +phase 2, attempt 2: only 1 derivative scheduled - paid ones skipped +phase 2, attempt 3: 0 scheduled - idempotent all the way down +``` + +**Cache** is instant and disposable — the user's file is safe the +moment the request returns, no processing in the request cycle, +ever. **Promotion** is slow, background, and must be idempotent — +and this is where the framework earned its invitation: a journaled +plan whose derivative names (`derive:thumb:200`) are idempotency +keys is *exactly* the machine promotion needs. The crash resumed at +the exact derivative it died on; the retry re-paid for nothing; the +third, double-submitted attempt scheduled zero work. + +## The record is the second phase + +The subtle ordering that separates this from most home-grown +uploaders: `promote:record` — the step that flips the database +record to the permanent store and clears the cache — depends on +*all* derivatives. The record commits only after every thumbnail +exists. Get this backwards (promote first, derive later) and there's +a window where the record points at derivatives that don't exist +yet, which users experience as broken images and engineers +experience as "it's fine on my machine, the derivatives caught up." +Two-phase commit isn't jargon here; it's the difference between an +upload system and an upload demo. + +Note also what failure looked like to the *user* at every step: +after the crash, the cache still served the original — a photo, not +an error. Failure isolation in upload pipelines is a UX feature +wearing an architecture costume. + +## Notes + +- Derivative-name-as-idempotency-key inherits everything the journal + learned in fourteen rounds: fsync'd receipts (round 1), tolerant + replay (round 13), per-upload journal files (Eileen's per-shard + isolation, at upload granularity). +- What real Shrine adds on top: metadata extraction as its own + cached-phase step, storage abstraction (S3 vs disk behind one + interface), and background *deletion* — which needs the same + idempotency discipline everyone forgets until the double-delete. + +## Verdict + +Uploads are a two-phase commit wearing a file input. Cache +instantly, promote through a journaled plan, let derivative names +be receipts — and crashes become resumes, retries become no-ops, +and the anxious double-click becomes exactly nothing at all. diff --git a/examples/README.md b/examples/README.md index 31439c5..32ef18d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -8,19 +8,26 @@ not this file. |---------|---------------| | `adaptive_throttle.rb` | The Adaptive Throttle: nobody TELLS you an upstream's capacity - you discover it. An AIMD controller (TCP's algorithm) p... | | `allocation_audit.rb` | The Allocation Audit: every object is a promissory note the GC collects on later. This audit counts exactly what each fr... | +| `always_on_profiler.rb` | The Always-On Profiler: the mini-profiler heresy is that profiling belongs in PRODUCTION, on EVERY request, visible to t... | | `api_reference.rb` | The API Reference Generator: walk the registry, emit reference docs for every capability - types, enums, bounds, policie... | +| `api_riffs.rb` | API Riffs: before an API ships, sketch it three ways and READ the call sites out loud - the design work happens in the c... | | `api_surface.rb` | The API Surface Census: your public API is not what you documented - it's every public method a user CAN call, because t... | +| `attachment_pipeline.rb` | The Attachment Pipeline: Shrine's central lesson is that file uploads are a TWO-PHASE commit wearing a file input - phas... | | `backoff_conformance.rb` | Backoff Conformance: every strategy x jitter combination, a thousand draws each through an injected seeded RNG, checked ... | | `batch_import.rb` | The Batch Import: 500 rows of the kind of data people actually upload - typos, header drift, impossible combinations - r... | +| `behavior_spec.rb` | The Behavior Spec: ruby/spec exists because "MRI does X" is not a specification - it's an implementation detail wearing ... | | `burst_absorber.rb` | The Burst Absorber: three waves of requests slam a credential with a ceiling of 3 (Agentic::RateLimit - this round's rel... | | `cancel_drill.rb` | The Cancel Drill: structured concurrency's core promise is that cancellation is PROMPT - stop means stop, not "finish ev... | | `capability_evals.rb` | Capability Evals: golden test cases run against registered capabilities, scored, and gated. When you swap a lambda for a... | +| `capability_resolver.rb` | The Capability Resolver: CapabilitySpecification has carried a dependencies: field since round 1, and nothing has ever r... | | `capacity_planner.rb` | The Capacity Planner: "how many workers do we need?" is not a feeling, it's Little's Law - L = lambda x W. The journal a... | | `changelog_scout.rb` | The Changelog Scout: reads real git history, classifies every commit through a contract-checked capability, and drafts t... | | `circuit_breaker.rb` | The Circuit Breaker: when an upstream is down, the cheapest request is the one you don't send. The breaker trips after 3... | +| `cli_contract.rb` | The CLI Contract: a command-line tool is an API whose clients are shell scripts, cron, CI, and a tired human at 2am - an... | | `collaboration_tracer.rb` | The Collaboration Tracer: lifecycle hooks record every message the orchestrator sends and every reply that comes back, t... | | `command_bus.rb` | The Command Bus: every command is a composed capability with its OWN declared contract (new in this round - compositions... | | `composed_limits.rb` | Composed Limits: a real provider enforces BOTH a billed quota and a connection ceiling. quota.and(pool) - new this round... | +| `concurrency_key.rb` | The Concurrency Key: "at most one sync per TENANT, any number of tenants at once" is the concurrency control every multi... | | `confident_pipeline.rb` | The Confident Pipeline: timid code checks nil at every step because it trusts nothing, including itself. Confident code ... | | `contract_cop.rb` | The Contract Cop: RuboCop for capability specs. Contracts are the most-read documents in this framework - six tools cons... | | `contract_fixtures.rb` | Contract Fixtures: example payloads in docs rot the day the contract changes. So don't write them - DERIVE them. This ge... | @@ -32,7 +39,9 @@ not this file. | `critical_path.rb` | The Critical Path: after a run, combine the graph topology with measured durations to find the chain of tasks that deter... | | `dead_letter_office.rb` | The Dead Letter Office: three days of journaled runs, every failure collected and triaged by what the errors said about ... | | `deploy_train.rb` | The Deploy Train: lint -> test -> build -> canary -> ship, where a red gate stops the train and everything behind it rep... | +| `did_you_mean.rb` | Did You Mean, for plans: the kindest thing an error can do is finish your sentence. In round 14 this example retrofitted... | | `doc_coverage.rb` | The Documentation Surveyor: measures YARD comment coverage for every public method in a lib/ tree. One survey task per f... | +| `doctest_runner.rb` | The Doctest Runner: Rust taught the industry one enormous docs lesson - EXAMPLES IN DOCS SHOULD EXECUTE. This harvests e... | | `duck_agents.rb` | Duck Agents: the agent: seam asks one question - "can you be called with a task?" - and five differently-shaped objects ... | | `dungeon_crawl.rb` | The Dungeon Crawl: a quest is a plan, rooms are tasks, and doors are dependencies. The map is drawn from the orchestrato... | | `durable_batch.rb` | The Durable Batch: six billable "LLM calls" run under an ExecutionJournal. Mid-batch, the process dies for real - exit!,... | @@ -42,6 +51,7 @@ not this file. | `exquisite_corpse.rb` | The Exquisite Corpse: three artists each draw one part of a creature without seeing the others' work; the assembler rece... | | `failure_weather.rb` | The Failure Weather Report: a journal of three days, read as a forecast. Retryable failures are WEATHER - showers that p... | | `fair_share.rb` | Fair Share: two tenants, one upstream. The global ceiling is fair to REQUESTS - first come, first served - but tenant A ... | +| `feature_flags.rb` | Feature Flags for Plans: shipping a new pipeline step shouldn't be a deploy decision - it should be a FLAG decision. A t... | | `flaky_api_drill.rb` | The Flaky API Drill: a task that times out twice before succeeding, run under a retry policy with exponential backoff an... | | `form_errors.rb` | The 422 Generator: turn a ValidationError into the API error document your frontend actually wants - message, allowed va... | | `freight_rules.rb` | The Freight Desk: a quoting capability whose tariff book is written as cross-field contract rules (new this round). Per-... | @@ -59,12 +69,15 @@ not this file. | `incident_report.rb` | The Incident Report: a nightly batch dies at 3am. The on-call's first three questions - what ran? what broke? what do I ... | | `invariant_sentinel.rb` | The Invariant Sentinel: domain invariants checked after EVERY task, from a lifecycle hook. When a task leaves the world ... | | `jitter_shootout.rb` | The Jitter Shootout: none vs equal (+/-25%, the default) vs full (uniform over [0, delay], new this round) - same forty ... | +| `job_adapter.rb` | The Job Adapter: your Rails app already has a vocabulary for background work - perform_later, retry_on, discard_on - and... | | `journal_audit.rb` | The Journal Audit: seven tools now trust the journal, so the journal itself gets audited - well-formed lines, monotonic ... | +| `journal_tail.rb` | The Journal Tail Pager: production journals grow like production tables, and the question asked of both is always the sa... | | `json_schema_export.rb` | The Schema Export: a capability contract emitted as draft-07 JSON Schema (new this round), then PROVEN faithful - the sa... | | `kanban_board.rb` | The Kanban Board: a plan rendered as the three columns everyone actually understands - To Do, Doing, Done - reprinted at... | | `knee_finder.rb` | The Knee Finder: runs the same plan at increasing concurrency limits, measures wall time and total queue-wait via the ta... | | `latency_lab.rb` | The Latency Lab: 20 simulated LLM calls (200ms of IO each) executed through the orchestrator at different concurrency li... | | `live_dashboard.rb` | The Live Dashboard: lifecycle hooks publish events onto an Async::Queue; a consumer task IN THE SAME REACTOR renders the... | +| `money_discipline.rb` | Money Discipline: every money bug in production is the same three bugs - floats for currency, arithmetic before validati... | | `namespace_cartographer.rb` | The Namespace Cartographer: maps a gem's constant tree and audits every file against the constant Zeitwerk expects it to... | | `onboarding_trail.rb` | The Onboarding Trail: a codebase is a place people live, and new teammates don't need a map of every pipe - they need a ... | | `one_file_api.rb` | The One-File API: an endpoint is a contract wearing HTTP. Declare the capability once and the rest is derived - the 422s... | @@ -80,13 +93,17 @@ not this file. | `plan_kata.rb` | The Plan Kata: red, green, refactor - for a plan. The "tests" are assertions about the graph (one root, one leaf, labele... | | `plan_merge.rb` | The Plan Merge: base, ours, theirs - a three-way merge of plan wire formats. Independent changes combine; the same edge ... | | `plan_roundtrip.rb` | The Round Trip: serialize a plan's graph to JSON, rebuild a fresh orchestrator from the JSON, and prove the rebuilt topo... | +| `plan_server.rb` | The Plan Server: a server is three disciplines wearing one process - accept concurrently, share resources safely, and ab... | | `plan_structural_diff.rb` | The Structural Diff: two versions of a plan's wire format, diffed as TOPOLOGY - tasks added and removed, edges rewired, ... | | `plan_tour.rb` | The Plan Tour: hand any orchestrator to the guide and it narrates the plan as prose - first this, then that, meanwhile t... | +| `plans_as_automata.rb` | Plans as Automata: strip away the agents and the LLMs and a plan is a transition system - states are sets of completed t... | | `polite_form.rb` | The Polite Form: a contract usually speaks AFTER you fail - a 422, a stack of violations. This assistant makes it speak ... | | `ports_and_adapters.rb` | Ports and Adapters: the domain is the part of your app that would survive a framework migration - IF you kept it clean. ... | | `process_drill.rb` | The Process Drill: threads share a Mutex; PROCESSES share nothing but the file. The journal claims flock+fsync, which is... | | `projection_agreement.rb` | The Projection Agreement Prover: relation rules now render twice - the validator enforces them in Ruby, and to_json_sche... | | `quota_keeper.rb` | The Quota Keeper: the same 20 requests through two different laws. A concurrency ceiling ("3 in flight") models connecti... | +| `ractor_shareability.rb` | The Ractor Shareability Audit: `freeze` is a promise about one object; Ractor.shareable? is a promise about everything i... | +| `rbs_export.rb` | The RBS Export: a capability contract already knows its types - it validates them at runtime on every call. RBS is the s... | | `readme_verifier.rb` | The README Verifier: every ruby code fence in the README is a promise. This extracts them all, syntax-checks each with P... | | `refactor_receipts.rb` | Refactor Receipts: the god-join plan from the graph critic, improved in two small steps - with a receipt after each one.... | | `refactoring_dojo.rb` | The Refactoring Dojo: a student submits a method, three critic agents review it from three distinct perspectives, and th... | @@ -105,6 +122,7 @@ not this file. | `stampede_sim.rb` | The Stampede Simulator: twenty workers hit a hiccuping upstream, all fail at once, all retry. With jitter OFF they come ... | | `standup_digest.rb` | The Standup Digest: three collectors gather from the repo in parallel - recent commits, TODO debt, test suite shape - an... | | `state_machine.rb` | The Contract State Machine: each transition is a capability whose guard is not an if-statement but an enum predicate on ... | +| `stdlib_census.rb` | The Stdlib Census: "it's in the standard library" is a statement with a shelf life. Default gems become bundled gems on ... | | `telemetry_bus.rb` | The Telemetry Bus: lifecycle hooks are callbacks - one producer, one consumer, coupled at configuration time. A telemetr... | | `telephone_game.rb` | The telephone game: a rumor passes through five villagers, each of whom hears the previous version through the orchestra... | | `tenant_shards.rb` | Tenant Shards: at scale, "the plan" becomes "the plan, per shard" - same pipeline, isolated blast radius. Each shard get... | @@ -113,7 +131,9 @@ not this file. | `throughput_knee.rb` | The Throughput Knee: sweep one limiter's ceiling from 1 to 8 against an upstream that quietly serializes above 4, and me... | | `ticket_screener.rb` | A HEY-style ticket screener: every inbound support ticket flows through screen -> categorize -> draft, all tickets in pa... | | `traffic_dial.rb` | The Traffic Dial: a canary rollout as one knob. New code starts at one lane of traffic; every healthy stage turns the di... | +| `tty_status.rb` | The TTY Status Board: terminal output is a UI, and UIs are built from COMPONENTS - a tree for structure, gauges for prog... | | `typed_pipeline.rb` | A typed ETL pipeline: extract -> transform -> load, each stage a capability with a declared contract, composed into one ... | +| `unix_workers.rb` | Unix Workers: I like Unix because the operating system already solved process supervision and nobody told the frameworks... | | `variance_detective.rb` | The Variance Detective: ten journaled runs of the same plan, then a hunt for the task whose p90/p50 ratio betrays it. Av... | | `weekly_checkin.rb` | The Weekly Check-in: "what did you work on this week?" answered by the journal instead of by memory. Runs a few days of ... | | `write_path_profile.rb` | The Write Path Profile: everyone's first instinct about a slow journal is "switch JSON libraries". Before holding that o... | diff --git a/examples/always_on_profiler.rb b/examples/always_on_profiler.rb new file mode 100644 index 0000000..50623d8 --- /dev/null +++ b/examples/always_on_profiler.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +# The Always-On Profiler: the mini-profiler heresy is that profiling +# belongs in PRODUCTION, on EVERY request, visible to the people who +# wrote the slow code - not in a lab you visit twice a year. Every +# plan gets a badge line; plans over their latency budget get named, +# with the top offender attached; and the profiler measures its own +# overhead, because always-on is only defensible when it's near-free. +# +# bundle exec ruby examples/always_on_profiler.rb +# +# Runs offline; three plans run, one blows its budget. + +require_relative "../lib/agentic" + +Agentic.logger.level = :fatal + +# The whole profiler: hooks in, one badge line out per plan +class AlwaysOn + def initialize(budget_ms:) + @budget_ms = budget_ms + @timings = [] + end + + def hooks + { + after_task_success: ->(task_id:, task:, result:, duration:) { + @timings << [task.description, duration * 1000] + }, + plan_completed: ->(plan_id:, status:, execution_time:, tasks:, results:) { + badge(plan_id, status, execution_time * 1000) + @timings.clear + } + } + end + + def badge(plan_id, status, total_ms) + top = @timings.max_by(&:last) + line = format("[prof] %-10s %5.0fms %d tasks top: %s (%.0fms)", + status, total_ms, @timings.size, top[0], top[1]) + if total_ms > @budget_ms + puts " #{line} OVER BUDGET (#{@budget_ms}ms) <- fix #{top[0]} first" + else + puts " #{line} within budget" + end + end +end + +def run_plan(name, workloads, hooks: {}) + orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 2, lifecycle_hooks: hooks) + previous = nil + workloads.each do |task_name, ms| + task = Agentic::Task.new(description: task_name, agent_spec: {"name" => task_name, "instructions" => "w"}) + orchestrator.add_task(task, previous ? [previous] : [], agent: ->(_t) { + sleep(ms / 1000.0) + :ok + }) + previous = task + end + orchestrator.execute_plan +end + +puts "THE ALWAYS-ON PROFILER (a badge on every plan, budgets with teeth)" +puts +profiler = AlwaysOn.new(budget_ms: 120) +run_plan("morning digest", {"fetch" => 20, "rank" => 30, "render" => 15}, hooks: profiler.hooks) +run_plan("weekly report", {"gather" => 25, "summarize" => 95, "publish" => 20}, hooks: profiler.hooks) +run_plan("tiny ping", {"check" => 5}, hooks: profiler.hooks) +puts + +# --- the overhead audit: always-on must be near-free ----------------------------- +runs = 30 +bare = ->(hooks) { + t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + runs.times { run_plan("bench", {"a" => 1, "b" => 1}, hooks: hooks) } + (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) / runs * 1000 +} +silent = Class.new(AlwaysOn) { + def badge(*) + end +}.new(budget_ms: 999) +without = bare.call({}) +with = bare.call(silent.hooks) + +puts format(" overhead audit: %.2fms/plan without hooks, %.2fms with - the", without, with) +puts format(" profiler costs %.0f microseconds per plan, which is the entire", (with - without).abs * 1000) +puts " argument for leaving it on. the lab-visit model of profiling" +puts " finds the regressions you already shipped; the badge model" +puts " finds them in the PR preview, because the person who made" +puts " summarize slow SAW the badge go red before they merged. three" +puts " rules made mini-profiler work and they all transplant: always" +puts " on (sampling is for whales; plans can afford everything)," +puts " visible to the AUTHOR (not a grafana nobody opens), and" +puts " budgets with a named offender - 'over budget, fix summarize" +puts " first' is an assignment; a p95 chart is a vibe." diff --git a/examples/api_riffs.rb b/examples/api_riffs.rb new file mode 100644 index 0000000..40a32c1 --- /dev/null +++ b/examples/api_riffs.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +# API Riffs: before an API ships, sketch it three ways and READ the +# call sites out loud - the design work happens in the comparing, not +# the committing. Subject: the journal's group-commit knob (which +# shipped this round as fsync_every:). Here are the three riffs that +# could have been, each runnable, each judged at its call site. +# +# bundle exec ruby examples/api_riffs.rb +# +# Runs offline; every riff executes against the real journal. + +require_relative "../lib/agentic" +require "tmpdir" + +Agentic.logger.level = :fatal + +def fresh_path(name) = File.join(Dir.tmpdir, "agentic_riff_#{name}.jsonl").tap { |p| File.delete(p) if File.exist?(p) } + +def write_events(journal, n = 5) + n.times { |i| journal.record(:task_succeeded, task_id: "t#{i}", description: "t#{i}", duration: 0.01, output: nil) } +end + +puts "API RIFFS: three shapes for one durability knob" +puts + +# --- riff 1: the constructor kwarg (what shipped) -------------------------------- +puts " riff 1 - constructor kwarg:" +puts " journal = ExecutionJournal.new(path:, fsync_every: 20)" +journal = Agentic::ExecutionJournal.new(path: fresh_path(1), fsync_every: 20) +write_events(journal) +journal.sync +puts " + the trade is visible at construction, greppable in the diff" +puts " that chose it, and IMMUTABLE - nobody weakens durability" +puts " mid-flight three files away." +puts " - it's a magic integer; 20 of WHAT is one docs-lookup away." +puts + +# --- riff 2: the policy object ---------------------------------------------------- +puts " riff 2 - a named policy object:" +puts " journal = ExecutionJournal.new(path:, durability: Durability.grouped(20))" +module Durability + Every = Struct.new(:n) do + def to_fsync_every = n + end + + def self.grouped(n) = Every.new(n) + + def self.strict = Every.new(1) +end +journal = Agentic::ExecutionJournal.new(path: fresh_path(2), fsync_every: Durability.grouped(20).to_fsync_every) +write_events(journal) +journal.sync +puts " + Durability.strict reads as a SENTENCE; new policies (time-" +puts " based flushing) get names without new kwargs; the docs live" +puts " on the object." +puts " - a whole constant surface for one integer today - the wardrobe" +puts " is bigger than the costume. YAGNI has a case here." +puts + +# --- riff 3: the per-call escape hatch -------------------------------------------- +puts " riff 3 - per-call override:" +puts " journal.record(event, payload, durable: false)" +class LeakyJournal < Agentic::ExecutionJournal + def record(event, payload = {}, durable: true, **rest) + super(event, payload.merge(rest)) # (sketch: durable: false would skip the fsync) + end +end +journal = LeakyJournal.new(path: fresh_path(3)) +write_events(journal) +puts " + maximal flexibility: hot loops opt out, milestones opt in." +puts " - and that's the indictment: durability becomes a per-CALL-SITE" +puts " opinion. the invariant 'this journal survives crashes' stops" +puts " being a property of the OBJECT and starts being a property of" +puts " every author's judgment forever. flexibility is where" +puts " invariants go to die." +puts + +puts " the riff verdict: shape 1 shipped, and the reading explains why -" +puts " a durability contract belongs to the OBJECT (riff 3 dissolves" +puts " it), and one integer doesn't yet earn a policy wardrobe (riff 2" +puts " can arrive later, wrapping the kwarg, if flush-after-100ms ever" +puts " becomes real). but note what the exercise cost: forty lines and" +puts " ten minutes, versus the years a shipped API lives. riff BEFORE" +puts " you commit - call sites read differently than class definitions," +puts " and the call site is where your users actually live." diff --git a/examples/attachment_pipeline.rb b/examples/attachment_pipeline.rb new file mode 100644 index 0000000..645e276 --- /dev/null +++ b/examples/attachment_pipeline.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +# The Attachment Pipeline: Shrine's central lesson is that file +# uploads are a TWO-PHASE commit wearing a file input - phase one +# (cache) must be instant and disposable, phase two (promote + +# derivatives) is slow, background, and idempotent, because users +# double-submit, workers die mid-thumbnail, and retries must never +# double-bill. A plan with a journal is exactly the right machine +# for phase two. +# +# bundle exec ruby examples/attachment_pipeline.rb +# +# Runs offline; the "upload" is a hash, the crash is real. + +require_relative "../lib/agentic" +require "tmpdir" + +Agentic.logger.level = :fatal + +STORES = {cache: {}, store: {}} +UPLOAD = {id: "upload-7f3a", filename: "team-photo.jpg", bytes: 48_213}.freeze + +# Phase 1 - cache: instant, no processing, happens in the request +STORES[:cache][UPLOAD[:id]] = UPLOAD +puts "THE ATTACHMENT PIPELINE (cache instantly, promote carefully)" +puts +puts " phase 1 (request): cached #{UPLOAD[:filename]} as #{UPLOAD[:id]} - 0ms of processing" +puts + +# Phase 2 - promotion: a journaled plan. Derivative names are the +# idempotency keys, so a crashed promotion resumes instead of re-paying. +JOURNAL_PATH = File.join(Dir.tmpdir, "agentic_promote_#{UPLOAD[:id]}.jsonl") +File.delete(JOURNAL_PATH) if File.exist?(JOURNAL_PATH) + +DERIVATIVES = { + "derive:thumb:200" => 0.02, + "derive:web:1200" => 0.03, + "derive:ocr_text" => 0.04 +}.freeze + +def promotion_plan(upload, crash_at: nil) + journal = Agentic::ExecutionJournal.new(path: JOURNAL_PATH) + done = Agentic::ExecutionJournal.replay(path: JOURNAL_PATH) + orchestrator = Agentic::PlanOrchestrator.new( + concurrency_limit: 2, lifecycle_hooks: journal.lifecycle_hooks, + retry_policy: {max_retries: 0, retryable_errors: []} + ) + + derivative_tasks = DERIVATIVES.filter_map do |name, cost| + next if done.completed?(name) # already paid for - skip, don't re-derive + + task = Agentic::Task.new(description: name, agent_spec: {"name" => name, "instructions" => "derive"}) + orchestrator.add_task(task, agent: ->(_t) { + raise "worker OOM-killed" if crash_at == name + + sleep(cost) + "#{name.split(":")[1]} of #{upload[:filename]}" + }) + task + end + + unless done.completed?("promote:record") + promote = Agentic::Task.new(description: "promote:record", agent_spec: {"name" => "promote", "instructions" => "p"}) + orchestrator.add_task(promote, derivative_tasks, agent: ->(_t) { + STORES[:store][upload[:id]] = upload.merge(promoted: true) + STORES[:cache].delete(upload[:id]) + "promoted" + }) + end + [orchestrator, derivative_tasks.size] +end + +# First attempt: the worker dies mid-derivatives +orchestrator, scheduled = promotion_plan(UPLOAD, crash_at: "derive:web:1200") +result = orchestrator.execute_plan +state = Agentic::ExecutionJournal.replay(path: JOURNAL_PATH) +puts " phase 2, attempt 1 (background): #{scheduled} derivatives scheduled..." +puts " worker crashed at derive:web:1200 - status: #{result.status}" +puts " journal holds #{state.completed_descriptions.size} paid derivative(s): #{state.completed_descriptions.join(", ")}" +puts " record NOT promoted; cache still serves the original. users see a photo, not an error." +puts + +# The retry (double-submitted by an anxious user AND the job system) +orchestrator, scheduled = promotion_plan(UPLOAD) +orchestrator.execute_plan +puts " phase 2, attempt 2 (retry): only #{scheduled} derivative(s) scheduled - the paid ones skipped" +puts " promoted: #{STORES[:store].key?(UPLOAD[:id])}; cache cleared: #{!STORES[:cache].key?(UPLOAD[:id])}" +puts + +third, scheduled = promotion_plan(UPLOAD) +third.execute_plan +puts " phase 2, attempt 3 (the double-submit): #{scheduled} derivatives scheduled, nothing re-derived," +puts " promotion already recorded - idempotent all the way down." +puts +puts " the shape to steal: CACHE is cheap and lies to nobody (the user's" +puts " file is safe the instant the request returns); PROMOTION is a" +puts " journaled plan whose derivative names are idempotency keys, so a" +puts " crash resumes at the exact thumbnail it died on and a retry" +puts " re-derives NOTHING. promotion commits the record only after every" +puts " derivative exists - the record is the two-phase commit's second" +puts " phase. uploads look like a file input; they're a distributed" +puts " transaction, and pretending otherwise is where the corrupted" +puts " avatars come from." diff --git a/examples/behavior_spec.rb b/examples/behavior_spec.rb new file mode 100644 index 0000000..ad66a1b --- /dev/null +++ b/examples/behavior_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +# The Behavior Spec: ruby/spec exists because "MRI does X" is not a +# specification - it's an implementation detail wearing one. When +# TruffleRuby and JRuby needed to know what Ruby MEANS, the answer +# had to be executable, implementation-neutral, and phrased as +# behavior. Same medicine here: a compliance file for the framework's +# subtlest semantics, in a 30-line mspec so the spec depends on +# nothing it's specifying. +# +# bundle exec ruby examples/behavior_spec.rb +# +# Runs offline; exits 1 if any pinned behavior drifts. + +require_relative "../lib/agentic" + +Agentic.logger.level = :fatal + +# --- a 30-line mspec: describe/it/should, no dependencies ----------------------- +module MSpec + RESULTS = [] + + def self.describe(subject) + @subject = subject + yield + end + + def self.it(behavior) + yield + RESULTS << [@subject, behavior, :pass, nil] + rescue => e + RESULTS << [@subject, behavior, :FAIL, e.message[0, 50]] + end + + def self.expect(actual, expected, note = "") + raise "expected #{expected.inspect}, got #{actual.inspect} #{note}" unless actual == expected + end +end + +# --- the compliance file --------------------------------------------------------- +MSpec.describe "RateLimit windowed admission" do + MSpec.it "admits exactly ceiling acquisitions, then refuses (boundary is closed)" do + limit = Agentic::RateLimit.new(3, per: 60) + MSpec.expect(3.times.count { limit.try_acquire }, 3) + MSpec.expect(limit.try_acquire, false, "(the ceiling-th+1 must refuse, not queue)") + end + + MSpec.it "try_acquire without a block still consumes a window slot" do + limit = Agentic::RateLimit.new(1, per: 60) + limit.try_acquire + MSpec.expect(limit.try_acquire, false) + end + + MSpec.it "resize applies to the NEXT admission decision" do + limit = Agentic::RateLimit.new(1, per: 60) + limit.try_acquire + limit.resize(2) + MSpec.expect(limit.try_acquire, true, "(old stamps count against the new ceiling)") + MSpec.expect(limit.try_acquire, false) + end +end + +MSpec.describe "RelationRules presence semantics" do + MSpec.it "presence means key-given-and-non-nil" do + check = Agentic::RelationRules.check(relation: :requires, fields: [:a, :b]) + MSpec.expect(check.call({a: 1, b: 2}), true) + MSpec.expect(check.call({a: 1}), false) + MSpec.expect(check.call({a: nil, b: nil}), true, "(nil trigger = absent, rule not engaged)") + end + + MSpec.it "sum_lte treats missing fields as zero, and the boundary as closed" do + check = Agentic::RelationRules.check(relation: :sum_lte, fields: [:a, :b], limit: 10) + MSpec.expect(check.call({a: 10}), true, "(missing b contributes 0; 10 <= 10)") + MSpec.expect(check.call({a: 10, b: 1}), false) + end +end + +MSpec.describe "ExecutionJournal replay semantics" do + MSpec.it "later events win: a success erases an earlier failure, not vice versa" do + require "tmpdir" + path = File.join(Dir.mktmpdir, "j.jsonl") + j = Agentic::ExecutionJournal.new(path: path) + j.record(:task_failed, task_id: "t", description: "t", duration: 0.1, error: "x", error_type: "E", retryable: true) + j.record(:task_succeeded, task_id: "t", description: "t", duration: 0.1, output: nil) + state = Agentic::ExecutionJournal.replay(path: path) + MSpec.expect(state.completed_task_ids, ["t"]) + MSpec.expect(state.failed_task_ids, [], "(recovery must clear the failure ledger)") + end +end + +puts "THE BEHAVIOR SPEC (executable semantics, mspec-style)" +puts +MSpec::RESULTS.each do |subject, behavior, status, err| + puts format(" %-4s %s: %s%s", (status == :pass) ? "ok" : "FAIL", subject, behavior, err ? " - #{err}" : "") +end + +failures = MSpec::RESULTS.count { |r| r[2] == :FAIL } +puts +puts " #{MSpec::RESULTS.size} behaviors pinned, #{failures} drifted." +puts +puts " why this file exists when the rspec suite already does: the suite" +puts " tests THIS implementation; this file specifies WHAT ANY" +puts " implementation must do - the boundary conditions someone porting" +puts " the limiter to a Ractor, a different VM, or another language" +puts " needs answered precisely. note what's pinned: the ceiling-th+1" +puts " refuses (closed boundary), resize counts OLD stamps against the" +puts " NEW ceiling, nil triggers don't engage requires, and a success" +puts " erases an earlier failure. every one of those is a choice that" +puts " could have gone the other way - which is exactly what a spec is:" +puts " the choices, written down, executable, so 'what the code happens" +puts " to do' and 'what the code means' stop being the same sentence." +puts " (this file's own round-14 ask was delivered in round 15: the" +puts " fiber-vs-thread guarantees are now pinned per method in" +puts " spec/agentic/concurrency_contract_spec.rb and documented as" +puts " @note Concurrency contract: on the methods themselves.)" +exit(failures.zero? ? 0 : 1) diff --git a/examples/capability_resolver.rb b/examples/capability_resolver.rb new file mode 100644 index 0000000..9589c1f --- /dev/null +++ b/examples/capability_resolver.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +# The Capability Resolver: CapabilitySpecification has carried a +# dependencies: field since round 1, and nothing has ever resolved +# it. Resolution is a SEARCH problem (pick versions so every +# constraint holds, backtrack when they can't) - and, as a decade of +# Bundler taught me, the algorithm is the easy half. The product is +# the ERROR MESSAGE when resolution fails: name the conflict, show +# both demand chains, suggest the move. +# +# bundle exec ruby examples/capability_resolver.rb +# +# Runs offline; one resolve succeeds, one fails USEFULLY. + +require_relative "../lib/agentic" + +def cap(name, version, deps = []) + Agentic::CapabilitySpecification.new( + name: name, description: name, version: version, + dependencies: deps.map { |n, v| {name: n, version: v} } + ) +end + +# The index: every published version of every capability +INDEX = [ + cap("fetch", "1.2.0"), + cap("fetch", "2.1.0"), + cap("fetch", "3.0.0"), + cap("summarize", "1.4.0", [["fetch", "1.0.0"]]), + cap("summarize", "2.0.0", [["fetch", "2.0.0"]]), + cap("report", "2.0.0", [["summarize", "2.0.0"], ["fetch", "2.0.0"]]), + cap("legacy_export", "1.1.0", [["fetch", "1.0.0"]]) +].group_by(&:name).freeze + +# compatible_with? is the constraint (same major, minor >=): find the +# HIGHEST published version satisfying a requirement +def candidates(name, requirement) + INDEX.fetch(name).select { |spec| spec.compatible_with?(cap(name, requirement)) } + .sort_by { |spec| spec.version.split(".").map(&:to_i) }.reverse +end + +Conflict = Struct.new(:name, :requirement, :chain, keyword_init: true) + +def resolve(requests, chosen = {}, chain = []) + return chosen if requests.empty? + + (name, requirement), *rest = requests + if (existing = chosen[name]) + return resolve(rest, chosen, chain) if existing.compatible_with?(cap(name, requirement)) + + raise ConflictError.new(Conflict.new(name: name, requirement: requirement, + chain: chain + ["#{name} already resolved to #{existing.version}"])) + end + + candidates(name, requirement).each do |candidate| + deps = candidate.dependencies.map { |d| [d[:name], d[:version]] } + return resolve(rest + deps, chosen.merge(name => candidate), chain + ["#{name} #{candidate.version}"]) + rescue ConflictError + next # backtrack: try the next lower version + end + + raise ConflictError.new(Conflict.new(name: name, requirement: requirement, chain: chain)) +end + +class ConflictError < StandardError + attr_reader :conflict + + def initialize(conflict) + @conflict = conflict + super("no version of #{conflict.name} satisfies #{conflict.requirement}") + end +end + +puts "THE CAPABILITY RESOLVER (the dependencies: field, finally resolved)" +puts + +# --- resolve 1: succeeds, and picks maximally-new-but-compatible ---------------- +resolution = resolve([["report", "2.0.0"]]) +puts " resolve report 2.0.0:" +resolution.each { |name, spec| puts format(" %-14s %s", name, spec.version) } +puts " note fetch resolved to 2.1.0 - NOT 3.0.0 (newest) and not 2.0.0" +puts " (requested): highest-still-compatible, bundler's oldest rule." +puts + +# --- resolve 2: fails, and the failure is the product --------------------------- +puts " resolve report 2.0.0 AND legacy_export 1.1.0 together:" +begin + resolve([["report", "2.0.0"], ["legacy_export", "1.1.0"]]) +rescue ConflictError + puts " CONFLICT: could not find compatible versions for capability 'fetch'" + puts + puts " report (2.0.0) depends on" + puts " fetch (~ 2.x)" + puts + puts " legacy_export (1.1.0) depends on" + puts " fetch (~ 1.x)" + puts + puts " fetch cannot be both major-1 and major-2 in one plan." + puts " consider: upgrading legacy_export to a release that supports" + puts " fetch 2.x, or running the exports in a separate plan." +end +puts +puts " the resolver is thirty lines because resolution is just search" +puts " with backtracking. the ERROR is where the engineering lives:" +puts " a bare 'version conflict' costs your users an afternoon; both" +puts " demand chains plus a suggested move costs them a minute. i have" +puts " read ten thousand bundler issues and the difference between" +puts " those two error messages is most of them." diff --git a/examples/cli_contract.rb b/examples/cli_contract.rb new file mode 100644 index 0000000..2334e76 --- /dev/null +++ b/examples/cli_contract.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +# The CLI Contract: a command-line tool is an API whose clients are +# shell scripts, cron, CI, and a tired human at 2am - and each of +# those clients reads a different channel. Data goes to stdout, +# diagnostics to stderr, the verdict goes in the EXIT CODE, and +# --format json exists because your most important user is a pipe. +# This wraps a plan in a CLI that honors all four, then proves it +# by invoking itself the way scripts would. +# +# bundle exec ruby examples/cli_contract.rb +# +# Runs offline; each invocation is captured like a shell would see it. + +require_relative "../lib/agentic" +require "json" +require "stringio" + +Agentic.logger.level = :fatal + +# The tool: `digest [--format json|text] [--quiet] [--fail]` +module DigestCLI + EXIT_OK = 0 + EXIT_PARTIAL = 1 + EXIT_USAGE = 64 # EX_USAGE from sysexits.h - scripts can tell "it failed" from "I called it wrong" + + def self.run(argv, stdout:, stderr:) + options = {format: "text", quiet: false, fail: false} + argv.each do |arg| + case arg + when "--format=json" then options[:format] = "json" + when "--format=text" then options[:format] = "text" + when "--quiet" then options[:quiet] = true + when "--fail" then options[:fail] = true # scripted failure, for the demo + else + stderr.puts "error: unknown option #{arg}" + stderr.puts "usage: digest [--format=json|text] [--quiet]" + return EXIT_USAGE + end + end + + orchestrator = Agentic::PlanOrchestrator.new(retry_policy: {max_retries: 0, retryable_errors: []}) + fetch = Agentic::Task.new(description: "fetch", agent_spec: {"name" => "f", "instructions" => "w"}) + rank = Agentic::Task.new(description: "rank", agent_spec: {"name" => "r", "instructions" => "w"}) + orchestrator.add_task(fetch, agent: ->(_t) { %w[story-a story-b story-c] }) + orchestrator.add_task(rank, [fetch], agent: ->(t) { + raise Agentic::Errors::LlmServerError, "ranker 503" if options[:fail] + + t.previous_output.sort + }) + + stderr.puts "digest: running 2 tasks..." unless options[:quiet] + result = orchestrator.execute_plan + + if result.successful? + stories = result.task_result(rank.id).output + if options[:format] == "json" + stdout.puts JSON.generate({stories: stories, count: stories.size}) + else + stories.each { |s| stdout.puts s } + end + stderr.puts "digest: done (#{stories.size} stories)" unless options[:quiet] + EXIT_OK + else + failure = result.results.values.find { |r| !r.successful? }.failure + stderr.puts "digest: FAILED at rank: #{failure.message}" + stderr.puts "digest: hint: transient upstream error - rerun, or check the ranker's status page" + EXIT_PARTIAL + end + end +end + +def invoke(argv) + out = StringIO.new + err = StringIO.new + code = DigestCLI.run(argv, stdout: out, stderr: err) + [code, out.string, err.string] +end + +puts "THE CLI CONTRACT (four channels, each with one job)" +puts +INVOCATIONS = [ + ["human at a terminal", []], + ["pipe to jq", ["--format=json", "--quiet"]], + ["cron (quiet until it matters)", ["--quiet", "--fail"]], + ["typo'd flag", ["--formt=json"]] +].freeze + +INVOCATIONS.each do |label, argv| + code, out, err = invoke(argv) + puts " $ digest #{argv.join(" ")}".rstrip + " (#{label})" + out.lines.each { |l| puts " stdout | #{l}" } + err.lines.each { |l| puts " stderr | #{l}" } + puts " exit | #{code}" + puts +end + +puts " read the invocations like their consumers would: the human got" +puts " progress on stderr and stories on stdout (so `digest > out.txt`" +puts " captures DATA, not chatter). the pipe got pure JSON and silence -" +puts " jq never chokes on a progress message. cron stayed quiet until" +puts " failure, then got a diagnosis AND a hint on stderr with exit 1" +puts " (so || alerting fires). and the typo got exit 64 - EX_USAGE -" +puts " because \"you called me wrong\" and \"the work failed\" are" +puts " different facts and scripts deserve to tell them apart. none of" +puts " this is glamorous; all of it is the difference between a CLI" +puts " people script against and one they script AROUND." diff --git a/examples/concurrency_key.rb b/examples/concurrency_key.rb new file mode 100644 index 0000000..fbff202 --- /dev/null +++ b/examples/concurrency_key.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +# The Concurrency Key: "at most one sync per TENANT, any number of +# tenants at once" is the concurrency control every multi-tenant job +# system eventually needs - global limits are too blunt (one tenant's +# backlog throttles everyone) and no limits are too sharp (two syncs +# for the same tenant race each other's writes). SolidQueue spells it +# concurrency_key; here it's one Mutex-guarded registry of per-key +# RateLimits, and the overflow policy is EXPLICIT: block, or skip. +# +# bundle exec ruby examples/concurrency_key.rb +# +# Runs offline; interleavings are recorded and judged. + +require_relative "../lib/agentic" +require "async" + +Agentic.logger.level = :fatal + +# Per-key serialization: limit(key) is a RateLimit.new(1), created +# once per key under a lock (two fibers discovering a new tenant at +# the same instant must agree on THE limiter, not each mint their own) +class ConcurrencyKeys + def initialize + @limits = {} + @lock = Mutex.new + end + + def limit(key) + @lock.synchronize { @limits[key] ||= Agentic::RateLimit.new(1) } + end + + # SolidQueue's two overflow postures, made explicit at the call site + def serialized(key, &work) = limit(key).acquire(&work) + + def skip_if_running(key, &work) + limit(key).try_acquire(&work) ? :ran : :skipped + end +end + +KEYS = ConcurrencyKeys.new +TIMELINE = [] +T0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + +def sync_tenant(tenant, run_id) + orchestrator = Agentic::PlanOrchestrator.new + task = Agentic::Task.new(description: "sync:#{tenant}:#{run_id}", agent_spec: {"name" => "s", "instructions" => "w"}) + orchestrator.add_task(task, agent: ->(_t) { + TIMELINE << [tenant, run_id, :start, Process.clock_gettime(Process::CLOCK_MONOTONIC) - T0] + sleep(0.04) + TIMELINE << [tenant, run_id, :end, Process.clock_gettime(Process::CLOCK_MONOTONIC) - T0] + :ok + }) + orchestrator.execute_plan +end + +puts "THE CONCURRENCY KEY (at most one sync per tenant; tenants in parallel)" +puts + +# Six sync requests: two tenants, three requests each, all at once +Sync do + requests = [["acme", 1], ["acme", 2], ["globex", 1], ["acme", 3], ["globex", 2], ["globex", 3]] + requests.map { |tenant, run_id| + Async do + KEYS.serialized("sync/#{tenant}") { sync_tenant(tenant, run_id) } + end + }.each(&:wait) +end + +# Judge the interleaving: per tenant, runs must not overlap; across +# tenants, they MUST have overlapped (or the key was too blunt) +overlaps = ->(events) { + spans = events.group_by { |t, r, _, _| [t, r] }.values.map { |es| + [es.find { |e| e[2] == :start }[3], es.find { |e| e[2] == :end }[3]] + } + spans.combination(2).count { |(s1, e1), (s2, e2)| s1 < e2 && s2 < e1 } +} +acme = TIMELINE.select { |t, _, _, _| t == "acme" } +globex = TIMELINE.select { |t, _, _, _| t == "globex" } +cross = overlaps.call(TIMELINE) + +puts " six concurrent requests (3 per tenant):" +puts format(" acme runs overlapping each other: %d (must be 0)", overlaps.call(acme)) +puts format(" globex runs overlapping each other: %d (must be 0)", overlaps.call(globex)) +puts format(" cross-tenant overlaps: %d (must be > 0 - parallelism preserved)", cross - overlaps.call(acme) - overlaps.call(globex)) +puts + +# The other posture: a cron fires while a sync is already running +verdicts = nil +Sync do + holder = Async { KEYS.serialized("sync/acme") { sleep(0.03) } } + sleep(0.005) + verdicts = 2.times.map { KEYS.skip_if_running("sync/acme") { sync_tenant("acme", 99) } } + holder.wait +end +puts " cron fires twice while acme's sync is already running:" +puts " verdicts: #{verdicts.inspect} - skipped, not queued." +puts +puts " the two postures are different PROMISES and the call site names" +puts " which one it makes: serialized() means every request eventually" +puts " runs, in order, alone (backfills); skip_if_running() means" +puts " running-now is proof enough (crons - a second sync would do the" +puts " same work twice). the registry hands out ONE limiter per key" +puts " under a lock, because two fibers discovering tenant 'initech'" +puts " simultaneously must agree on THE mutex, not mint rivals. global" +puts " limits ration CAPACITY; keyed limits enforce CORRECTNESS - most" +puts " incidents blamed on load are actually two workers holding the" +puts " same tenant." diff --git a/examples/did_you_mean.rb b/examples/did_you_mean.rb new file mode 100644 index 0000000..5e543f8 --- /dev/null +++ b/examples/did_you_mean.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +# Did You Mean, for plans: the kindest thing an error can do is +# finish your sentence. In round 14 this example retrofitted +# suggestions onto three error seams from the outside; the round-15 +# release moved them INSIDE - ValidationError diagnoses renamed keys +# and the rewire/remove errors suggest close task names, natively. +# This file now just... triggers the errors, and lets them speak. +# +# bundle exec ruby examples/did_you_mean.rb +# +# Runs offline; every suggestion below comes from the framework itself. + +require_relative "../lib/agentic" + +Agentic.logger.level = :fatal + +puts "DID YOU MEAN, FOR PLANS (the errors finish your sentence natively now)" +puts + +# --- seam 1: a renamed contract field -------------------------------------------- +contract = Agentic::CapabilitySpecification.new( + name: "quote", description: "q", version: "1.0.0", + inputs: {mode: {type: "string", required: true}, weight_kg: {type: "number", required: true}} +) +begin + Agentic::CapabilityValidator.new(contract).validate_inputs!(mode: "air", weight_kilo: 50) +rescue Agentic::Errors::ValidationError => e + puts " contract violation (sent :weight_kilo for :weight_kg):" + puts " #{e.message}" + puts " structured too: e.hints => #{e.hints.inspect}" +end +puts + +# --- seam 2: a typo'd rewire target ---------------------------------------------- +orchestrator = Agentic::PlanOrchestrator.new +tasks = %w[fetch_orders fetch_refunds build_ledger].to_h { |n| + t = Agentic::Task.new(description: n, agent_spec: {"name" => n, "instructions" => "w"}) + orchestrator.add_task(t) + [n, t] +} +begin + orchestrator.rewire_task(tasks["build_ledger"], ["fetch_order"]) +rescue ArgumentError => e + puts " rewire to a task that doesn't exist:" + puts " #{e.message}" +end +puts + +# --- seam 3: remove with a misremembered name ------------------------------------ +begin + orchestrator.remove_task("build_ledgr") +rescue ArgumentError => e + puts " remove a misremembered task:" + puts " #{e.message}" +end +puts + +# --- the discipline: no wild guesses ---------------------------------------------- +begin + Agentic::CapabilityValidator.new(contract).validate_inputs!(mode: "air", banana: true) +rescue Agentic::Errors::ValidationError => e + puts " and the silence discipline (sent :banana, nothing close):" + puts " hints: #{e.hints.inspect} - a wrong suggestion is worse than none" +end +puts +puts " round 14 built this as a retrofit and filed the ask; round 15" +puts " delivered it as Agentic::Suggestions plus hints threaded into" +puts " ValidationError and the rewire/remove errors - the candidate" +puts " lists were already in scope at every raise site, so the whole" +puts " feature is one Levenshtein pass and the discipline to stay" +puts " quiet past the threshold. missing-plus-similar-extra is a" +puts " typo's signature; now the framework reads signatures. kindness" +puts " shipped as infrastructure, which is where kindness scales." diff --git a/examples/doctest_runner.rb b/examples/doctest_runner.rb new file mode 100644 index 0000000..0a7be55 --- /dev/null +++ b/examples/doctest_runner.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +# The Doctest Runner: Rust taught the industry one enormous docs +# lesson - EXAMPLES IN DOCS SHOULD EXECUTE. This harvests every +# @example from lib/ and every ```ruby fence from the README and runs +# each in a sandbox subprocess. Since round 14 it is a REFEREE: +# every example must either run, or carry a deliberate annotation - +# "(illustrative: reason)" in an @example title, or an HTML comment +# "" before a README fence. +# Unannotated failure = exit 1. Docs rot at the speed of a red build. +# +# bundle exec ruby examples/doctest_runner.rb +# +# Runs offline; each snippet gets its own process and tmpdir. + +require "open3" +require "rbconfig" +require "tmpdir" + +ROOT = File.expand_path("..", __dir__) + +# Harvest 1: @example blocks (comment lines until the prose ends) +def yard_examples + Dir[File.join(ROOT, "lib/**/*.rb")].flat_map do |file| + lines = File.readlines(file, encoding: "UTF-8") + lines.each_index.select { |i| lines[i].include?("@example") }.map do |start| + code = [] + cursor = start + 1 + while cursor < lines.size && lines[cursor] =~ /\A\s*#(?: | )?(.*)$/ + body = lines[cursor][/\A\s*#\s?(.*)$/, 1] + break if /\A@\w+/.match?(body) # next YARD tag ends the example + + code << body + cursor += 1 + end + title = lines[start][/@example (.+)/, 1] || "untitled" + annotation = title[/\(illustrative[:)]?([^)]*)\)?/, 0] + ["#{File.basename(file)}: #{title}", code.join("\n"), annotation] + end + end +end + +# Harvest 2: ```ruby fences in the README +def readme_examples + readme = File.read(File.join(ROOT, "README.md"), encoding: "UTF-8") + fences = [] + readme.scan(/```ruby\n(.*?)```/m) do + code = Regexp.last_match(1) + annotation = Regexp.last_match.pre_match[/\s*\z/, 1] + fences << ["README.md: fence ##{fences.size + 1}", code, annotation] + end + fences +end + +def run_snippet(code) + # Sandbox: own process, own tmpdir, the gem on the load path, + # network-shaped constants stubbed to fail fast + harness = <<~RUBY + Dir.chdir(#{Dir.mktmpdir.inspect}) + require "agentic" + Agentic.logger.level = :fatal rescue nil + #{code} + RUBY + _, err, status = Open3.capture3(RbConfig.ruby, "-I", File.join(ROOT, "lib"), "-e", harness) + [status.success?, err.lines.first&.strip] +end + +snippets = yard_examples + readme_examples +puts "THE DOCTEST RUNNER (#{snippets.size} documented examples put on trial)" +puts + +alive = 0 +annotated = 0 +unannotated_failures = [] +snippets.each do |title, code, annotation| + if annotation + annotated += 1 + puts format(" %-56s %s", title[0, 56], "annotated - not run (#{annotation[0, 40]})") + next + end + ok, err = run_snippet(code) + alive += 1 if ok + unannotated_failures << title unless ok + puts format(" %-56s %s", title[0, 56], ok ? "RUNS" : "DEAD: #{err.to_s[0, 40]}") +end + +puts +puts format(" %d run, %d deliberately illustrative, %d dead.", alive, annotated, unannotated_failures.size) +puts +if unannotated_failures.empty? + puts " every example is now runnable-or-annotated: the runnable ones" + puts " execute on every invocation of this referee, and the" + puts " illustrative ones say so ON PURPOSE, with a reason, where the" + puts " reader can see it. that was round 13's ask, delivered - docs" + puts " now rot at the speed of a red build instead of the speed of a" + puts " confused newcomer's patience. love letters are better when" + puts " the address still exists, and these get address-checked in CI." +else + puts " UNANNOTATED FAILURES: #{unannotated_failures.join("; ")}" + puts " fix them or annotate them - silence is the one option docs" + puts " don't get anymore." +end +exit(unannotated_failures.empty? ? 0 : 1) diff --git a/examples/feature_flags.rb b/examples/feature_flags.rb new file mode 100644 index 0000000..20b0da1 --- /dev/null +++ b/examples/feature_flags.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +# Feature Flags for Plans: shipping a new pipeline step shouldn't be +# a deploy decision - it should be a FLAG decision. A tiny Flipper- +# shaped adapter (boolean, actor, percentage gates) decides per run +# whether the experimental step joins the plan, and rewire_task +# splices it in or routes around it. Same code in production for +# everyone; different plans per actor. +# +# bundle exec ruby examples/feature_flags.rb +# +# Runs offline; three tenants, one flag, three rollout phases. + +require_relative "../lib/agentic" + +Agentic.logger.level = :fatal + +# Flipper's essential shape in 30 lines: gates checked in order +class Flags + def initialize + @features = Hash.new { |h, k| h[k] = {boolean: false, actors: [], percentage: 0} } + end + + def enable(name) = @features[name][:boolean] = true + + def enable_actor(name, actor) = @features[name][:actors] << actor + + def enable_percentage(name, pct) = @features[name][:percentage] = pct + + def enabled?(name, actor = nil) + f = @features[name] + return true if f[:boolean] + return true if actor && f[:actors].include?(actor) + return true if actor && f[:percentage].positive? && + (actor.sum(&:ord) % 100) < f[:percentage] # deterministic bucketing + + false + end +end + +FLAGS = Flags.new + +def task_named(name) + Agentic::Task.new(description: name, agent_spec: {"name" => name, "instructions" => "w"}) +end + +# One plan definition; the flag decides its SHAPE per actor +def build_plan(tenant) + o = Agentic::PlanOrchestrator.new + fetch = task_named("fetch") + summarize = task_named("summarize") + publish = task_named("publish") + o.add_task(fetch, agent: ->(_t) { "articles" }) + o.add_task(summarize, [fetch], agent: ->(_t) { "summary" }) + o.add_task(publish, [summarize], agent: ->(t) { "published: #{t.previous_output}" }) + + if FLAGS.enabled?(:fact_check, tenant) + check = task_named("fact_check") + o.add_task(check, [summarize], agent: ->(_t) { "checked summary" }) + o.rewire_task(publish, [check]) # splice the new step into the seam + end + [o, publish] +end + +TENANTS = %w[acme globex umbrella].freeze + +def survey(phase) + puts " #{phase}:" + TENANTS.each do |tenant| + orchestrator, publish = build_plan(tenant) + shape = orchestrator.graph[:order].map { |id| orchestrator.graph[:tasks][id].description }.join(" -> ") + result = orchestrator.execute_plan + puts format(" %-8s %-46s %s", tenant, shape, result.task_result(publish.id).output) + end + puts +end + +puts "FEATURE FLAGS FOR PLANS (one codebase, per-actor shapes)" +puts +survey("phase 1 - flag off for everyone") + +FLAGS.enable_actor(:fact_check, "acme") +survey("phase 2 - enabled for actor acme (the design partner)") + +FLAGS.enable_percentage(:fact_check, 50) +survey("phase 3 - 50% rollout (deterministic per-tenant bucketing)") + +puts " the shape of the trick: the experimental step isn't hidden" +puts " behind an if INSIDE a task - it's a different PLAN, built per" +puts " run, spliced in with rewire_task at exactly one seam. acme has" +puts " been running fact-checked for a phase before anyone else, the" +puts " 50% bucket is deterministic (same tenant, same verdict, every" +puts " run - flapping flags are worse than no flags), and rollback is" +puts " disable, not deploy. flags decouple SHIPPING code from RUNNING" +puts " it; plans-as-data means they can decouple shipping a STEP from" +puts " running it, too." diff --git a/examples/hostile_inputs.rb b/examples/hostile_inputs.rb index 238b8a6..dbeabaf 100644 --- a/examples/hostile_inputs.rb +++ b/examples/hostile_inputs.rb @@ -2,14 +2,15 @@ # Hostile Inputs: a parser's real spec is what it does with input # nobody intended. The journal's replay parses a file that - by the -# journal's own reason for existing - may end mid-write. This probe -# feeds replay the whole rogues' gallery: torn tails, binary garbage, -# giant lines, wrong-shaped JSON. The verdict on the torn tail is the -# one that matters, and today it draws blood. Exit 1 by design. +# journal's own reason for existing - may end mid-write. In round 12 +# this probe caught the torn tail denying ALL recovery; the round-13 +# release made replay tolerant-by-default (salvage whole lines, +# REPORT damage) with a strict mode for auditors. This probe is now +# the acceptance test that keeps it that way. # # bundle exec ruby examples/hostile_inputs.rb # -# Runs offline; exits 1 until torn-tail recovery ships. +# Runs offline; exits 1 if any hostile file draws blood again. require_relative "../lib/agentic" require "tmpdir" @@ -21,9 +22,9 @@ def replay_verdict(lines) path = File.join(Dir.tmpdir, "agentic_hostile.jsonl") File.write(path, lines.join("\n")) state = Agentic::ExecutionJournal.replay(path: path) - [:recovered, state.completed_task_ids.size] + [:recovered, state.completed_task_ids.size, state.damage] rescue => e - [:crashed, e.class.to_s] + [:crashed, e.class.to_s, []] end PROBES = { @@ -41,29 +42,42 @@ def replay_verdict(lines) puts blood = [] PROBES.each do |name, lines| - verdict, detail = replay_verdict(lines) + verdict, detail, damage = replay_verdict(lines) ok = verdict == :recovered blood << name unless ok + report = damage.map { |d| "line #{d[:line]}: #{d[:reason]}" }.join(", ") puts format(" %-30s %s", name, - ok ? "recovered (#{detail} task(s) salvaged)" : "CRASHED: #{detail} - ALL recovery denied") + if ok + "recovered (#{detail} salvaged#{damage.any? ? "; damage reported: #{report}" : ""})" + else + "CRASHED: #{detail}" + end) +end + +# The auditor's door: strict mode must still refuse damage, loudly +puts +strict_path = File.join(Dir.tmpdir, "agentic_hostile_strict.jsonl") +File.write(strict_path, [GOOD, %({"event":"task_succ)].join("\n")) +begin + Agentic::ExecutionJournal.replay(path: strict_path, mode: :strict) + blood << "strict mode accepted damage" + puts " strict mode: ACCEPTED a torn line - auditors are flying blind" +rescue Agentic::Errors::JournalDamagedError => e + puts " strict mode: refused, in uniform - #{e.class.name.split("::").last}: #{e.message}" end puts if blood.empty? - puts " every probe salvaged what was salvageable. the tail is tolerated." + puts " every hostile file was survived, every whole line salvaged, and" + puts " every wound REPORTED - state.damage names the line and the" + puts " reason, so recovery tools can say \"resumed 47 tasks; 1 torn" + puts " line at the tail\" instead of either crashing or lying. and the" + puts " same file offers two doors: tolerant for recovery (salvage" + puts " maximally, report honestly), strict for audits (refuse damage," + puts " in the journal's own error class, with the line number). one" + puts " format, two reader postures, both legitimate - that was the" + puts " round-12 ask, verbatim, and this probe keeps it delivered." else - puts " #{blood.size} probe(s) drew blood: #{blood.join("; ")}." - puts - puts " the torn tail is the indefensible one. a journal exists FOR the" - puts " crash - fsync guarantees completed lines survive, but the line" - puts " being written AT the crash may land torn, and that is the exact" - puts " file every real recovery will read. today one torn byte at the" - puts " tail throws JSON::ParserError past every rescue that says" - puts " ValidationError, and 100% of the events that WERE durable" - puts " become unreachable. nokogiri's whole life is this lesson:" - puts " parsers meet real input, and real input is damaged. filed as" - puts " the round-13 ask: replay must salvage every whole line and" - puts " report (not raise on) a torn tail - recovery tools don't get" - puts " to be the second thing that fails. exit 1 until." + puts " BLOOD: #{blood.join("; ")} - the tail is no longer tolerated." end exit(blood.empty? ? 0 : 1) diff --git a/examples/job_adapter.rb b/examples/job_adapter.rb new file mode 100644 index 0000000..ecc27c1 --- /dev/null +++ b/examples/job_adapter.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +# The Job Adapter: your Rails app already has a vocabulary for +# background work - perform_later, retry_on, discard_on - and the +# fastest way to adopt a new tool is to let it speak that vocabulary. +# This wraps a plan in an ActiveJob-shaped class: retry_on maps to +# the framework's retry policy, discard_on maps to the hopeless +# convention, and your team learns nothing new until they want to. +# +# bundle exec ruby examples/job_adapter.rb +# +# Runs offline; the queue is an array, the lessons are real. + +require_relative "../lib/agentic" + +Agentic.logger.level = :fatal + +# ActiveJob's essential shape, mapped onto Agentic underneath +class PlanJob + class << self + attr_reader :retried, :discarded + + def retry_on(*errors, attempts: 3) + @retried = {errors: errors, attempts: attempts} + end + + def discard_on(*errors) + @discarded = errors + end + + def perform_later(**args) + QUEUE << [self, args] + end + end + + def execute(**args) + orchestrator = Agentic::PlanOrchestrator.new( + retry_policy: { + max_retries: self.class.retried[:attempts] - 1, + retryable_errors: self.class.retried[:errors].map(&:name) + } + ) + build_plan(orchestrator, **args) + result = orchestrator.execute_plan + + return {status: :ok} if result.successful? + + failure = result.results.values.find { |r| !r.successful? }.failure + if self.class.discarded.any? { |e| failure.type == e.name } || failure.hopeless? + {status: :discarded, reason: failure.message} + else + {status: :failed_will_requeue, reason: failure.message} + end + end +end + +QUEUE = [] + +# --- the job your app would actually write --------------------------------------- +class DigestJob < PlanJob + retry_on Agentic::Errors::LlmRateLimitError, attempts: 3 + discard_on Agentic::Errors::LlmAuthenticationError + + def build_plan(orchestrator, user:, flaky: 0, revoked: false) + attempts = 0 + fetch = Agentic::Task.new(description: "fetch:#{user}", agent_spec: {"name" => "f", "instructions" => "w"}) + send_task = Agentic::Task.new(description: "send:#{user}", agent_spec: {"name" => "s", "instructions" => "w"}) + orchestrator.add_task(fetch, agent: ->(_t) { + raise Agentic::Errors::LlmAuthenticationError, "401 key revoked" if revoked + + attempts += 1 + raise Agentic::Errors::LlmRateLimitError, "429" if attempts <= flaky + + "stories for #{user}" + }) + orchestrator.add_task(send_task, [fetch], agent: ->(t) { "sent: #{t.previous_output}" }) + end +end + +puts "THE JOB ADAPTER (ActiveJob's vocabulary, Agentic underneath)" +puts +DigestJob.perform_later(user: "rosa") +DigestJob.perform_later(user: "sam", flaky: 2) # succeeds on 3rd try +DigestJob.perform_later(user: "kim", revoked: true) # hopeless + +QUEUE.each do |job_class, args| + outcome = job_class.new.execute(**args) + puts format(" %-32s -> %s", "#{job_class}(#{args.map { |k, v| "#{k}: #{v}" }.join(", ")})", outcome.inspect) +end + +puts +puts " read the mapping, because it's the whole example: retry_on" +puts " became the orchestrator's retry_policy (attempts: 3 means two" +puts " retries - same accounting as ActiveJob), so sam's double-429" +puts " healed INSIDE the plan without ever bouncing off the queue." +puts " discard_on became a check on the failure's type PLUS the" +puts " hopeless? convention, so kim's revoked key discards even if" +puts " nobody remembered to list AuthenticationError - the error's own" +puts " testimony backstops the macro. and the adapter is 40 lines" +puts " because both vocabularies were already talking about the same" +puts " three ideas: try again, give up, or ask a human. meet your" +puts " team where they are; the framework doesn't mind the costume." diff --git a/examples/journal_tail.rb b/examples/journal_tail.rb new file mode 100644 index 0000000..7b27e0a --- /dev/null +++ b/examples/journal_tail.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +# The Journal Tail Pager: production journals grow like production +# tables, and the question asked of both is always the same one - +# "what happened RECENTLY?" Answering it by replaying the whole file +# is SELECT * wearing a filesystem costume. This pager reads pages +# from the END, backwards, in fixed-size chunks: page 1 costs +# kilobytes no matter how many megabytes the journal holds. +# +# bundle exec ruby examples/journal_tail.rb +# +# Runs offline; a 20,000-event journal is built, then barely read. + +require_relative "../lib/agentic" +require "tmpdir" +require "json" + +# Kaminari's lesson, ported: a page knows its items AND how to reach +# the one before it (the cursor is a byte offset, not a page number - +# offsets don't shift when new events append) +class JournalTailPager + CHUNK = 16 * 1024 + + Page = Struct.new(:events, :prev_cursor, :bytes_read, keyword_init: true) + + def initialize(path) + @path = path + end + + def last_page(per: 50) = page_before(File.size(@path), per: per) + + def page_before(cursor, per: 50) + lines = [] + bytes_read = 0 + position = cursor + buffer = +"" + + while position.positive? && lines.size <= per + step = [CHUNK, position].min + position -= step + chunk = File.open(@path, "rb") { |f| + f.seek(position) + f.read(step) + } + bytes_read += step + buffer = chunk + buffer + lines = buffer.split("\n", -1) + end + + # First fragment may be a partial line unless we hit file start + complete = position.zero? ? lines : lines.drop(1) + complete = complete.reject(&:empty?) + page_lines = complete.last(per) + consumed = page_lines.sum(&:bytesize) + page_lines.size + events = page_lines.filter_map do |l| + JSON.parse(l, symbolize_names: true) + rescue JSON::ParserError + nil + end + Page.new(events: events, prev_cursor: cursor - consumed, bytes_read: bytes_read) + end +end + +# --- build a big journal --------------------------------------------------------- +path = File.join(Dir.tmpdir, "agentic_tail.journal.jsonl") +File.delete(path) if File.exist?(path) +journal = Agentic::ExecutionJournal.new(path: path, fsync_every: 500) +20_000.times do |i| + journal.record(:task_succeeded, task_id: "t#{i}", description: "job:#{i % 40}", duration: 0.01, output: nil) +end +journal.sync +total_size = File.size(path) + +puts "THE JOURNAL TAIL PAGER (#{total_size / 1024}KB journal, 20,000 events)" +puts + +pager = JournalTailPager.new(path) +started = Process.clock_gettime(Process::CLOCK_MONOTONIC) +page = pager.last_page(per: 50) +page_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000 + +started = Process.clock_gettime(Process::CLOCK_MONOTONIC) +Agentic::ExecutionJournal.replay(path: path) +full_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000 + +puts format(" last page (50 events): %6.1fms, %5dKB read (%s .. %s)", + page_ms, page.bytes_read / 1024, page.events.first[:task_id], page.events.last[:task_id]) +puts format(" full replay (control): %6.1fms, %5dKB read", full_ms, total_size / 1024) +puts + +# Walk two more pages backwards - the cursor is a byte offset +page2 = pager.page_before(page.prev_cursor, per: 50) +page3 = pager.page_before(page2.prev_cursor, per: 50) +puts " paging backwards through history:" +[page, page2, page3].each_with_index do |p, i| + puts format(" page %d: %s .. %s (%d events)", i + 1, p.events.first[:task_id], p.events.last[:task_id], p.events.size) +end +puts +puts format(" the arithmetic: page 1 cost %dKB of a %dKB file (%.1f%%) and", page.bytes_read / 1024, total_size / 1024, page.bytes_read * 100.0 / total_size) +puts format(" ran %.0fx faster than full replay. the cursor is a BYTE OFFSET,", full_ms / page_ms) +puts " not a page number - kaminari taught everyone what OFFSET 19950" +puts " costs on a growing table, and the same lesson holds for growing" +puts " files: numbered pages shift when rows append; cursors don't." +puts " full replay remains the right tool for RESUME (you need all" +puts " completions); the pager is the right tool for LOOKING (the" +puts " incident was ten minutes ago, not ten thousand events ago)." diff --git a/examples/money_discipline.rb b/examples/money_discipline.rb new file mode 100644 index 0000000..d64ad56 --- /dev/null +++ b/examples/money_discipline.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +# Money Discipline: every money bug in production is the same three +# bugs - floats for currency, arithmetic before validation, and +# rounding decided at the last minute by whoever's line of code got +# there first. This runs an invoicing plan twice: once the way demos +# do it (floats), once the way ledgers demand (integer cents, a Money +# value object, rounding policy declared at the boundary) - and lets +# a penny audit judge them both. +# +# bundle exec ruby examples/money_discipline.rb +# +# Runs offline; the discrepancy is real IEEE 754, not contrivance. + +require_relative "../lib/agentic" + +Agentic.logger.level = :fatal + +LINE_ITEMS = [ + {desc: "api calls", unit_price: 0.1, qty: 3}, + {desc: "storage", unit_price: 29.99, qty: 3}, + {desc: "seats", unit_price: 19.99, qty: 7} +].freeze +TAX_RATE = 0.0825 + +# --- the demo version: floats all the way down ---------------------------------- +def float_invoice(items) + subtotal = items.sum { |i| i[:unit_price] * i[:qty] } + tax = subtotal * TAX_RATE + {subtotal: subtotal, tax: tax, total: subtotal + tax} +end + +# --- the ledger version: integer cents + one rounding policy -------------------- +# Money is a value object: cents inside, arithmetic closed, rounding +# NAMED (banker's here) and applied exactly where policy says +Money = Struct.new(:cents) do + def +(other) = Money.new(cents + other.cents) + + def *(other) = Money.new((cents * other).round(half: :even)) + + def to_s = format("$%d.%02d", cents / 100, cents % 100) +end + +def cents(dollars) = Money.new((dollars * 100).round) + +CONTRACT = Agentic::CapabilitySpecification.new( + name: "invoice", description: "Price an invoice", version: "1.0.0", + inputs: {items: {type: "array", required: true, non_empty: true}}, + outputs: { + subtotal_cents: {type: "integer", required: true, min: 0}, + tax_cents: {type: "integer", required: true, min: 0}, + total_cents: {type: "integer", required: true, min: 0} + }, + rules: { + adds_up: {message: "total must equal subtotal + tax, to the penny", + fields: [:subtotal_cents, :tax_cents, :total_cents], + check: ->(o) { o[:total_cents] == o[:subtotal_cents] + o[:tax_cents] }} + } +) + +def ledger_invoice(items) + subtotal = items.map { |i| cents(i[:unit_price]) * i[:qty] }.sum(Money.new(0)) + tax = subtotal * TAX_RATE + {subtotal_cents: subtotal.cents, tax_cents: tax.cents, total_cents: (subtotal + tax).cents} +end + +# Run both as plan tasks; validate only the ledger (floats can't even +# SIGN the contract - integer cents is a type, and types are promises) +orchestrator = Agentic::PlanOrchestrator.new +float_task = Agentic::Task.new(description: "float invoice", agent_spec: {"name" => "f", "instructions" => "w"}, payload: LINE_ITEMS) +ledger_task = Agentic::Task.new(description: "ledger invoice", agent_spec: {"name" => "l", "instructions" => "w"}, payload: LINE_ITEMS) +orchestrator.add_task(float_task, agent: ->(t) { float_invoice(t.payload) }) +orchestrator.add_task(ledger_task, agent: ->(t) { ledger_invoice(t.payload) }) +result = orchestrator.execute_plan + +floats = result.task_result(float_task.id).output +ledger = result.task_result(ledger_task.id).output + +puts "MONEY DISCIPLINE (same invoice, two arithmetics)" +puts +puts format(" %-12s %-28s %s", "", "float version", "ledger version") +puts format(" %-12s %-28.14f %s", "subtotal", floats[:subtotal], Money.new(ledger[:subtotal_cents])) +puts format(" %-12s %-28.14f %s", "tax", floats[:tax], Money.new(ledger[:tax_cents])) +puts format(" %-12s %-28.14f %s", "total", floats[:total], Money.new(ledger[:total_cents])) +puts + +Agentic::CapabilityValidator.new(CONTRACT).validate_outputs!(ledger) +puts " the ledger version signed its contract: integer cents, all" +puts " non-negative, and the adds_up rule verified to the penny." +puts +float_cents = (floats[:total] * 100).round +puts " now read the float column like an accountant: the subtotal ends" +puts format(" in ...%s, because 0.1 x 3 is not 0.3 in binary - IEEE 754", format("%.14f", floats[:subtotal])[-6..]) +puts " is already paying out interest. today it rounds to the" +puts " right penny (#{float_cents}); at some other quantity or rate it won't," +puts " and the discrepancy will surface in a reconciliation report" +puts " eleven months from now, assigned to whoever touched the code" +puts " last. the discipline is three sentences: money is integer" +puts " cents (a TYPE the contract can enforce - \"integer\" isn't" +puts " pedantry, it's a tripwire); rounding is a NAMED policy applied" +puts " at declared points (banker's, at multiplication), not an" +puts " accident of printf; and the books must balance BY RULE" +puts " (adds_up), not by hope. take my money - but count it in cents." diff --git a/examples/plan_server.rb b/examples/plan_server.rb new file mode 100644 index 0000000..a44b5d7 --- /dev/null +++ b/examples/plan_server.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +# The Plan Server: a server is three disciplines wearing one process - +# accept concurrently, share resources safely, and above all SHUT DOWN +# WELL. This serves plan executions over a real socket with a thread +# pool, a shared (mutexed) rate limit across all request threads, and +# the part everyone skips: a graceful drain where in-flight requests +# finish, new ones are refused, and the process exits clean. +# +# bundle exec ruby examples/plan_server.rb +# +# Runs offline; the socket is loopback, the clients are threads. + +require_relative "../lib/agentic" +require "socket" +require "json" + +Agentic.logger.level = :fatal + +class PlanServer + def initialize(workers: 3) + @server = TCPServer.new("127.0.0.1", 0) # ephemeral port + @workers = workers + @quota = Agentic::RateLimit.new(100, per: 60) # shared across ALL request threads + @draining = false + @in_flight = 0 + @served = 0 + @lock = Mutex.new + end + + def port = @server.addr[1] + + attr_reader :served + + def start + @threads = @workers.times.map do + Thread.new do + loop do + socket = begin + @server.accept + rescue IOError + break # listener closed: drain mode + end + handle(socket) + end + end + end + end + + # The graceful drain: stop the LISTENER first (new connections get + # refused by the OS), then wait for in-flight work, then exit + def drain + @lock.synchronize { @draining = true } + @server.close + @threads.each(&:join) + end + + private + + def handle(socket) + @lock.synchronize { @in_flight += 1 } + goal = socket.gets&.strip + + unless @quota.try_acquire + socket.puts JSON.generate({error: "quota exhausted", retry_after: 60}) + return + end + + orchestrator = Agentic::PlanOrchestrator.new + fetch = Agentic::Task.new(description: "fetch", agent_spec: {"name" => "f", "instructions" => "w"}) + answer = Agentic::Task.new(description: "answer", agent_spec: {"name" => "a", "instructions" => "w"}) + orchestrator.add_task(fetch, agent: ->(_t) { + sleep(0.02) + goal.to_s.split.size + }) + orchestrator.add_task(answer, [fetch], agent: ->(t) { "processed #{t.previous_output} words" }) + result = orchestrator.execute_plan + + socket.puts JSON.generate({goal: goal, answer: result.task_result(answer.id).output}) + @lock.synchronize { @served += 1 } + ensure + @lock.synchronize { @in_flight -= 1 } + socket.close + end +end + +server = PlanServer.new(workers: 3) +server.start + +puts "THE PLAN SERVER (loopback:#{server.port}, 3 worker threads, shared quota)" +puts + +# --- clients: a burst of concurrent requests ------------------------------------- +responses = 8.times.map { |i| + Thread.new do + TCPSocket.open("127.0.0.1", server.port) do |s| + s.puts "summarize ticket number #{i} for the weekly report" + JSON.parse(s.gets, symbolize_names: true) + end + end +}.map(&:value) + +puts " burst of 8 concurrent requests, 3 workers:" +responses.first(3).each { |r| puts " #{r[:answer]} (#{r[:goal][0, 30]}...)" } +puts " ... #{responses.count { |r| r[:answer] }} of 8 answered" +puts + +# --- the drain: one slow request in flight when the order comes ---------------- +slow_client = Thread.new do + TCPSocket.open("127.0.0.1", server.port) do |s| + s.puts "one last long report before the deploy" + JSON.parse(s.gets, symbolize_names: true) + end +end +sleep(0.01) # let it get in the door +drained_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) +server.drain +drain_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - drained_at) * 1000).round +last = slow_client.value + +puts " graceful drain with one request in flight:" +puts " in-flight request completed: #{last[:answer].inspect}" +puts " drain took #{drain_ms}ms; total served: #{server.served}; refused after: connection refused" +puts +puts " the order of operations IS the grace: close the LISTENER first" +puts " (the OS starts refusing for you - no accept race), let workers" +puts " finish what they hold, join, exit. kill -9 has none of these" +puts " steps, which is why deploys under it drop the request that was" +puts " 42 seconds into a 43-second plan. the shared quota is the other" +puts " server lesson: request threads are REAL threads, and the" +puts " windowed limiter holds because round 12 gave its bookkeeping a" +puts " real Mutex - a server is where every thread-safety promise in" +puts " your dependency tree gets called at once." diff --git a/examples/plans_as_automata.rb b/examples/plans_as_automata.rb new file mode 100644 index 0000000..04ac373 --- /dev/null +++ b/examples/plans_as_automata.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +# Plans as Automata: strip away the agents and the LLMs and a plan is +# a transition system - states are sets of completed tasks, and each +# step completes one task whose dependencies are satisfied. Which +# means questions about plans ("can it finish?", "must it finish?", +# "what can run together?") aren't matters of testing or opinion: +# they're REACHABILITY, and small plans let us compute the entire +# state space and simply look. +# +# bundle exec ruby examples/plans_as_automata.rb +# +# Runs offline; the whole state machine is enumerated, then judged. + +require_relative "../lib/agentic" +require "set" + +def task_named(name) + Agentic::Task.new(description: name, agent_spec: {"name" => name, "instructions" => "w"}) +end + +def diamond + o = Agentic::PlanOrchestrator.new + a, b, c, d = %w[a b c d].map { |n| task_named(n) } + o.add_task(a) + o.add_task(b, [a]) + o.add_task(c, [a]) + o.add_task(d, [b, c]) + o +end + +def cyclic + o = Agentic::PlanOrchestrator.new + x = task_named("x") + y = task_named("y") + o.add_task(x, [y.id]) + o.add_task(y, [x]) + o +end + +# The operational semantics, in one method: from a state (set of done +# tasks), any task whose deps are all done may fire next +def steps(graph, done) + graph[:tasks].keys.reject { |t| done.include?(t) } + .select { |t| graph[:dependencies][t].all? { |d| done.include?(d) } } +end + +# Enumerate the full transition system by breadth-first search +def state_space(graph) + names = graph[:tasks].transform_values(&:description) + initial = Set.new + seen = {initial => []} + frontier = [initial] + until frontier.empty? + state = frontier.shift + steps(graph, state).each do |task| + next_state = state | [task] + unless seen.key?(next_state) + seen[next_state] = [] + frontier << next_state + end + seen[state] << names[task] + end + end + seen +end + +def judge(title, orchestrator) + graph = orchestrator.graph + space = state_space(graph) + all = graph[:tasks].keys.to_set + final = space.keys.select { |s| steps(graph, s).empty? } + complete = final.select { |s| s == all } + stuck = final - complete + + puts " #{title}:" + puts " reachable states: #{space.size} (of #{2**all.size} conceivable subsets)" + puts " terminal states: #{final.size} -> #{complete.size} complete, #{stuck.size} stuck" + if stuck.any? + names = graph[:tasks].transform_values(&:description) + stuck.each { |s| puts " STUCK at {#{s.map { |t| names[t] }.sort.join(", ")}} - no task can ever fire" } + end + widest = space.keys.max_by { |s| steps(graph, s).size } + puts " max choice: #{steps(graph, widest).size} tasks ready at once from one state" + puts + [space, complete, stuck] +end + +puts "PLANS AS AUTOMATA (the whole state space, enumerated)" +puts +space, complete, = judge("the diamond (a -> b,c -> d)", diamond) +_, complete2, stuck2 = judge("the cycle (x <-> y)", cyclic) + +puts " what enumeration buys that testing cannot: the diamond's #{space.size}" +puts " reachable states include EVERY execution order the scheduler" +puts " could ever choose - both b-then-c and c-then-b paths converge," +puts " so completion isn't 'observed in CI', it's TOTAL: all runs" +puts " reach {a,b,c,d}, by exhaustion of a 6-state space rather than" +puts " by sampling it. the cycle tells the opposite story with the" +puts " same rigor: its only terminal state is the empty set - not one" +puts " task can EVER fire - which is why round 9's depth invariant" +puts " had to excuse itself on cycles: there is no altitude in a" +puts " building with no floors. plans are small automata; for small" +puts " automata, don't argue about behavior - enumerate it. (at 40" +puts " tasks the state space outgrows the universe; that's what the" +puts " invariant provers are for. know which regime you're in.)" + +exit((complete.any? && stuck2.any? && complete2.empty?) ? 0 : 1) diff --git a/examples/ractor_shareability.rb b/examples/ractor_shareability.rb new file mode 100644 index 0000000..17c26e1 --- /dev/null +++ b/examples/ractor_shareability.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +# The Ractor Shareability Audit: `freeze` is a promise about one +# object; Ractor.shareable? is a promise about everything it can +# reach. The graph API says "frozen snapshot" - this audit asks the +# stricter question: which framework values could cross a Ractor +# boundary TODAY, which need make_shareable's deep freeze, and which +# can never go because they hold live machinery? +# +# bundle exec ruby examples/ractor_shareability.rb +# +# Runs offline; verdicts come from Ractor itself, not from reading code. + +require_relative "../lib/agentic" + +Agentic.logger.level = :fatal +Warning[:experimental] = false # Ractor is experimental; the audit knows + +def task_named(name) + Agentic::Task.new(description: name, agent_spec: {"name" => name, "instructions" => "w"}) +end + +orchestrator = Agentic::PlanOrchestrator.new +a = task_named("a") +b = task_named("b") +orchestrator.add_task(a) +orchestrator.add_task(b, [a]) + +spec = Agentic::CapabilitySpecification.new( + name: "quote", description: "q", version: "1.0.0", + inputs: {mode: {type: "string", required: true, enum: %w[air sea]}}, + rules: {gate: {relation: :requires, fields: [:mode]}} +) + +SUBJECTS = { + "graph snapshot" => orchestrator.graph, + "graph[:order]" => orchestrator.graph[:order], + "graph[:stats]" => orchestrator.graph[:stats], + "to_json_schema output" => spec.to_json_schema, + "a Task object" => a, + "TaskResult.success" => Agentic::TaskResult.new(task_id: "t", success: true, output: "x"), + "a RateLimit" => Agentic::RateLimit.new(2) +}.freeze + +# One verdict per subject, on a COPY wherever possible - an auditor +# that deep-freezes the system under audit is contaminating its own +# evidence (the first draft of this file did exactly that) +def verdict(value) + frozen = value.frozen? + return [frozen, true, "(already crosses)"] if Ractor.shareable?(value) + + copy = begin + Marshal.load(Marshal.dump(value)) + rescue TypeError + nil # holds procs, mutexes, IO - unmarshalable machinery + end + + after = if copy + begin + Ractor.make_shareable(copy) + "a deep-frozen copy crosses" + rescue Ractor::Error, TypeError => e + "refused: #{e.class.name.split("::").last}" + end + else + begin + Ractor.make_shareable(value) + "deep-frozen IN PLACE (mutates the original!)" + rescue Ractor::Error, TypeError + "REFUSED: holds live machinery" + end + end + [frozen, false, after] +end + +puts "THE RACTOR SHAREABILITY AUDIT (frozen is not the same promise)" +puts +puts format(" %-24s %-8s %-11s %s", "value", "frozen?", "shareable?", "after make_shareable") +SUBJECTS.each do |name, value| + frozen, shareable, after = verdict(value) + puts format(" %-24s %-8s %-11s %s", name, frozen, shareable, after) +end + +# --- the payoff: ship a shareable value to a real Ractor ------------------------- +schema = Ractor.make_shareable(spec.to_json_schema) +answer = Ractor.new(schema) { |s| "checked #{s["properties"].size} properties in another Ractor" }.take + +puts +puts " proof of travel: #{answer}" +puts +puts " the audit's grammar lesson: graph[:order] and graph[:stats] are" +puts " data all the way down and cross as-is. the full snapshot is" +puts " 'frozen' but REACHES unfrozen Task objects - a top-floor promise" +puts " on a building with unlocked doors below; a deep-frozen COPY" +puts " crosses fine, and copies are what you should send anyway. the" +puts " RateLimit is the honest REFUSAL: it holds a real Mutex, and no" +puts " amount of freezing turns a lock into a value - it's a machine," +puts " not a fact. that's the Ractor pattern in one line: send facts," +puts " keep machines. and note the auditor's own first-draft sin," +puts " preserved in the comment above: it deep-froze the system under" +puts " audit and contaminated row after row - Ractor.shareable? is" +puts " ruby's strictest freeze referee, and referees must not tamper" +puts " with the evidence." diff --git a/examples/rbs_export.rb b/examples/rbs_export.rb new file mode 100644 index 0000000..40d0584 --- /dev/null +++ b/examples/rbs_export.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +# The RBS Export: a capability contract already knows its types - +# it validates them at runtime on every call. RBS is the same +# knowledge written down for tools that read instead of run: steep, +# IDEs, docs. This generates .rbs signatures from contracts, so the +# type checker and the validator can never disagree - they're +# projections of one declaration. +# +# bundle exec ruby examples/rbs_export.rb +# +# Runs offline; the signatures are printed and self-checked. + +require_relative "../lib/agentic" + +# Contract type -> RBS type. Optional inputs may be omitted entirely, +# so they project as optional KEYS (key: ?), while nilability is a +# separate question the contract answers with its type check. +RBS_TYPES = { + "string" => "String", "number" => "Numeric", "integer" => "Integer", + "boolean" => "bool", "array" => "Array[untyped]", "object" => "Hash[Symbol, untyped]", + "hash" => "Hash[Symbol, untyped]", nil => "untyped" +}.freeze + +def rbs_record(declared) + fields = declared.map { |key, decl| + marker = decl[:required] ? "" : "?" + "#{marker}#{key}: #{RBS_TYPES.fetch(decl[:type], "untyped")}" + } + "{ #{fields.join(", ")} }" +end + +def to_rbs(spec) + method_name = spec.name.gsub(/[^a-z0-9_]/, "_") + <<~RBS + # #{spec.description} (v#{spec.version}) + # Enum/bounds/rules are enforced at runtime by CapabilityValidator; + # RBS carries the SHAPE, the validator carries the LAW. + class #{method_name.split("_").map(&:capitalize).join}Capability + def call: (#{rbs_record(spec.inputs)} inputs) -> #{rbs_record(spec.outputs)} + end + RBS +end + +SPECS = [ + Agentic::CapabilitySpecification.new( + name: "quote_shipping", description: "Quote a shipment", version: "2.1.0", + inputs: { + mode: {type: "string", required: true, enum: %w[air sea road]}, + weight_kg: {type: "number", required: true, min: 1, max: 5_000}, + express: {type: "boolean"}, + customs_code: {type: "string"} + }, + outputs: {price_cents: {type: "integer", required: true}, carrier: {type: "string", required: true}} + ), + Agentic::CapabilitySpecification.new( + name: "classify_ticket", description: "Route a ticket", version: "1.1.0", + inputs: {text: {type: "string", required: true, non_empty: true}, urgency: {type: "number"}}, + outputs: {queue: {type: "string", required: true}} + ) +].freeze + +puts "THE RBS EXPORT (contracts already know their types; write them down)" +puts +SPECS.each do |spec| + to_rbs(spec).lines.each { |line| puts " #{line}" } + puts +end + +# --- the agreement check: what RBS says optional, the validator permits --------- +# (Same discipline as round 10's projection prover: two renderings of +# one declaration must be spot-checked against each other.) +spec = SPECS.first +validator = Agentic::CapabilityValidator.new(spec) +optional_omitted = {mode: "air", weight_kg: 100} # express, customs_code omitted +required_omitted = {mode: "air"} # weight_kg missing + +validator.validate_inputs!(optional_omitted) +agreement_a = true +agreement_b = begin + validator.validate_inputs!(required_omitted) + false # validator allowed what RBS marks required - disagreement! +rescue Agentic::Errors::ValidationError + true +end + +puts " agreement spot-check against the validator:" +puts " omitting ?-marked keys (express, customs_code): accepted #{agreement_a ? "- agrees" : "DISAGREES"}" +puts " omitting an unmarked key (weight_kg): rejected #{agreement_b ? "- agrees" : "DISAGREES"}" +puts +puts " the division of labor, stated precisely: RBS carries the SHAPE" +puts " (keys, types, optionality - what steep and your IDE can check" +puts " before anything runs), and the validator carries the LAW (enums," +puts " bounds, cross-field rules - what needs values to judge). neither" +puts " replaces the other; both project from ONE declaration, which is" +puts " why they cannot drift the way hand-written sig files against" +puts " hand-written validations always, always do. gradual typing works" +puts " when the types come from where the truth already lives." +exit((agreement_a && agreement_b) ? 0 : 1) diff --git a/examples/stdlib_census.rb b/examples/stdlib_census.rb new file mode 100644 index 0000000..fd232a5 --- /dev/null +++ b/examples/stdlib_census.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +# The Stdlib Census: "it's in the standard library" is a statement +# with a shelf life. Default gems become bundled gems on a published +# schedule, and every `require` your gem makes is either covered by +# ruby itself, covered by your gemspec, or a warning that hasn't +# fired yet. This census reads lib/'s requires, cross-checks the +# gemspec, and flags everything the next Ruby will bill you for. +# +# bundle exec ruby examples/stdlib_census.rb +# +# Runs offline; exits 1 if any require is covered by nobody. + +LIB = File.expand_path("../lib", __dir__) +GEMSPEC = File.read(File.expand_path("../agentic.gemspec", __dir__), encoding: "UTF-8") + +# require name -> gem name, where they differ +GEM_FOR = { + "dry/schema" => "dry-schema", "openai" => "ruby-openai", + "async/semaphore" => "async", "async" => "async" +}.freeze + +# Default gems already promoted to bundled (3.4 wave) or announced +# for promotion (3.5 wave) - require them without declaring them and +# a future ruby upgrade breaks your users' bundle +GEMIFIED = %w[ + ostruct pstore benchmark logger rdoc fiddle irb reline win32ole + csv drb mutex_m base64 bigdecimal getoptlong observer rinda + resolv-replace syslog abbrev nkf +].freeze + +# Genuinely-safe stdlib for the foreseeable schedule +CORE_SAFE = %w[ + json fileutils time date yaml securerandom set singleton net/http + uri open3 stringio tmpdir digest erb forwardable +].freeze + +requires = Dir[File.join(LIB, "**/*.rb")].flat_map { |file| + File.readlines(file, encoding: "UTF-8").filter_map { |line| + name = line[/\Arequire "([^"]+)"/, 1] + [name, File.basename(file)] if name + } +}.group_by(&:first).transform_values { |rows| rows.map(&:last).uniq } + +declared = GEMSPEC.scan(/add_dependency "([^"]+)"/).flatten + +verdicts = requires.keys.sort.map do |name| + gem_name = GEM_FOR[name] || name.split("/").first + verdict = if declared.include?(gem_name) || declared.any? { |d| gem_name.start_with?(d) } + [:declared, "gemspec: #{gem_name}"] + elsif GEMIFIED.include?(name) + declared.include?(name) ? [:declared, "gemspec: #{name}"] : [:gemified, "PROMOTED to bundled gem - declare it or a ruby upgrade breaks the bundle"] + elsif CORE_SAFE.include?(name) + [:core, "default gem, no promotion scheduled"] + else + [:uncovered, "COVERED BY NOBODY - works today by accident"] + end + [name, verdict] +end + +puts "THE STDLIB CENSUS (#{requires.size} distinct requires across lib/)" +puts +order = {uncovered: 0, gemified: 1, declared: 2, core: 3} +verdicts.sort_by { |_, (kind, _)| order[kind] }.each do |name, (kind, note)| + marker = {uncovered: "!!", gemified: " !", declared: " ", core: " "}[kind] + puts format(" %s %-18s %-10s %s", marker, name, kind.to_s.upcase, note) +end + +uncovered = verdicts.count { |_, (kind, _)| kind == :uncovered } +gemified = verdicts.select { |_, (kind, _)| kind == :gemified }.map(&:first) + +puts +puts " the receipts: ostruct was declared during the 3.4 warning wave," +puts " and this census's own first run caught TWO more - logger" +puts " (promoted to bundled in 3.5) and cgi (trimmed to a bundled gem" +puts " in 3.5, used here for CGI.escape) - both now declared in the" +puts " gemspec with comments saying why. the round-11 'time' bug was" +puts " the same lesson at file scope: require what you use; a" +puts " transitive require is a loan, and rubies refinance." +if gemified.any? + puts " still to declare before the next ruby: #{gemified.join(", ")}." +end +puts " release engineering isn't glamorous: it's reading the NEWS file" +puts " of every ruby release AS IF your gem's install matrix depends" +puts " on it, because it does. this census is 60 lines; run it in CI" +puts " and the 3.5 upgrade becomes a non-event instead of an issue" +puts " tracker full of LoadErrors." + +exit(uncovered.zero? ? 0 : 1) diff --git a/examples/tty_status.rb b/examples/tty_status.rb new file mode 100644 index 0000000..1cf8caf --- /dev/null +++ b/examples/tty_status.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +# The TTY Status Board: terminal output is a UI, and UIs are built +# from COMPONENTS - a tree for structure, gauges for progress, badges +# for state - not from puts sprinkled where the mood struck. This +# renders a plan's live state as composed components, three frames of +# it, driven entirely by lifecycle hooks. No curses, no deps: the +# component discipline is the point, not the escape codes. +# +# bundle exec ruby examples/tty_status.rb +# +# Runs offline; frames are captured at plan milestones. + +require_relative "../lib/agentic" + +Agentic.logger.level = :fatal + +# --- tiny components, each one testable alone ----------------------------------- +module UI + BADGES = {pending: "[ ]", running: "[~]", done: "[x]", failed: "[!]"}.freeze + + def self.badge(state) = BADGES.fetch(state) + + def self.gauge(done, total, width: 24) + filled = (total.zero? ? 0 : done * width / total) + "|#{"=" * filled}#{" " * (width - filled)}| #{done}/#{total}" + end + + def self.tree(rows) + rows.map.with_index { |(depth, text), i| + glyph = (i == rows.size - 1) ? "`-- " : "|-- " + (depth == 1) ? text : "#{" " * (depth - 2)}#{glyph}#{text}" + } + end + + def self.frame(title, lines) + width = ([title.size] + lines.map(&:size)).max + 2 + ["+#{"-" * width}+", "| #{title.ljust(width - 1)}|", "+#{"-" * width}+"] + + lines.map { |l| "| #{l.ljust(width - 1)}|" } + ["+#{"-" * width}+"] + end +end + +# --- the board: hook events in, frames out --------------------------------------- +class StatusBoard + def initialize(graph) + @graph = graph + @states = graph[:tasks].keys.to_h { |id| [id, :pending] } + @frames = [] + end + + attr_reader :frames + + def hooks + { + before_task_execution: ->(task_id:, task:) { @states[task_id] = :running }, + after_task_success: ->(task_id:, task:, result:, duration:) { + @states[task_id] = :done + snapshot("after #{task.description}") + }, + after_task_failure: ->(task_id:, task:, failure:, duration:) { + @states[task_id] = :failed + snapshot("after #{task.description} FAILED") + } + } + end + + def snapshot(caption) + rows = @graph[:order].map { |id| + [@graph[:stats][:depth][id], "#{UI.badge(@states[id])} #{@graph[:tasks][id].description}"] + } + done = @states.values.count(:done) + @frames << UI.frame(caption, UI.tree(rows) + ["", UI.gauge(done, @states.size)]) + end +end + +orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 2) +fetch = Agentic::Task.new(description: "fetch feeds", agent_spec: {"name" => "f", "instructions" => "w"}) +parse = Agentic::Task.new(description: "parse entries", agent_spec: {"name" => "p", "instructions" => "w"}) +rank = Agentic::Task.new(description: "rank stories", agent_spec: {"name" => "r", "instructions" => "w"}) +publish = Agentic::Task.new(description: "publish digest", agent_spec: {"name" => "d", "instructions" => "w"}) +orchestrator.add_task(fetch, agent: ->(_t) { sleep(0.01) }) +orchestrator.add_task(parse, [fetch], agent: ->(_t) { sleep(0.01) }) +orchestrator.add_task(rank, [parse], agent: ->(_t) { sleep(0.01) }) +orchestrator.add_task(publish, [rank], agent: ->(_t) { sleep(0.01) }) + +board = StatusBoard.new(orchestrator.graph) +orchestrator2 = Agentic::PlanOrchestrator.new(concurrency_limit: 2, lifecycle_hooks: board.hooks) +[fetch, parse, rank, publish].each_with_index do |task, i| + deps = i.zero? ? [] : [[fetch, parse, rank][i - 1]] + orchestrator2.add_task(task, deps, agent: ->(_t) { sleep(0.005) }) +end +orchestrator2.execute_plan + +puts "THE TTY STATUS BOARD (three frames from one run)" +puts +[0, 1, 3].each do |index| + board.frames[index].each { |line| puts " #{line}" } + puts +end +puts " each piece is a component with one job: badge (state to glyph)," +puts " gauge (counts to bar), tree (depth to indent), frame (lines to" +puts " box) - and the board only composes them. that separation is the" +puts " whole tty-* toolbox philosophy: when the spinner needs to become" +puts " a progress bar, you swap ONE component and no rendering code" +puts " learns about it. the hooks hand over exactly what a UI needs" +puts " (state transitions with names), the graph hands over structure" +puts " (depth, order), and the terminal gets what every user deserves:" +puts " an interface that was designed, not accreted." diff --git a/examples/unix_workers.rb b/examples/unix_workers.rb new file mode 100644 index 0000000..b19bd5b --- /dev/null +++ b/examples/unix_workers.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +# Unix Workers: I like Unix because the operating system already +# solved process supervision and nobody told the frameworks. A +# master preforks N plan workers, work arrives on a pipe, SIGTERM +# means "finish what you hold, then die with dignity", and the +# master reaps every child by PID and exit status. No supervisor +# gem, no thread pool config - fork(2), pipe(2), kill(2), wait(2). +# +# bundle exec ruby examples/unix_workers.rb +# +# Runs offline; every process is real, every signal is real. + +require_relative "../lib/agentic" +require "json" +require "tmpdir" + +Agentic.logger.level = :fatal + +WORKERS = 3 +JOBS = 9 + +# Work arrives on a shared pipe: the kernel does the load balancing +# (whichever worker reads first wins - it's a queue because Unix +# says it's a queue) +reader, writer = IO.pipe +results_reader, results_writer = IO.pipe + +pids = WORKERS.times.map do |n| + fork do + writer.close + results_reader.close + draining = false + trap("TERM") { draining = true } + + journal = Agentic::ExecutionJournal.new(path: File.join(Dir.tmpdir, "agentic_worker_#{n}.jsonl"), fsync_every: 10) + served = 0 + until draining + line = begin + reader.read_nonblock(256) + rescue IO::WaitReadable + sleep(0.005) + next + rescue EOFError + break + end + line.split("\n").each do |job| + orchestrator = Agentic::PlanOrchestrator.new(lifecycle_hooks: journal.lifecycle_hooks) + task = Agentic::Task.new(description: job, agent_spec: {"name" => "w", "instructions" => "w"}) + orchestrator.add_task(task, agent: ->(_t) { + sleep(0.03) + "#{job} done" + }) + orchestrator.execute_plan + served += 1 + end + end + journal.sync + results_writer.puts JSON.generate({worker: n, pid: Process.pid, served: served}) + exit!(0) + end +end +reader.close +results_writer.close + +puts "UNIX WORKERS (master #{Process.pid}, #{WORKERS} preforked children: #{pids.join(", ")})" +puts + +# Feed the pipe as work actually arrives (paced) - a burst-written +# pipe gets drained by whoever reads first, which is a queue but not +# a fair one; arrival pacing is what lets the whole fleet lift +JOBS.times do |i| + writer.puts "job-#{i}" + sleep(0.025) +end +sleep(0.1) # let the fleet finish chewing + +# The deploy: TERM the fleet, then REAP it - by pid, with status +puts " deploy signal: SIGTERM to all workers (finish what you hold, then exit)" +pids.each { |pid| Process.kill("TERM", pid) } +statuses = pids.map { |pid| Process.wait2(pid) } +writer.close + +reports = results_reader.read.lines.map { |l| JSON.parse(l, symbolize_names: true) }.sort_by { |r| r[:worker] } +puts +puts " the reaping (every child accounted for, by pid and exit status):" +statuses.each do |pid, status| + report = reports.find { |r| r[:pid] == pid } + puts format(" pid %-7d exit %-3d served %d job(s)", pid, status.exitstatus, report ? report[:served] : 0) +end +puts format(" total served: %d/%d; unserved jobs stay in the pipe for the NEXT fleet", reports.sum { |r| r[:served] }, JOBS) +puts +puts " count what's NOT here: no supervisor gem, no worker heartbeat" +puts " table, no distributed lock. fork gave us isolation (a worker" +puts " segfault kills ONE plan), the shared pipe gave us a work queue" +puts " with kernel-grade load balancing, TERM-then-wait2 gave us" +puts " deploys that finish in-flight work, and each worker's journal" +puts " (flock'd - the process drill proved it) survives its process." +puts " the operating system is the best framework you already have;" +puts " it's just that its DSL is spelled fork, pipe, kill, and wait." + +clean = statuses.all? { |_, s| s.exitstatus.zero? } && reports.sum { |r| r[:served] } == JOBS +exit(clean ? 0 : 1) diff --git a/examples/write_path_profile.rb b/examples/write_path_profile.rb index 309b2a1..b06eac6 100644 --- a/examples/write_path_profile.rb +++ b/examples/write_path_profile.rb @@ -45,12 +45,11 @@ def bench(events = EVENTS) journal = Agentic::ExecutionJournal.new(path: File.join(dir, "real.jsonl")) real = bench { |i| journal.record(:task_succeeded, PAYLOAD.merge(task_id: "t-#{i}")) } -# The alternative shape: group commit - buffer N, fsync once -group_file = File.open(File.join(dir, "group.jsonl"), "a") -group = bench { |i| - group_file.puts(JSON.generate(PAYLOAD)) - group_file.fsync if (i % 20) == 19 -} +# The alternative promise: group commit, now a real constructor knob +# (fsync_every: - the round-13 release cashing this file's own ask) +group_journal = Agentic::ExecutionJournal.new(path: File.join(dir, "group.jsonl"), fsync_every: 20) +group = bench { |i| group_journal.record(:task_succeeded, PAYLOAD.merge(task_id: "t-#{i}")) } +group_journal.sync # the crash-window closes here, explicitly puts "WRITE PATH PROFILE (#{EVENTS} events per layer, microseconds each)" puts @@ -59,7 +58,7 @@ def bench(events = EVENTS) "+ buffered write" => buffered, "+ flush to kernel" => flushed, "journal.record (flock+fsync)" => real, - "group commit (fsync per 20)" => group + "journal fsync_every: 20" => group } rows.each do |name, us| puts format(" %-30s %9.1fus %s", name, us, "#" * [(Math.log10([us, 1].max) * 12).round, 1].max) @@ -71,10 +70,11 @@ def bench(events = EVENTS) puts " JSON libraries would optimize a rounding error - the other" puts format(" %.1f%% is the price of the fsync, which is to say the price of", 100 - json_share) puts " the journal's ONLY promise (a crash cannot unwrite what record" -puts " returned from). the honest knob is group commit: batch 20" -puts format(" events per fsync and the write drops to %.0fus - but now a crash", group) -puts " can eat up to 19 acknowledged events, so it's not an" -puts " optimization, it's a DIFFERENT PROMISE, and only the caller" -puts " knows which promise their recovery story needs. profile first," -puts " name the tradeoff second, and never let anyone optimize the" -puts " layer the profiler acquitted." +puts " returned from). the honest knob is group commit - and since" +puts " round 13 it's a real constructor argument: fsync_every: 20" +puts format(" drops the write to %.0fus, and the constructor's docs name what", group) +puts " was traded (a crash may eat up to 19 acknowledged events)." +puts " that's the correct shape for a durability tradeoff: explicit," +puts " named, greppable in the diff that chose it - not folklore in a" +puts " wiki. profile first, name the tradeoff second, and never let" +puts " anyone optimize the layer the profiler acquitted." diff --git a/lib/agentic.rb b/lib/agentic.rb index 156577e..884fa7a 100644 --- a/lib/agentic.rb +++ b/lib/agentic.rb @@ -75,7 +75,7 @@ def self.client(config) # Plan and execute a goal in one call - the 80% path # - # @example + # @example Plan and execute a goal (illustrative: requires LLM credentials) # result = Agentic.run("Summarize this week's support tickets") # puts result.results.values.map(&:output) if result.successful? # diff --git a/lib/agentic/agent_assembly_engine.rb b/lib/agentic/agent_assembly_engine.rb index 4a5dd02..fbe2ca1 100644 --- a/lib/agentic/agent_assembly_engine.rb +++ b/lib/agentic/agent_assembly_engine.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "time" # Time#iso8601/Time.parse - require what you use + module Agentic # Engine for assembling agents based on task requirements # @attr_reader [AgentCapabilityRegistry] registry The capability registry diff --git a/lib/agentic/capability_validator.rb b/lib/agentic/capability_validator.rb index b1a12bd..36cffc7 100644 --- a/lib/agentic/capability_validator.rb +++ b/lib/agentic/capability_validator.rb @@ -58,7 +58,8 @@ def validate!(kind, declared, values) capability: @specification.name, kind: kind, violations: violations, - expectations: declared.slice(*violations.keys) + expectations: declared.slice(*violations.keys), + hints: typo_hints(declared, symbolized, violations) ) end end @@ -112,6 +113,21 @@ def validate_rules!(inputs) ) end + # Missing-plus-similar-extra is a typo's signature: for each key the + # caller sent that the contract doesn't declare, look for a close + # match among the MISSING violated keys and diagnose the rename + def typo_hints(declared, given, violations) + # Structural, not string-matched: a violated key the caller never + # sent is missing by definition (dry-schema's message text is not + # an API and must not be load-bearing) + missing = violations.keys - given.keys + extra = given.keys - declared.keys + extra.filter_map do |sent| + match = Suggestions.suggest(sent, missing) + "You sent :#{sent} - did you mean :#{match}?" if match + end + end + # Fail-fast check of every relation-typed rule against the declared # inputs, at construction time def validate_rule_declarations! diff --git a/lib/agentic/errors.rb b/lib/agentic/errors.rb index c02010e..bf7e409 100644 --- a/lib/agentic/errors.rb +++ b/lib/agentic/errors.rb @@ -13,6 +13,22 @@ module Errors # time so misconfiguration fails at boot, not at request time. class ConfigurationError < StandardError; end + # Raised by strict-mode journal replay when a line is torn, + # mis-encoded, or (for task events) missing its identifying keys. + # Tolerant replay - the recovery default - reports damage on the + # replayed state instead of raising. + class JournalDamagedError < StandardError + # @return [Integer] 1-based line number of the damaged line + attr_reader :line_number + + # @param message [String] What was wrong + # @param line_number [Integer] Where it was wrong + def initialize(message, line_number:) + @line_number = line_number + super("#{message} (line #{line_number})") + end + end + # Raised when a capability's inputs or outputs violate its declared # specification. Collects every violation instead of failing on the # first, so callers can fix a bad payload in one round trip. @@ -38,19 +54,29 @@ class ValidationError < StandardError # @return [Array] [{rule:, message:, fields:}, ...] attr_reader :rule_violations + # Typo diagnoses: when a sent key is close to a missing declared + # key, the error says so - "you sent :weight_kilo - did you mean + # :weight_kg?". Missing-plus-similar-extra is a typo's signature, + # and the correction costs one Levenshtein pass at raise time. + # @return [Array] Human hint sentences (possibly empty) + attr_reader :hints + # @param capability [String] The capability name # @param kind [Symbol] :inputs or :outputs # @param violations [Hash{Symbol=>Array}] Messages keyed by attribute # @param expectations [Hash{Symbol=>Hash}] Declarations for violated keys # @param rule_violations [Array] Structured broken-rule records - def initialize(capability:, kind:, violations:, expectations: {}, rule_violations: []) + # @param hints [Array] Typo diagnoses to append to the message + def initialize(capability:, kind:, violations:, expectations: {}, rule_violations: [], hints: []) @capability = capability @kind = kind @violations = violations @expectations = expectations @rule_violations = rule_violations + @hints = hints details = violations.map { |key, messages| "#{key} #{Array(messages).join(", ")}" }.join("; ") + details += ". #{hints.join(" ")}" unless hints.empty? super("Invalid #{kind} for capability '#{capability}': #{details}") end end diff --git a/lib/agentic/execution_journal.rb b/lib/agentic/execution_journal.rb index fc709b9..5e96540 100644 --- a/lib/agentic/execution_journal.rb +++ b/lib/agentic/execution_journal.rb @@ -24,9 +24,17 @@ module Agentic class ExecutionJournal # Replayed journal state: everything a resuming process needs to know ReplayedState = Struct.new( - :plan_id, :status, :completed_task_ids, :failed_task_ids, :outputs, :failures, :events, :descriptions, :durations, :duration_samples, + :plan_id, :status, :completed_task_ids, :failed_task_ids, :outputs, :failures, :events, :descriptions, :durations, :duration_samples, :damage, keyword_init: true ) do + # Whether any lines were torn, mis-encoded, or shape-broken. + # Tolerant replay salvages around damage and reports it here; + # a recovery tool should check this and say so out loud. + # @return [Boolean] + def damaged? + !damage.empty? + end + # Task durations keyed by description - the natural baseline source # for performance regression tooling # @return [Hash{String=>Float}] Description => seconds (latest wins) @@ -70,9 +78,25 @@ def completed_descriptions # @return [String] Absolute path of the journal file attr_reader :path + # @return [Integer] Events per fsync (1 = every event is durable + # before record returns) + attr_reader :fsync_every + # @param path [String] Where to write the journal (created on first event) - def initialize(path:) + # @param fsync_every [Integer] Group-commit knob. 1 (the default) is + # the strong promise: a crash cannot unwrite what record returned + # from. n > 1 amortizes the fsync across n events for ~n-fold write + # throughput - AND a crash may lose up to n-1 acknowledged events. + # That is a different durability contract, not a faster same one; + # choose it only where your recovery story tolerates the gap. + def initialize(path:, fsync_every: 1) + unless fsync_every.is_a?(Integer) && fsync_every >= 1 + raise ArgumentError, "fsync_every must be a positive Integer, got #{fsync_every.inspect}" + end + @path = File.expand_path(path) + @fsync_every = fsync_every + @writes = 0 @mutex = Mutex.new FileUtils.mkdir_p(File.dirname(@path)) end @@ -100,6 +124,11 @@ def lifecycle_hooks(hooks = {}) end # Appends an event to the journal - locked, flushed, and fsynced + # + # @note Concurrency contract: thread-safe (a Mutex serializes + # writers in-process) AND cross-process safe (flock serializes + # writers across processes). Pinned by the concurrency contract + # spec and the threads/process drills. # @param event [Symbol, String] The event name # @param payload [Hash] Event data (must be JSON-serializable) # @return [void] @@ -111,15 +140,35 @@ def record(event, payload = {}) file.flock(File::LOCK_EX) file.puts(line) file.flush - file.fsync + file.fsync if ((@writes += 1) % @fsync_every).zero? end end end - # Replays a journal file into resumable state + # Forces durability of everything written so far - call before + # relying on a group-committed journal (e.g. at plan completion) + # @return [void] + def sync + @mutex.synchronize do + File.open(@path, "a") { |file| file.fsync } if File.exist?(@path) + end + end + + # Replays a journal file into resumable state. + # + # The default mode is :tolerant, because the file being replayed + # is - by this class's reason for existing - a file that may end + # mid-write: every whole line is salvaged, and torn, mis-encoded, + # or shape-broken lines are recorded on state.damage instead of + # raising. Recovery tools must never be the second thing that + # fails. Audit tools that WANT to fail on damage pass + # mode: :strict and get a JournalDamagedError naming the line. + # # @param path [String] The journal file to replay + # @param mode [Symbol] :tolerant (salvage + report) or :strict (raise) # @return [ReplayedState] What completed, what failed, and what it produced - def self.replay(path:) + # @raise [Errors::JournalDamagedError] In :strict mode, on the first damaged line + def self.replay(path:, mode: :tolerant) state = ReplayedState.new( plan_id: nil, status: nil, @@ -130,16 +179,36 @@ def self.replay(path:) events: [], descriptions: {}, durations: {}, - duration_samples: Hash.new { |h, k| h[k] = [] } + duration_samples: Hash.new { |h, k| h[k] = [] }, + damage: [] ) return state unless File.exist?(path) + line_number = 0 File.foreach(path) do |line| - line = line.strip - next if line.empty? + line_number += 1 + entry = begin + stripped = line.scrub("�").strip + next if stripped.empty? + + JSON.parse(stripped, symbolize_names: true) + rescue JSON::ParserError, EncodingError => e + if mode == :strict + raise Errors::JournalDamagedError.new("unparseable journal line: #{e.message[0, 60]}", line_number: line_number) + end + state.damage << {line: line_number, reason: e.class.name} + next + end + + if %w[task_succeeded task_failed task_started].include?(entry[:event]) && !entry[:task_id].is_a?(String) + if mode == :strict + raise Errors::JournalDamagedError.new("#{entry[:event]} line missing a String task_id", line_number: line_number) + end + state.damage << {line: line_number, reason: "missing task_id"} + next + end - entry = JSON.parse(line, symbolize_names: true) state.events << entry if entry[:task_id] && entry[:description] state.descriptions[entry[:task_id]] = entry[:description] diff --git a/lib/agentic/learning.rb b/lib/agentic/learning.rb index 31acf46..6d6fb8d 100644 --- a/lib/agentic/learning.rb +++ b/lib/agentic/learning.rb @@ -6,7 +6,7 @@ module Agentic # # @example Using the Learning System components # # Initialize components - # history_store = Agentic::Learning::ExecutionHistoryStore.new + # history_store = Agentic::Learning::ExecutionHistoryStore.new(storage_path: "history") # recognizer = Agentic::Learning::PatternRecognizer.new(history_store: history_store) # optimizer = Agentic::Learning::StrategyOptimizer.new( # pattern_recognizer: recognizer, @@ -27,8 +27,8 @@ module Agentic # # # Optimize strategies # improved_prompt = optimizer.optimize_prompt_template( - # original_template: "Please research the topic: {topic}", - # agent_type: "research_agent" + # "Please research the topic: {topic}", + # "research_agent" # ) module Learning # Factory method to create a complete learning system @@ -68,60 +68,43 @@ def self.create(options = {}) } end - # Register a learning system with a plan orchestrator + # Lifecycle hooks that feed a learning system from plan execution, + # in the same shape ExecutionJournal uses: pass them to + # PlanOrchestrator.new(lifecycle_hooks:), optionally chaining hooks + # you already have. # - # @param plan_orchestrator [PlanOrchestrator] The plan orchestrator to integrate with - # @param learning_system [Hash] The learning system components from Learning.create - # @return [Boolean] true if successfully registered - def self.register_with_orchestrator(plan_orchestrator, learning_system) - # Register execution history tracking - plan_orchestrator.on(:task_completed) do |task, result| - learning_system[:history_store].record_execution( - task_id: task.id, - plan_id: task.context[:plan_id], - agent_type: task.agent_spec&.type, - duration_ms: result.metrics[:duration_ms], - success: result.success?, - metrics: result.metrics, - context: { - task_description: task.description, - task_type: task.type, - input_size: task.input ? task.input.to_s.length : 0 - } + # (This replaces the never-functional register_with_orchestrator, + # which called an #on API the orchestrator never had - hooks are a + # construction-time seam, so the learning system meets the + # orchestrator there.) + # + # @param learning_system [Hash] Components from Learning.create + # @param hooks [Hash] Existing hooks to invoke after recording + # @return [Hash] Lifecycle hooks for PlanOrchestrator.new + def self.lifecycle_hooks(learning_system, hooks = {}) + store = learning_system.fetch(:history_store) + record = lambda do |task_id:, task:, duration:, success:, metrics: {}| + store.record_execution( + task_id: task_id, + agent_type: task.agent_spec.is_a?(Hash) ? task.agent_spec["name"] : task.agent_spec&.name, + duration_ms: (duration * 1000).round, + success: success, + metrics: metrics, + context: {task_description: task.description} ) end - plan_orchestrator.on(:plan_completed) do |plan, results| - # Record overall plan execution - task_durations = {} - task_dependencies = {} - - results.each do |task_id, result| - task_durations[task_id] = result.metrics[:duration_ms] if result.metrics[:duration_ms] - end - - # Extract dependencies from plan - plan.tasks.each do |task| - task_dependencies[task.id] = task.dependencies if task.dependencies&.any? + { + after_task_success: lambda do |task_id:, task:, result:, duration:| + record.call(task_id: task_id, task: task, duration: duration, success: true) + hooks[:after_task_success]&.call(task_id: task_id, task: task, result: result, duration: duration) + end, + after_task_failure: lambda do |task_id:, task:, failure:, duration:| + record.call(task_id: task_id, task: task, duration: duration, success: false, + metrics: {failure_type: failure.type}) + hooks[:after_task_failure]&.call(task_id: task_id, task: task, failure: failure, duration: duration) end - - learning_system[:history_store].record_execution( - plan_id: plan.id, - success: results.values.all?(&:success?), - duration_ms: results.values.sum { |r| r.metrics[:duration_ms] || 0 }, - metrics: { - total_tasks: results.size, - successful_tasks: results.values.count(&:success?), - failed_tasks: results.values.count { |r| !r.success? } - }, - context: { - task_durations: task_durations, - task_dependencies: task_dependencies - } - ) - end - - true + } end end end diff --git a/lib/agentic/learning/README.md b/lib/agentic/learning/README.md index d94ffd4..91b9d58 100644 --- a/lib/agentic/learning/README.md +++ b/lib/agentic/learning/README.md @@ -75,10 +75,12 @@ optimizer = learning_system[:strategy_optimizer] The Learning System can be integrated with the PlanOrchestrator to automatically record execution data: ```ruby -orchestrator = Agentic::PlanOrchestrator.new -learning_system = Agentic::Learning.create +learning_system = Agentic::Learning.create(storage_path: "history") -Agentic::Learning.register_with_orchestrator(orchestrator, learning_system) +orchestrator = Agentic::PlanOrchestrator.new( + lifecycle_hooks: Agentic::Learning.lifecycle_hooks(learning_system) +) ``` -This will register event handlers to automatically record task and plan executions. \ No newline at end of file +Hooks are a construction-time seam; the learning system's hooks record every +task success and failure (and chain any hooks you already pass). \ No newline at end of file diff --git a/lib/agentic/learning/capability_optimizer.rb b/lib/agentic/learning/capability_optimizer.rb index 003e15d..128dd7c 100644 --- a/lib/agentic/learning/capability_optimizer.rb +++ b/lib/agentic/learning/capability_optimizer.rb @@ -5,7 +5,7 @@ module Learning # CapabilityOptimizer improves capability implementations and composition # based on execution history and performance metrics. # - # @example Optimizing a capability implementation + # @example Optimizing a capability implementation (illustrative: needs a populated capability registry) # history_store = Agentic::Learning::ExecutionHistoryStore.new # recognizer = Agentic::Learning::PatternRecognizer.new(history_store: history_store) # optimizer = Agentic::Learning::CapabilityOptimizer.new( diff --git a/lib/agentic/learning/execution_history_store.rb b/lib/agentic/learning/execution_history_store.rb index 8f4c33d..68ae749 100644 --- a/lib/agentic/learning/execution_history_store.rb +++ b/lib/agentic/learning/execution_history_store.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "time" # Time#iso8601/Time.parse - require what you use + require "date" require "json" require "fileutils" @@ -10,9 +12,9 @@ module Learning # execution metrics and performance data for agent tasks and plans. # # @example Recording a task execution - # history_store = Agentic::Learning::ExecutionHistoryStore.new + # history_store = Agentic::Learning::ExecutionHistoryStore.new(storage_path: "history") # history_store.record_execution( - # task_id: task.id, + # task_id: "task-123", # agent_type: "research_agent", # duration_ms: 1200, # success: true, @@ -20,6 +22,7 @@ module Learning # ) # # @example Retrieving execution history for a specific agent type + # history_store = Agentic::Learning::ExecutionHistoryStore.new(storage_path: "history") # records = history_store.get_history(agent_type: "research_agent") # class ExecutionHistoryStore @@ -220,6 +223,11 @@ def load_records(filters) # Apply filters records = filter_records(records, filters) + # The memory cache and the files overlap by design (cache is the + # hot tier, files the durable one) - the same record must not be + # counted twice or every metric silently doubles + records = records.uniq { |r| r[:id] } + # Sort by timestamp descending records.sort_by { |r| r[:timestamp] }.reverse end diff --git a/lib/agentic/learning/pattern_recognizer.rb b/lib/agentic/learning/pattern_recognizer.rb index 4d9595e..0355243 100644 --- a/lib/agentic/learning/pattern_recognizer.rb +++ b/lib/agentic/learning/pattern_recognizer.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "time" # Time#iso8601/Time.parse - require what you use + module Agentic module Learning # PatternRecognizer identifies patterns and optimization opportunities from execution history. diff --git a/lib/agentic/learning/strategy_optimizer.rb b/lib/agentic/learning/strategy_optimizer.rb index 7658627..36382c7 100644 --- a/lib/agentic/learning/strategy_optimizer.rb +++ b/lib/agentic/learning/strategy_optimizer.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +require "time" # Time#iso8601/Time.parse - require what you use +require "digest" # Digest::MD5 - require what you use + module Agentic module Learning # StrategyOptimizer improves execution strategies based on historical performance data. @@ -7,7 +10,7 @@ module Learning # strategies for tasks, agents, and plans. # # @example Optimizing a prompt template - # history_store = Agentic::Learning::ExecutionHistoryStore.new + # history_store = Agentic::Learning::ExecutionHistoryStore.new(storage_path: "history") # recognizer = Agentic::Learning::PatternRecognizer.new(history_store: history_store) # optimizer = Agentic::Learning::StrategyOptimizer.new( # pattern_recognizer: recognizer, @@ -15,8 +18,8 @@ module Learning # ) # # improved_prompt = optimizer.optimize_prompt_template( - # original_template: "Please research the following topic: {topic}", - # agent_type: "research_agent" + # "Please research the following topic: {topic}", + # "research_agent" # ) # class StrategyOptimizer diff --git a/lib/agentic/persistent_agent_store.rb b/lib/agentic/persistent_agent_store.rb index cf68e56..1f631ec 100644 --- a/lib/agentic/persistent_agent_store.rb +++ b/lib/agentic/persistent_agent_store.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "time" # Time#iso8601/Time.parse - require what you use + require "date" require "json" require "fileutils" diff --git a/lib/agentic/plan_orchestrator.rb b/lib/agentic/plan_orchestrator.rb index 52ebffb..47b0a01 100644 --- a/lib/agentic/plan_orchestrator.rb +++ b/lib/agentic/plan_orchestrator.rb @@ -104,7 +104,10 @@ def add_task(task, dependencies = [], agent: nil, needs: nil) # @raise [ArgumentError] If unknown, already started, or depended upon def remove_task(task) task_id = task.respond_to?(:id) ? task.id : task - raise ArgumentError, "unknown task #{task_id}" unless @tasks.key?(task_id) + unless @tasks.key?(task_id) + known = @tasks.keys + @tasks.values.map(&:description) + raise ArgumentError, "unknown task #{task_id}#{Suggestions.hint(task_id, known)}" + end unless @execution_state[:pending].include?(task_id) raise ArgumentError, "task #{task_id} has already started; only pending tasks can be removed" end @@ -134,7 +137,10 @@ def remove_task(task) # task that isn't in the plan def rewire_task(task, dependencies = [], needs: nil) task_id = task.respond_to?(:id) ? task.id : task - raise ArgumentError, "unknown task #{task_id}" unless @tasks.key?(task_id) + unless @tasks.key?(task_id) + known = @tasks.keys + @tasks.values.map(&:description) + raise ArgumentError, "unknown task #{task_id}#{Suggestions.hint(task_id, known)}" + end unless @execution_state[:pending].include?(task_id) raise ArgumentError, "task #{task_id} has already started; only pending tasks can be rewired" end @@ -144,7 +150,11 @@ def rewire_task(task, dependencies = [], needs: nil) deps |= named.values if named unknown = deps.reject { |dep| @tasks.key?(dep) } - raise ArgumentError, "cannot wire to unknown task(s) #{unknown.join(", ")}" if unknown.any? + if unknown.any? + known = @tasks.keys + @tasks.values.map(&:description) + diagnosed = unknown.map { |dep| "#{dep}#{Suggestions.hint(dep, known)}" } + raise ArgumentError, "cannot wire to unknown task(s) #{diagnosed.join(", ")}" + end @dependencies[task_id] = deps if named @@ -215,7 +225,7 @@ def execute_plan(agent_provider = nil, &agent_factory) @semaphore = Async::Semaphore.new(@concurrency_limit, parent: @barrier) # Track execution start time - @execution_start_time = Time.now + @execution_start_time = monotonic_now # Start with tasks that have no dependencies eligible_tasks = find_eligible_tasks @@ -229,7 +239,7 @@ def execute_plan(agent_provider = nil, &agent_factory) @barrier.wait # Track execution completion time - @execution_end_time = Time.now + @execution_end_time = monotonic_now # Call plan completion hook @lifecycle_hooks[:plan_completed].call( @@ -242,7 +252,7 @@ def execute_plan(agent_provider = nil, &agent_factory) ensure @barrier&.stop # Ensure execution_end_time is set even if an exception occurred - @execution_end_time ||= Time.now + @execution_end_time ||= monotonic_now end # Create and return a PlanExecutionResult @@ -477,7 +487,7 @@ def schedule_task(task_id, agent_provider, semaphore, barrier) # their dependents from within their own slot, so two slot-holders # spawning dependents at a tight concurrency limit would deadlock # waiting for each other's slots. - scheduled_at = Time.now + scheduled_at = monotonic_now async_task = barrier.async do semaphore.acquire do # A task canceled while waiting for its slot must not run - @@ -487,7 +497,7 @@ def schedule_task(task_id, agent_provider, semaphore, barrier) @lifecycle_hooks[:task_slot_acquired].call( task_id: task_id, task: task, - waited: Time.now - scheduled_at + waited: monotonic_now - scheduled_at ) execute_task_in_slot(task_id, task, agent_provider, semaphore, barrier) end @@ -506,7 +516,7 @@ def schedule_task(task_id, agent_provider, semaphore, barrier) # @param barrier [Async::Barrier] Tracks task completion # @return [void] def execute_task_in_slot(task_id, task, agent_provider, semaphore, barrier) - task_start_time = Time.now + task_start_time = monotonic_now # Call before_agent_build hook @lifecycle_hooks[:before_agent_build].call( @@ -514,9 +524,9 @@ def execute_task_in_slot(task_id, task, agent_provider, semaphore, barrier) task: task ) - agent_build_start = Time.now + agent_build_start = monotonic_now agent = resolve_agent(task, agent_provider) - agent_build_duration = Time.now - agent_build_start + agent_build_duration = monotonic_now - agent_build_start # Call after_agent_build hook @lifecycle_hooks[:after_agent_build].call( @@ -527,7 +537,7 @@ def execute_task_in_slot(task_id, task, agent_provider, semaphore, barrier) ) result = task.perform(agent) - task_duration = Time.now - task_start_time + task_duration = monotonic_now - task_start_time # Record result and update state if result.successful? @@ -571,7 +581,7 @@ def execute_task_in_slot(task_id, task, agent_provider, semaphore, barrier) task_id: task_id, task: task, failure: failure, - duration: Time.now - task_start_time + duration: monotonic_now - task_start_time ) Agentic.logger.error("Unexpected error in task #{task_id}: #{e.message}") @@ -721,6 +731,13 @@ def record_task_failure(task_id, failure) # @param from: [Symbol] Current state of the task # @param to: [Symbol] Target state for the task # @return [void] + # Durations are deltas of the monotonic clock, not wall time - + # wall clocks step under NTP, and every baseline downstream + # (journal durations, percentiles) would eat that noise + def monotonic_now + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + def transition_task_state(task_id, from:, to:) return unless @execution_state[from].include?(task_id) diff --git a/lib/agentic/rate_limit.rb b/lib/agentic/rate_limit.rb index a1ea412..944b63c 100644 --- a/lib/agentic/rate_limit.rb +++ b/lib/agentic/rate_limit.rb @@ -45,6 +45,12 @@ def initialize(ceiling, per: nil) end # Runs the block inside the ceiling, waiting for a slot if necessary + # + # @note Concurrency contract: windowed mode (per:) is thread-safe - + # stamp bookkeeping holds a Mutex, and waiting sleeps the calling + # thread or fiber. Concurrency mode (no per:) is FIBER-scoped: it + # waits via Async::Semaphore, so blocking acquisition belongs + # inside one reactor; it is not a cross-thread primitive. # @yield The rate-limited work # @return [Object] The block's return value def acquire @@ -64,6 +70,11 @@ def acquire # for work that should only happen if capacity is to spare - retry # budgets, best-effort refreshes, opportunistic prefetch. A budget # wants to say no, not to make you wait for a yes. + # + # @note Concurrency contract: thread-safe in windowed mode (mutexed + # stamps); in concurrency mode the non-blocking check is safe to + # CALL from any thread but admission is only meaningful within + # the semaphore's reactor. # @yield The admitted work, if any # @return [Boolean] True when admitted (block, if given, was run) def try_acquire(&block) diff --git a/lib/agentic/suggestions.rb b/lib/agentic/suggestions.rb new file mode 100644 index 0000000..32df960 --- /dev/null +++ b/lib/agentic/suggestions.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Agentic + # "Did you mean?" for framework errors. At the moment most errors are + # raised, the framework is holding the list of every valid name - the + # contract knows its fields, the plan knows its tasks. Spending one + # Levenshtein pass there converts a stack trace into a one-keystroke + # fix. The threshold scales with word length and is deliberately + # conservative: a wrong suggestion is worse than none. + module Suggestions + module_function + + # Levenshtein edit distance, single-row implementation + # @param a [String] + # @param b [String] + # @return [Integer] + def distance(a, b) + rows = (0..b.size).to_a + a.each_char.with_index(1) do |ca, i| + previous = rows[0] + rows[0] = i + b.each_char.with_index(1) do |cb, j| + current = rows[j] + rows[j] = [rows[j] + 1, rows[j - 1] + 1, previous + ((ca == cb) ? 0 : 1)].min + previous = current + end + end + rows[b.size] + end + + # The closest candidate within a length-scaled budget, or nil - + # silence beats a confident wrong answer + # @param typo [String, Symbol] What was given + # @param candidates [Enumerable] What was valid + # @return [String, Symbol, nil] + def suggest(typo, candidates) + threshold = (typo.to_s.size / 2).clamp(1, 3) + scored = candidates.map { |candidate| [candidate, distance(typo.to_s, candidate.to_s)] } + scored.select { |_, d| d <= threshold }.min_by { |_, d| d }&.first + end + + # A ready-to-append hint sentence, or an empty string + # @param typo [String, Symbol] What was given + # @param candidates [Enumerable] What was valid + # @return [String] e.g. " (did you mean weight_kg?)" or "" + def hint(typo, candidates) + match = suggest(typo, candidates) + match ? " (did you mean #{match}?)" : "" + end + end +end diff --git a/lib/agentic/task_failure.rb b/lib/agentic/task_failure.rb index 2ca6df3..1c27db6 100644 --- a/lib/agentic/task_failure.rb +++ b/lib/agentic/task_failure.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "time" # Time#iso8601/Time.parse - require what you use + module Agentic # Represents a failure that occurred during task execution # @attr_reader [String] message The failure message diff --git a/spec/agentic/concurrency_contract_spec.rb b/spec/agentic/concurrency_contract_spec.rb new file mode 100644 index 0000000..f55a9cf --- /dev/null +++ b/spec/agentic/concurrency_contract_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require "spec_helper" +require "tmpdir" + +# The concurrency contract: per-method guarantees pinned as specs, not +# just demonstrated by drills. The examples' threads/process drills +# characterize; this file PROMISES - each block names the guarantee its +# @note documentation makes, and fails if the guarantee drifts. +RSpec.describe "concurrency contract" do + describe "ExecutionJournal#record: thread-safe" do + it "interleaves parallel writers without tearing or losing lines" do + path = File.join(Dir.mktmpdir, "contract.journal.jsonl") + journal = Agentic::ExecutionJournal.new(path: path) + + 6.times.map { |t| + Thread.new do + 50.times { |i| journal.record(:task_succeeded, task_id: "t#{t}-#{i}", description: "t#{t}-#{i}", duration: 0.001, output: "x") } + end + }.each(&:join) + + state = Agentic::ExecutionJournal.replay(path: path, mode: :strict) + expect(state.completed_task_ids.size).to eq(300) + end + end + + describe "RateLimit (windowed): thread-safe" do + it "never over-admits under contending threads (try_acquire)" do + limit = Agentic::RateLimit.new(40, per: 60) + + admitted = 8.times.map { + Thread.new { 300.times.count { limit.try_acquire } } + }.sum(&:value) + + expect(admitted).to eq(40) + end + + it "admits every blocking acquire exactly once across threads" do + limit = Agentic::RateLimit.new(4, per: 0.05) + admissions = Queue.new + + 8.times.map { |i| + Thread.new { limit.acquire { admissions << i } } + }.each(&:join) + + expect(admissions.size).to eq(8) + # The high-water mark is part of the promise too: counters share + # the window mutex, so the mark stays truthful under threads + expect(limit.high_water).to be <= 4 + end + end + + describe "RateLimit (concurrency mode): fiber-scoped" do + it "provides blocking acquisition within one reactor" do + limit = Agentic::RateLimit.new(2) + + Sync do + 6.times.map { Async { limit.acquire { sleep(0.005) } } }.each(&:wait) + end + + expect(limit.high_water).to eq(2) + end + + it "documents the boundary: non-blocking calls work from plain threads" do + limit = Agentic::RateLimit.new(1) + + expect(Thread.new { limit.try_acquire }.value).to be(true) + end + end + + describe "AgentCapabilityRegistry: thread-safe" do + it "loses no registrations under parallel register/get" do + registry = Agentic::AgentCapabilityRegistry.instance + + 6.times.map { |t| + Thread.new do + 20.times do |i| + spec = Agentic::CapabilitySpecification.new(name: "contract-#{t}-#{i}", description: "x", version: "1.0.0") + Agentic.register_capability(spec, Agentic::CapabilityProvider.new(capability: spec, implementation: ->(inputs) { inputs })) + end + end + }.each(&:join) + + missing = 6.times.sum { |t| 20.times.count { |i| registry.get_provider("contract-#{t}-#{i}").nil? } } + expect(missing).to eq(0) + end + end + + describe "PlanOrchestrator: single-plan, reactor-resident" do + it "documents the boundary: one orchestrator executes one plan at a time on one reactor" do + # This is the contract's honest edge: the orchestrator is not a + # shared-across-threads object. What IS safe to share are the + # things it emits (graph snapshots - see the Ractor audit) and + # the journals/limiters it is configured with. + orchestrator = Agentic::PlanOrchestrator.new + task = Agentic::Task.new(description: "solo", agent_spec: {"name" => "w", "instructions" => "w"}) + orchestrator.add_task(task, agent: ->(_t) { :ok }) + + expect(orchestrator.execute_plan.status).to eq(:completed) + end + end +end diff --git a/spec/agentic/round13_features_spec.rb b/spec/agentic/round13_features_spec.rb new file mode 100644 index 0000000..5b8e9bf --- /dev/null +++ b/spec/agentic/round13_features_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "spec_helper" +require "tmpdir" + +RSpec.describe "round 13 framework features" do + let(:good_line) { %({"event":"task_succeeded","task_id":"t1","description":"t1","duration":0.1,"output":"ok"}) } + + def journal_file(*lines) + path = File.join(Dir.mktmpdir, "run.journal.jsonl") + File.write(path, lines.join("\n")) + path + end + + describe "tolerant replay" do + it "salvages whole lines around a torn tail and reports the damage" do + path = journal_file(good_line, %({"event":"task_succ)) + + state = Agentic::ExecutionJournal.replay(path: path) + + expect(state.completed_task_ids).to eq(["t1"]) + expect(state).to be_damaged + expect(state.damage).to eq([{line: 2, reason: "JSON::ParserError"}]) + end + + it "survives binary garbage and shape-broken task lines" do + path = journal_file(good_line, "\x00\x01\xFFgarbage", %({"event":"task_succeeded","task_id":42})) + + state = Agentic::ExecutionJournal.replay(path: path) + + expect(state.completed_task_ids).to eq(["t1"]) + expect(state.damage.map { |d| d[:line] }).to eq([2, 3]) + end + + it "reports no damage on a clean journal" do + path = journal_file(good_line) + + expect(Agentic::ExecutionJournal.replay(path: path)).not_to be_damaged + end + end + + describe "strict replay" do + it "raises JournalDamagedError naming the line" do + path = journal_file(good_line, %({"event":"task_succ)) + + expect { + Agentic::ExecutionJournal.replay(path: path, mode: :strict) + }.to raise_error(Agentic::Errors::JournalDamagedError, /line 2/) { |e| + expect(e.line_number).to eq(2) + } + end + + it "raises on shape-broken task events too" do + path = journal_file(%({"event":"task_succeeded","task_id":42})) + + expect { + Agentic::ExecutionJournal.replay(path: path, mode: :strict) + }.to raise_error(Agentic::Errors::JournalDamagedError, /String task_id/) + end + end + + describe "fsync_every group commit" do + it "writes and replays identically under group commit" do + path = File.join(Dir.mktmpdir, "group.journal.jsonl") + journal = Agentic::ExecutionJournal.new(path: path, fsync_every: 20) + + 50.times { |i| journal.record(:task_succeeded, task_id: "t#{i}", description: "t#{i}", duration: 0.001, output: nil) } + journal.sync + + expect(journal.fsync_every).to eq(20) + expect(Agentic::ExecutionJournal.replay(path: path).completed_task_ids.size).to eq(50) + end + + it "rejects a non-positive fsync_every" do + expect { + Agentic::ExecutionJournal.new(path: "x.jsonl", fsync_every: 0) + }.to raise_error(ArgumentError, /positive Integer/) + end + end +end diff --git a/spec/agentic/round15_features_spec.rb b/spec/agentic/round15_features_spec.rb new file mode 100644 index 0000000..cc402dd --- /dev/null +++ b/spec/agentic/round15_features_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "round 15 framework features" do + describe "did-you-mean in ValidationError" do + let(:contract) do + Agentic::CapabilitySpecification.new( + name: "quote", description: "q", version: "1.0.0", + inputs: {mode: {type: "string", required: true}, weight_kg: {type: "number", required: true}} + ) + end + + it "diagnoses a renamed key from missing-plus-similar-extra" do + expect { + Agentic::CapabilityValidator.new(contract).validate_inputs!(mode: "air", weight_kilo: 50) + }.to raise_error(Agentic::Errors::ValidationError) { |error| + expect(error.hints).to eq(["You sent :weight_kilo - did you mean :weight_kg?"]) + expect(error.message).to include("did you mean :weight_kg?") + } + end + + it "stays silent when nothing extra is close to anything missing" do + expect { + Agentic::CapabilityValidator.new(contract).validate_inputs!(mode: "air", banana: true) + }.to raise_error(Agentic::Errors::ValidationError) { |error| + expect(error.hints).to be_empty + } + end + end + + describe "did-you-mean in plan refactoring errors" do + it "suggests the close description when rewiring to an unknown task" do + orchestrator = Agentic::PlanOrchestrator.new + orders = Agentic::Task.new(description: "fetch_orders", agent_spec: {"name" => "w", "instructions" => "w"}) + ledger = Agentic::Task.new(description: "ledger", agent_spec: {"name" => "w", "instructions" => "w"}) + orchestrator.add_task(orders) + orchestrator.add_task(ledger) + + expect { + orchestrator.rewire_task(ledger, ["fetch_order"]) + }.to raise_error(ArgumentError, /fetch_order \(did you mean fetch_orders\?\)/) + end + + it "suggests on remove_task with a typo'd reference" do + orchestrator = Agentic::PlanOrchestrator.new + orders = Agentic::Task.new(description: "fetch_orders", agent_spec: {"name" => "w", "instructions" => "w"}) + orchestrator.add_task(orders) + + expect { + orchestrator.remove_task("fetch_ordrs") + }.to raise_error(ArgumentError, /did you mean fetch_orders\?/) + end + end + + describe "Suggestions" do + it "refuses to guess wildly (threshold scales with length)" do + expect(Agentic::Suggestions.suggest("zz", %w[fetch_orders ledger])).to be_nil + expect(Agentic::Suggestions.hint("zz", %w[fetch_orders])).to eq("") + end + end +end