diff --git a/README.md b/README.md index e80ec88..5946498 100644 --- a/README.md +++ b/README.md @@ -765,10 +765,18 @@ planner = Agentic::TaskPlanner.new("Write a blog post", config) client = Agentic::LlmClient.new(config) ``` +## Examples + +A small set of canonical, offline-runnable examples lives in [`examples/`](examples/README.md) — start with `bundle exec ruby examples/ticket_screener.rb`. + +The full catalog — 144 example programs covering the entire framework surface, plus the persona-driven field notes that shaped it — lives in [codenamev/agentic-examples](https://github.com/codenamev/agentic-examples), with refs marking which Agentic version each snapshot is certified against (`agentic-v0.2.0` matches this release). + ## Development After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. +Two repo referees live in `bin/`: `bundle exec ruby bin/doctest.rb` verifies every code example in the docs runs (or is deliberately annotated as illustrative), and `bundle exec ruby bin/release_rehearsal.rb` builds, audits, cleanly installs, and boots the packaged gem before you tag anything. + ### Running Tests Agentic includes a comprehensive test suite with both unit tests and integration tests. To run all tests: diff --git a/examples/doctest_runner.rb b/bin/doctest.rb similarity index 98% rename from examples/doctest_runner.rb rename to bin/doctest.rb index 0a7be55..4cb9e31 100644 --- a/examples/doctest_runner.rb +++ b/bin/doctest.rb @@ -9,7 +9,7 @@ # "" before a README fence. # Unannotated failure = exit 1. Docs rot at the speed of a red build. # -# bundle exec ruby examples/doctest_runner.rb +# bundle exec ruby bin/doctest.rb # # Runs offline; each snippet gets its own process and tmpdir. diff --git a/bin/release_rehearsal.rb b/bin/release_rehearsal.rb new file mode 100644 index 0000000..9b89bd8 --- /dev/null +++ b/bin/release_rehearsal.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +# The Release Rehearsal: your repo is not your gem. The gem is +# whatever the gemspec PACKAGES, installed into a clean GEM_HOME, +# required by a Ruby that has never seen your working directory - +# and the day to discover a file missing from the package is today, +# on this machine, not release day in someone's CI. Build, audit, +# install, boot: the full ceremony, rehearsed. +# +# bundle exec ruby bin/release_rehearsal.rb +# +# Runs offline; exits 1 if the packaged gem can't do its job. + +require_relative "../lib/agentic" +require "open3" +require "rbconfig" +require "tmpdir" + +failures = [] +stage = Dir.mktmpdir("agentic_rehearsal") + +# --- act 1: build the actual artifact -------------------------------------------- +out, err, status = Open3.capture3("gem", "build", "agentic.gemspec", "--output", File.join(stage, "rehearsal.gem")) +if status.success? + puts " act 1 - gem build: ok (#{File.size(File.join(stage, "rehearsal.gem")) / 1024}KB)" +else + failures << "build failed" + puts " act 1 - gem build FAILED: #{err.lines.last&.strip || out.lines.last&.strip}" +end + +# --- act 2: audit the manifest - every lib file must be aboard ------------------- +spec = Gem::Specification.load("agentic.gemspec") +packaged = spec.files +missing = Dir["lib/**/*.rb"].reject { |f| packaged.include?(f) } +version_ok = spec.version.to_s == Agentic::VERSION +failures << "manifest missing #{missing.size} lib file(s)" if missing.any? +failures << "version drift" unless version_ok +puts " act 2 - manifest audit: #{packaged.size} files packaged; lib coverage #{missing.empty? ? "complete" : "MISSING #{missing.take(3).join(", ")}"}" +puts " version: gemspec #{spec.version} == Agentic::VERSION #{Agentic::VERSION} - #{version_ok ? "agree" : "DRIFT"}" + +# --- act 3: install into a clean GEM_HOME ---------------------------------------- +gem_home = File.join(stage, "gem_home") +_, err, status = Open3.capture3( + {"GEM_HOME" => gem_home}, + "gem", "install", "--local", "--no-document", "--ignore-dependencies", + File.join(stage, "rehearsal.gem") +) +if status.success? + puts " act 3 - clean install: ok (GEM_HOME=#{File.basename(gem_home)})" +else + failures << "install failed" + puts " act 3 - install FAILED: #{err.lines.last&.strip}" +end + +# --- act 4: boot the INSTALLED gem, far from this repo --------------------------- +# The child's load path knows the temp GEM_HOME (for our gem) and the +# host gem path (for dependencies) - and pointedly NOT this repo's lib/ +probe = <<~RUBY + gem "agentic" + require "agentic" + raise "loaded from the repo, not the package!" if Agentic.method(:run).source_location.first.include?("/lib/agentic") && !Agentic.method(:run).source_location.first.include?("gem_home") + orchestrator = Agentic::PlanOrchestrator.new + task = Agentic::Task.new(description: "boot", agent_spec: {"name" => "b", "instructions" => "w"}) + orchestrator.add_task(task, agent: ->(_t) { "the package works" }) + print orchestrator.execute_plan.task_result(task.id).output +RUBY +# Scrub the inherited environment: under `bundle exec`, RUBYOPT +# smuggles bundler/setup into every child, which would quietly put +# THIS REPO back on the load path - the exact contamination the +# rehearsal exists to prevent (and its first run caught) +clean_env = { + "GEM_HOME" => gem_home, + "GEM_PATH" => "#{gem_home}#{File::PATH_SEPARATOR}#{Gem.paths.home}", + "RUBYOPT" => nil, "RUBYLIB" => nil, + "BUNDLE_GEMFILE" => nil, "BUNDLE_BIN_PATH" => nil, "BUNDLER_SETUP" => nil +} +out, err, status = Open3.capture3(clean_env, RbConfig.ruby, "-e", probe, chdir: stage) +if status.success? && out.include?("the package works") + puts " act 4 - boot from the package: \"#{out}\" (repo lib/ never on the path)" +else + failures << "packaged gem failed to boot" + puts " act 4 - boot FAILED: #{err.lines.grep_v(/warning/).last&.strip}" +end + +puts +if failures.empty? + puts " the rehearsal passed all four acts, which certifies the thing" + puts " releases actually ship: not your repo, THE PACKAGE. the classic" + puts " release-day wounds are all rehearsable - a file added without" + puts " `git add` (invisible to git-ls-files manifests), a version.rb" + puts " bumped but gemspec pinned, an implicit load-order dependency" + puts " that only your spec_helper satisfied. rubygems maintenance is" + puts " mostly this lesson at scale: every gem that breaks on install" + puts " worked perfectly in its own repo. rehearse the ceremony in CI" + puts " and release day becomes a tag, not an event." +else + puts " REHEARSAL FAILED: #{failures.join("; ")} - fix before tagging." +end +exit(failures.empty? ? 0 : 1) diff --git a/docs/perspectives/README.md b/docs/perspectives/README.md deleted file mode 100644 index bccb3b6..0000000 --- a/docs/perspectives/README.md +++ /dev/null @@ -1,840 +0,0 @@ -# Ten Rubyist Perspectives on Agentic - -An exercise in multi-perspective review: we embraced the personas of ten of the -most prolific Rubyists, asked how each would use this gem, what they would -build with it, what would delight or confuse them — and then **built the thing -each persona proposed**, taking field notes in character along the way. - -> These are imagined characterizations of public figures, grounded in their -> well-known public work and stated philosophies. They are not the actual -> opinions of the people named. - -## Round 1 — reviewing and repairing the framework - -| # | Persona | Lens | What they built | Field notes | -|---|---------|------|-----------------|-------------| -| 0 | Prologue | The broken baseline | Repaired the truncated test suite | [00-prologue.md](field-notes/00-prologue.md) | -| 1 | Matz | Language design, developer happiness | `examples/haiku_agent.rb` — the three-line agent | [01-matz.md](field-notes/01-matz.md) | -| 2 | DHH | Conceptual compression | `Agentic.run("goal")` — the one-liner | [02-dhh.md](field-notes/02-dhh.md) | -| 3 | Aaron Patterson | Performance, runtime internals | `benchmark/boot.rb` + thread-safe assembly init | [03-tenderlove.md](field-notes/03-tenderlove.md) | -| 4 | Xavier Noria | Code loading correctness | Zeitwerk as the single loader; 19× faster require | [04-fxn.md](field-notes/04-fxn.md) | -| 5 | Samuel Williams | Structured concurrency | Reactor-composable `PlanOrchestrator` | [05-ioquatix.md](field-notes/05-ioquatix.md) | -| 6 | Jeremy Evans | Fail-fast correctness | Fail-fast credential validation, `ConfigurationError` | [06-jeremyevans.md](field-notes/06-jeremyevans.md) | -| 7 | Piotr Solnica | Types and boundaries | Capability input validation against declared schemas | [07-solnic.md](field-notes/07-solnic.md) | -| 8 | Mike Perham | Durability, boring reliability | `ExecutionJournal` — crash-surviving plan state | [08-mperham.md](field-notes/08-mperham.md) | -| 9 | Sandi Metz | Small objects, honest messages | `execute_with_schema` honesty + subclass-safe factory | [09-sandimetz.md](field-notes/09-sandimetz.md) | -| 10 | Andrew Kane | Practical ML gems | Pluggable `web_search` capability backend | [10-ankane.md](field-notes/10-ankane.md) | - -## Round 2 — building *with* the gem - -Each persona then built something novel **using** Agentic as a consumer — -every program under `examples/` runs offline, and the field notes record -what building on the framework actually felt like. - -| # | Persona | Built with the gem | Run it | Field notes | -|---|---------|--------------------|--------|-------------| -| 1 | Matz | Renga circle — dependency graphs as poetic form | `examples/renga_circle.rb` | [round-2/01-matz.md](round-2/01-matz.md) | -| 2 | DHH | HEY-style ticket screener (parallel capability pipeline) | `examples/ticket_screener.rb` | [round-2/02-dhh.md](round-2/02-dhh.md) | -| 3 | Aaron Patterson | Performance Detective — Prism audits the gem's own methods | `examples/performance_detective.rb` | [round-2/03-tenderlove.md](round-2/03-tenderlove.md) | -| 4 | Xavier Noria | Namespace Cartographer — maps constant trees, audits conformance | `examples/namespace_cartographer.rb` | [round-2/04-fxn.md](round-2/04-fxn.md) | -| 5 | Samuel Williams | Latency Lab — measured fan-out scaling + reactor cohabitation | `examples/latency_lab.rb` | [round-2/05-ioquatix.md](round-2/05-ioquatix.md) | -| 6 | Jeremy Evans | Schema Advisor — deterministic DBA rules as capabilities | `examples/schema_advisor.rb` | [round-2/06-jeremyevans.md](round-2/06-jeremyevans.md) | -| 7 | Piotr Solnica | Typed ETL pipeline — contracts stop bad data at named boundaries | `examples/typed_pipeline.rb` | [round-2/07-solnic.md](round-2/07-solnic.md) | -| 8 | Mike Perham | Durable Batch — real `exit!` mid-run, resume without re-paying | `examples/durable_batch.rb` | [round-2/08-mperham.md](round-2/08-mperham.md) | -| 9 | Sandi Metz | Refactoring Dojo — three critics, one prescribed next step | `examples/refactoring_dojo.rb` | [round-2/09-sandimetz.md](round-2/09-sandimetz.md) | -| 10 | Andrew Kane | Gem Scout — search + score pipeline on the pluggable backend | `examples/gem_scout.rb` | [round-2/10-ankane.md](round-2/10-ankane.md) | - -## Round 3 — new experiments on the improved framework - -The round-2 consensus was delivered as a release (task payloads, direct -agents/callables, dependency output piping, provider-optional -`execute_plan`, composed-capability contracts, journal idempotency keys, -and the concurrency documentation — plus a scheduler deadlock fix found -by one of these builds). The personas then built ten *new* things: - -| # | Persona | Built with the improved gem | Run it | Field notes | -|---|---------|------------------------------|--------|-------------| -| 1 | Matz | Telephone game — piping as the whole program | `examples/telephone_game.rb` | [round-3/01-matz.md](round-3/01-matz.md) | -| 2 | DHH | Standup digest — parallel collectors fan into one writer | `examples/standup_digest.rb` | [round-3/02-dhh.md](round-3/02-dhh.md) | -| 3 | Aaron Patterson | Plan Gantt — ASCII execution timeline (found a scheduler deadlock) | `examples/plan_gantt.rb` | [round-3/03-tenderlove.md](round-3/03-tenderlove.md) | -| 4 | Xavier Noria | Documentation surveyor — 90.2% YARD coverage, fan-in report | `examples/doc_coverage.rb` | [round-3/04-fxn.md](round-3/04-fxn.md) | -| 5 | Samuel Williams | Live dashboard — hooks → `Async::Queue` → live renderer | `examples/live_dashboard.rb` | [round-3/05-ioquatix.md](round-3/05-ioquatix.md) | -| 6 | Jeremy Evans | Contract fuzzer — seed-deterministic boundary attack, 34 trials | `examples/contract_fuzzer.rb` | [round-3/06-jeremyevans.md](round-3/06-jeremyevans.md) | -| 7 | Piotr Solnica | Command bus — commands as contract-bearing compositions | `examples/command_bus.rb` | [round-3/07-solnic.md](round-3/07-solnic.md) | -| 8 | Mike Perham | Flaky API drill — retries that provably wait, journaled | `examples/flaky_api_drill.rb` | [round-3/08-mperham.md](round-3/08-mperham.md) | -| 9 | Sandi Metz | Collaboration tracer — plans as sequence diagrams | `examples/collaboration_tracer.rb` | [round-3/09-sandimetz.md](round-3/09-sandimetz.md) | -| 10 | Andrew Kane | Changelog scout — release notes from real git history | `examples/changelog_scout.rb` | [round-3/10-ankane.md](round-3/10-ankane.md) | - -## Round 4 — the asks become grammar - -The round-3 asks shipped as a release (named dependencies via `needs:`, -`Task#previous_output`, the `task_slot_acquired` hook, retry policies -consulting `failure.retryable?`, and contract value predicates — -`enum:`, `min:`/`max:`, `non_empty:`), and ten more experiments followed: - -| # | Persona | Built on the round-4 release | Run it | Field notes | -|---|---------|------------------------------|--------|-------------| -| 1 | Matz | Exquisite corpse — creature parts assembled by name | `examples/exquisite_corpse.rb` | [round-4/01-matz.md](round-4/01-matz.md) | -| 2 | DHH | Setup doctor — the onboarding wiki, deleted | `examples/setup_doctor.rb` | [round-4/02-dhh.md](round-4/02-dhh.md) | -| 3 | Aaron Patterson | Knee finder — measured concurrency recommendations | `examples/knee_finder.rb` | [round-4/03-tenderlove.md](round-4/03-tenderlove.md) | -| 4 | Xavier Noria | Coupling cartographer — the constant-reference force map | `examples/coupling_cartographer.rb` | [round-4/04-fxn.md](round-4/04-fxn.md) | -| 5 | Samuel Williams | Shared rate limit — one credential ceiling across two plans | `examples/shared_rate_limit.rb` | [round-4/05-ioquatix.md](round-4/05-ioquatix.md) | -| 6 | Jeremy Evans | Invariant sentinel — laws checked after every task (found the `:canceled` status bug) | `examples/invariant_sentinel.rb` | [round-4/06-jeremyevans.md](round-4/06-jeremyevans.md) | -| 7 | Piotr Solnica | Contract state machine — enum guards instead of transition tables | `examples/state_machine.rb` | [round-4/07-solnic.md](round-4/07-solnic.md) | -| 8 | Mike Perham | Error taxonomy drill — errors testify about their own retryability | `examples/error_taxonomy_drill.rb` | [round-4/08-mperham.md](round-4/08-mperham.md) | -| 9 | Sandi Metz | Graph critic — design review for dependency graphs, pre-execution | `examples/graph_critic.rb` | [round-4/09-sandimetz.md](round-4/09-sandimetz.md) | -| 10 | Andrew Kane | README verifier — every snippet parsed, every constant resolved (found a 4-round-old broken snippet) | `examples/readme_verifier.rb` | [round-4/10-ankane.md](round-4/10-ankane.md) | - -## Round 5 — the ecosystem turn - -The round-4 asks shipped as a release (`PlanOrchestrator#graph`, -`ValidationError#expectations`, cross-field contract `rules:`, -`Agentic::RateLimit` + `LlmClient limiter:`, jitter-on-by-default), the -three examples that requested them were modernized onto them, and ten -more experiments followed: - -| # | Persona | Built on the round-5 release | Run it | Field notes | -|---|---------|------------------------------|--------|-------------| -| 1 | Matz | Dungeon crawl — the map drawn from the plan itself | `examples/dungeon_crawl.rb` | [round-5/01-matz.md](round-5/01-matz.md) | -| 2 | DHH | Live kanban — the WIP limit is the concurrency limit | `examples/kanban_board.rb` | [round-5/02-dhh.md](round-5/02-dhh.md) | -| 3 | Aaron Patterson | Critical path — which task the wall clock is actually about | `examples/critical_path.rb` | [round-5/03-tenderlove.md](round-5/03-tenderlove.md) | -| 4 | Xavier Noria | Mermaid diagrammer — docs generated from the graph, labeled by `needs:` | `examples/plan_diagram.rb` | [round-5/04-fxn.md](round-5/04-fxn.md) | -| 5 | Samuel Williams | Burst absorber — `RateLimit` characterized under hostile waves | `examples/burst_absorber.rb` | [round-5/05-ioquatix.md](round-5/05-ioquatix.md) | -| 6 | Jeremy Evans | Freight desk — a tariff book as cross-field rules, all violations at once | `examples/freight_rules.rb` | [round-5/06-jeremyevans.md](round-5/06-jeremyevans.md) | -| 7 | Piotr Solnica | 422 generator — one contract-agnostic error renderer from `expectations` | `examples/form_errors.rb` | [round-5/07-solnic.md](round-5/07-solnic.md) | -| 8 | Mike Perham | Stampede simulator — the jitter default, argued by histogram | `examples/stampede_sim.rb` | [round-5/08-mperham.md](round-5/08-mperham.md) | -| 9 | Sandi Metz | Three shapes — chain vs star vs staged, chosen by evidence | `examples/three_shapes.rb` | [round-5/09-sandimetz.md](round-5/09-sandimetz.md) | -| 10 | Andrew Kane | Examples index — self-maintaining signage for a 40-example gallery | `examples/examples_index.rb` | [round-5/10-ankane.md](round-5/10-ankane.md) | - -## Round 6 — plans as artifacts - -The round-5 asks shipped as a release (`graph[:order]` via Kahn's -algorithm, labeled `graph[:edges]`, structured rules with -`fields:`/`rule_violations`, `backoff_jitter: :full`, and windowed -rate limits `RateLimit.new(30, per: 60)`), four examples were -modernized onto them, and ten more experiments followed: - -| # | Persona | Built on the round-6 release | Run it | Field notes | -|---|---------|------------------------------|--------|-------------| -| 1 | Matz | Plan tour — the plan narrated as prose, before it runs | `examples/plan_tour.rb` | [round-6/01-matz.md](round-6/01-matz.md) | -| 2 | DHH | Deploy train — the unhappy path as the product | `examples/deploy_train.rb` | [round-6/02-dhh.md](round-6/02-dhh.md) | -| 3 | Aaron Patterson | Perf diff — did the PR make it worse, path-qualified | `examples/perf_diff.rb` | [round-6/03-tenderlove.md](round-6/03-tenderlove.md) | -| 4 | Xavier Noria | Plan round-trip — graph → JSON → graph with isomorphism proof | `examples/plan_roundtrip.rb` | [round-6/04-fxn.md](round-6/04-fxn.md) | -| 5 | Samuel Williams | Quota keeper — ceiling physics vs window physics, 61ms vs 601ms | `examples/quota_keeper.rb` | [round-6/05-ioquatix.md](round-6/05-ioquatix.md) | -| 6 | Jeremy Evans | Rule prober — field declarations audited; a lying rule caught | `examples/rule_prober.rb` | [round-6/06-jeremyevans.md](round-6/06-jeremyevans.md) | -| 7 | Piotr Solnica | API reference — docs from the contracts that validate the calls | `examples/api_reference.rb` | [round-6/07-solnic.md](round-6/07-solnic.md) | -| 8 | Mike Perham | Jitter shootout — none/equal/full on one scoreboard: 40/19/13 | `examples/jitter_shootout.rb` | [round-6/08-mperham.md](round-6/08-mperham.md) | -| 9 | Sandi Metz | Refactor receipts — the god join dissolved in priced steps | `examples/refactor_receipts.rb` | [round-6/09-sandimetz.md](round-6/09-sandimetz.md) | -| 10 | Andrew Kane | Cost estimator — the plan priced before it runs, reconciled after | `examples/cost_estimator.rb` | [round-6/10-ankane.md](round-6/10-ankane.md) | - -## Round 7 — the referee round - -The round-6 asks shipped as a release (`RateLimit#and` composition, -`graph[:stats]`, journal `durations` keyed by description, -`CapabilitySpecification#to_json_schema`, injectable retry `rng:`), -two graph tools were modernized onto `stats`, and ten more experiments -followed: - -| # | Persona | Built on the round-7 release | Run it | Field notes | -|---|---------|------------------------------|--------|-------------| -| 1 | Matz | Plan fortune teller — structural diagnoses in a mystic's robe | `examples/plan_fortune.rb` | [round-7/01-matz.md](round-7/01-matz.md) | -| 2 | DHH | Weekly check-in — the journal answers so nobody's Friday has to | `examples/weekly_checkin.rb` | [round-7/02-dhh.md](round-7/02-dhh.md) | -| 3 | Aaron Patterson | Perf history — regressions judged against the last release's journal | `examples/perf_history.rb` | [round-7/03-tenderlove.md](round-7/03-tenderlove.md) | -| 4 | Xavier Noria | Structural diff — plan review at design altitude, not JSON altitude | `examples/plan_structural_diff.rb` | [round-7/04-fxn.md](round-7/04-fxn.md) | -| 5 | Samuel Williams | Composed limits — both laws at once; the chart names the binding one | `examples/composed_limits.rb` | [round-7/05-ioquatix.md](round-7/05-ioquatix.md) | -| 6 | Jeremy Evans | Backoff conformance — nine timing envelopes certified via injected rng | `examples/backoff_conformance.rb` | [round-7/06-jeremyevans.md](round-7/06-jeremyevans.md) | -| 7 | Piotr Solnica | Schema export + agreement proof — every projection ships its referee | `examples/json_schema_export.rb` | [round-7/07-solnic.md](round-7/07-solnic.md) | -| 8 | Mike Perham | Incident report — the 3am questions answered from the journal | `examples/incident_report.rb` | [round-7/08-mperham.md](round-7/08-mperham.md) | -| 9 | Sandi Metz | Graph style guide — RuboCop for plans, ten lines per cop | `examples/graph_style.rb` | [round-7/09-sandimetz.md](round-7/09-sandimetz.md) | -| 10 | Andrew Kane | Capability evals — contracts check types, evals check truth | `examples/capability_evals.rb` | [round-7/10-ankane.md](round-7/10-ankane.md) | - -### What round 7 surfaced - -1. **The referee pattern generalized**: six exit-code-gated honesty - tools now exist (fuzzer, prober, verifier, conformance, agreement - proof, evals) — the framework can no longer lie about its - contracts, rules, docs, timing, exports, or answers. -2. **The journal became four products**: crash recovery, resume keys, - perf baselines, and prose (check-ins, incident reports) — one - fsynced JSONL file, read with different questions. -3. **Tools kept correcting their authors**: Samuel's binding-constraint - prose and Piotr's generator coverage were both fixed by their own - measurements — the third straight round of measurement-over-narrative. -4. **Next asks**: `stats[:roots]`/`stats[:leaves]`, percentile - baselines over journal history (p50-of-last-N), rename detection - hints in the structural diff, JSON Schema `if/then` emission for - expressible rules, and an eval-scorer seam for LLM-backed - capabilities. - -## Round 8 — structure becomes vocabulary - -The round-7 asks shipped as a release (`stats[:roots]`/`stats[:leaves]`, -journal `duration_samples` with `duration_percentile(desc, pct, last:)`, -and `x-agentic-rules` emission in `to_json_schema`), and ten more -experiments followed: - -| # | Persona | Built on the round-8 release | Run it | Field notes | -|---|---------|------------------------------|--------|-------------| -| 1 | Matz | Plan forest — the graph drawn as trees, depth as altitude | `examples/plan_forest.rb` | [round-8/01-matz.md](round-8/01-matz.md) | -| 2 | DHH | Hill chart — where the work *is*, from lifecycle hooks | `examples/hill_chart.rb` | [round-8/02-dhh.md](round-8/02-dhh.md) | -| 3 | Aaron Patterson | Variance detective — flaky vs slow, settled by percentiles | `examples/variance_detective.rb` | [round-8/03-tenderlove.md](round-8/03-tenderlove.md) | -| 4 | Xavier Noria | Plan merge — three-way merge with conflicts at seam altitude | `examples/plan_merge.rb` | [round-8/04-fxn.md](round-8/04-fxn.md) | -| 5 | Samuel Williams | Adaptive throttle — AIMD finds the capacity nobody documented | `examples/adaptive_throttle.rb` | [round-8/05-ioquatix.md](round-8/05-ioquatix.md) | -| 6 | Jeremy Evans | Journal audit — five invariants; a tampered journal named precisely | `examples/journal_audit.rb` | [round-8/06-jeremyevans.md](round-8/06-jeremyevans.md) | -| 7 | Piotr Solnica | Contract semver — breaking-or-compatible computed, bump advised | `examples/contract_semver.rb` | [round-8/07-solnic.md](round-8/07-solnic.md) | -| 8 | Mike Perham | Dead letter office — requeue, parked, recovered, by last word | `examples/dead_letter_office.rb` | [round-8/08-mperham.md](round-8/08-mperham.md) | -| 9 | Sandi Metz | Graph to specs — structural roles dictate the test plan | `examples/graph_to_specs.rb` | [round-8/09-sandimetz.md](round-8/09-sandimetz.md) | -| 10 | Andrew Kane | Eval scorers — four ways to say "good enough", one seam | `examples/eval_scorers.rb` | [round-8/10-ankane.md](round-8/10-ankane.md) | - -### What round 8 surfaced - -1. **Structure became vocabulary**: `roots`/`leaves`/`depth` landed and - were immediately spent three ways — a drawing (forest), a test plan - (graph-to-specs), and merge conflicts named at seam altitude. Metadata - that keeps buying unplanned tools is metadata shaped right. -2. **Durations became distributions**: `duration_samples` turned point - readings into percentiles, and two tools acted on them — the variance - detective separates flaky from slow (p90/p50 ratio), and the adaptive - throttle steers concurrency by p50 drift instead of vibes. -3. **Signal-to-noise as a design goal**: the dead letter office triages - by *most recent* attempt (no paging for ghosts), and the eval scorers - flag exactly one real failure where exact-match flags two. Both argue - the same point: a report is only as good as what its failures mean. -4. **The declarations' blind spot held**: Piotr's semver advisor and - Jeremy's audit both stop at callable rules — predicates stay opaque - to every static tool. Structured rules narrow the gap; they don't - close it. -5. **Next asks**: `RateLimit#resize(n)` so the adaptive throttle can - steer the real limiter instead of simulating one, and journaling - `retryable:` at write time from `failure.retryable?` so triage - survives taxonomy renames. (`eval_scorers.rb` joins the - exit-1-by-design set.) - -## Round 9 — the operations round - -The round-8 asks shipped as a release (`RateLimit#resize(n)` — live -ceiling changes, growing wakes waiters, shrinking drains — and the -journal recording `retryable:` on `task_failed` at write time from the -failure's own verdict), the two examples that asked were modernized -onto them, and ten more experiments followed: - -| # | Persona | Built on the round-9 release | Run it | Field notes | -|---|---------|------------------------------|--------|-------------| -| 1 | Matz | Failure weather — retryable is weather, non-retryable is climate | `examples/failure_weather.rb` | [round-9/01-matz.md](round-9/01-matz.md) | -| 2 | DHH | Traffic dial — a canary rollout as one resized limiter | `examples/traffic_dial.rb` | [round-9/02-dhh.md](round-9/02-dhh.md) | -| 3 | Aaron Patterson | Throughput knee — the ceiling sweep with two honest clocks | `examples/throughput_knee.rb` | [round-9/03-tenderlove.md](round-9/03-tenderlove.md) | -| 4 | Xavier Noria | Graph invariants — seven promises of the reflection API, proved | `examples/graph_invariants.rb` | [round-9/04-fxn.md](round-9/04-fxn.md) | -| 5 | Samuel Williams | Fair share — tenant-fairness composed, shares rebalanced live | `examples/fair_share.rb` | [round-9/05-ioquatix.md](round-9/05-ioquatix.md) | -| 6 | Jeremy Evans | Resize torture — shrink drains, grow wakes, ceilings bind | `examples/resize_torture.rb` | [round-9/06-jeremyevans.md](round-9/06-jeremyevans.md) | -| 7 | Piotr Solnica | Contract fixtures — examples derived from declarations, proved | `examples/contract_fixtures.rb` | [round-9/07-solnic.md](round-9/07-solnic.md) | -| 8 | Mike Perham | Circuit breaker — three strikes for 503s, one for a revoked key | `examples/circuit_breaker.rb` | [round-9/08-mperham.md](round-9/08-mperham.md) | -| 9 | Sandi Metz | Duck agents — five shapes through one seam, one tiny decorator | `examples/duck_agents.rb` | [round-9/09-sandimetz.md](round-9/09-sandimetz.md) | -| 10 | Andrew Kane | Impl shootout — accuracy AND latency on one table | `examples/impl_shootout.rb` | [round-9/10-ankane.md](round-9/10-ankane.md) | - -### What round 9 surfaced - -1. **resize turned limits into policy objects**: five tools steer one - live limiter — the rollout dial, the ceiling sweep, tenant share - rebalancing, the torture certificate, and the modernized AIMD - throttle. The topology of a limiter graph stays fixed; only the - numbers move at runtime, which is the property that makes it safe. -2. **The write-time verdict became a decision input**: the weather - report (wait vs dig a well), the circuit breaker (three strikes vs - instant trip), and the modernized dead letter office all *act* on - `retryable:` instead of reconstructing it — the error's testimony, - recorded when fresh, drives policy later. -3. **The tools kept correcting their authors** (fifth consecutive - round): Samuel's one-worker tenant couldn't starve, Aaron's - "throughput goes flat" was actually a fall, Xavier's depth - invariant was ill-posed on cycles, Jeremy's harness read its clock - before setting it, Mike's breaker read the wrong journal event, and - Kane's challenger lost a case to a missing stem. Every one was - caught by the example's own output before a user saw it. -4. **Fairness needs unmet demand to be visible**: a FIFO door is fair - to requests, not tenants — starvation only appears when a tenant's - demand exceeds its receipts, which is why quiet outages stay quiet. -5. **Next asks**: relation-typed structured rules (`sum_lte:`, - `requires:`, `mutually_exclusive:`) so generators can satisfy and - advisors can diff the declarable majority of cross-field rules - (Piotr); and a breaker-friendly convention for `retryable: nil` — - "no opinion" should mean retry-with-suspicion, not hopeless (Mike). - -## Round 10 — predicates become data - -The round-9 asks shipped as a release (`Agentic::RelationRules` — -`sum_lte`/`requires`/`mutually_exclusive` declared as data, enforced -by the validator with derived messages, projected into real draft-07 -keywords, and carried in `x-agentic-rules`; plus the retryable-nil -convention on `TaskFailure`: `hopeless?` / `possibly_transient?`), -the two asking examples were modernized, and ten more experiments -followed: - -| # | Persona | Built on the round-10 release | Run it | Field notes | -|---|---------|-------------------------------|--------|-------------| -| 1 | Matz | Polite form — every declaration read aloud as a question | `examples/polite_form.rb` | [round-10/01-matz.md](round-10/01-matz.md) | -| 2 | DHH | One-file API — schema, 422s, and 201s derived from one declaration | `examples/one_file_api.rb` | [round-10/02-dhh.md](round-10/02-dhh.md) | -| 3 | Aaron Patterson | Contract overhead — validation priced against the call it guards | `examples/contract_overhead.rb` | [round-10/03-tenderlove.md](round-10/03-tenderlove.md) | -| 4 | Xavier Noria | Projection agreement — both renderings of the law, proved; the nil frontier mapped | `examples/projection_agreement.rb` | [round-10/04-fxn.md](round-10/04-fxn.md) | -| 5 | Samuel Williams | Cancel drill — task cancel is prompt; plan cancel bills you anyway | `examples/cancel_drill.rb` | [round-10/05-ioquatix.md](round-10/05-ioquatix.md) | -| 6 | Jeremy Evans | Relation prober — 13 probes pass; one step off the road draws blood | `examples/relation_prober.rb` | [round-10/06-jeremyevans.md](round-10/06-jeremyevans.md) | -| 7 | Piotr Solnica | Relation diff — the rules join semver; opacity becomes opt-in | `examples/relation_diff.rb` | [round-10/07-solnic.md](round-10/07-solnic.md) | -| 8 | Mike Perham | Retry budget — one fleet-wide wallet; 45 doomed calls become 17 | `examples/retry_budget.rb` | [round-10/08-mperham.md](round-10/08-mperham.md) | -| 9 | Sandi Metz | Rule shapes — one policy, three representations, four consumers | `examples/rule_shapes.rb` | [round-10/09-sandimetz.md](round-10/09-sandimetz.md) | -| 10 | Andrew Kane | Batch import — a reject file with line, field, and rule, at 162us/row | `examples/batch_import.rb` | [round-10/10-ankane.md](round-10/10-ankane.md) | - -### What round 10 surfaced - -1. **Predicates as data compounded immediately**: within one round, - relation rules were asked as questions (polite form), served as - draft-07 keywords (one-file API), satisfied by the generator, - diffed for semver, audited for consumer count, and used to explain - 118 CSV rejections. Six consumers for a feature shipped that - morning — the strongest version yet of "metadata keeps buying - unplanned tools." -2. **Two real defects found by drills**: `cancel_plan` under a joined - reactor is bookkeeping-only — every agent runs and bills while the - status says canceled (Samuel); and a relation rule over an - undeclared field escapes as raw TypeError instead of - ValidationError, turning 422 paths into 500 paths (Jeremy, whose - prober exits 1 by design as the acceptance test). -3. **Agreement-for-different-reasons is a named hazard**: typed - fields guard the nil frontier so both renderings reject - `{express: nil}` — for unrelated reasons; relax the type and the - renderings diverge. Verdict-only tests would call that a pass - (Xavier). -4. **The meter settled the validation debate**: the largest contract - costs 0.14ms against the 800ms call it guards, and rejection - costs 12x the happy path — both numbers now on the table (Aaron, - Kane concurring at 162us/row with rejects included). -5. **Next asks**: make `cancel_plan` stop the scheduler and in-flight - fibers (the cancel drill is the acceptance test); relation rules - must type-check their fields at declaration time or wrap - evaluation failures in ValidationError (the relation prober is - the acceptance test); `RateLimit#try_acquire` for non-blocking - admission so retry budgets can be RateLimits; and align or - document presence semantics (Ruby nil vs JSON null) across the - projection boundary. (`relation_prober.rb` joins the - exit-1-by-design set.) - -## Round 11 — a new cast takes the bench - -The round-10 asks shipped as a release: `cancel_plan` is now prompt -(bookkeeping first, then fiber stops - never the reactor handle, never -the calling fiber), relation declarations fail fast at validator -construction (undeclared fields and sum_lte-over-strings refuse to -boot), `RateLimit#try_acquire` gives non-blocking admission, and -relations over untyped fields stay out of draft-07 keywords. The two -exit-1-by-design probers flipped to green acceptance tests, the retry -budget's wallet became a real windowed RateLimit — and then **ten new -prolific Rubyists** took over the experiments: - -| # | Persona | Built with the gem | Run it | Field notes | -|---|---------|--------------------|--------|-------------| -| 1 | Koichi Sasada (ko1) | Allocation audit — exact object counts per operation, via GC.stat | `examples/allocation_audit.rb` | [round-11/01-ko1.md](round-11/01-ko1.md) | -| 2 | Charles Nutter (headius) | Threads drill — real parallelism vs everything shared; a load-order bug caught | `examples/threads_drill.rb` | [round-11/02-headius.md](round-11/02-headius.md) | -| 3 | Nate Berkopec | Capacity planner — Little's Law over journal percentiles | `examples/capacity_planner.rb` | [round-11/03-nateberkopec.md](round-11/03-nateberkopec.md) | -| 4 | Ryan Davis (zenspider) | Plan flog — a pain score per plan; idiom free, coupling priced | `examples/plan_flog.rb` | [round-11/04-zenspider.md](round-11/04-zenspider.md) | -| 5 | Avdi Grimm | Confident pipeline — ten timid conditionals vs one barricade | `examples/confident_pipeline.rb` | [round-11/05-avdi.md](round-11/05-avdi.md) | -| 6 | Katrina Owen | Plan kata — red/green/refactor with graph assertions written first | `examples/plan_kata.rb` | [round-11/06-kytrinyx.md](round-11/06-kytrinyx.md) | -| 7 | Bozhidar Batsov | Contract cop — seven named cops, mechanical autocorrect only | `examples/contract_cop.rb` | [round-11/07-bbatsov.md](round-11/07-bbatsov.md) | -| 8 | José Valim | Telemetry bus — :telemetry on the hooks; runtime attach/detach, crash isolation | `examples/telemetry_bus.rb` | [round-11/08-josevalim.md](round-11/08-josevalim.md) | -| 9 | Luca Guidi | Ports and adapters — the domain survives the migration, with a purity scan | `examples/ports_and_adapters.rb` | [round-11/09-jodosha.md](round-11/09-jodosha.md) | -| 10 | Eileen Uchitelle | Tenant shards — N journals, N limits, one ignorant control plane | `examples/tenant_shards.rb` | [round-11/10-eileencodes.md](round-11/10-eileencodes.md) | - -### What round 11 surfaced - -1. **Fresh eyes found a fresh class of bug immediately**: headius's - bare-journal drill caught `Time#iso8601` used without requiring - "time" — a load-order bug nine rounds of fiber-world examples never - tripped, fixed in this round's release. New perspectives audit - different assumptions. -2. **The runtime got audited from below**: exact allocation counts - (37 objects per happy validation, 11x on rejection, zero GC runs - per plan), real-thread verdicts (journal and registry hold on real - locks; the windowed limiter coasts on the GVL), and Little's Law - turning the journal into a capacity plan whose binding constraint - was outside the meeting. -3. **The teaching seat is real**: the kata (assertions before tasks), - the flog score (calibrated so idiom is free), the confident/timid - contrast (nil-tolerance launders errors), and the cop (autocorrect - only what has one right answer) are all *pedagogy tools* built on - the same reflection surfaces the ops tools use. -4. **The architecture seat approves the seams**: the duck-typed - `agent:` seam let a pure domain walk in without signing tenancy - (ports-and-adapters with a purity scan), and ten lines bridged the - hooks to a :telemetry-style bus with crash isolation - frameworks - orchestrate, domains decide, handlers come and go. -5. **Next asks**: a Mutex around the windowed limiter's stamp - bookkeeping so the answer is the same on every Ruby (headius); - `remove_task`/rewire so refactoring a plan doesn't mean demolition - (Katrina, with the kata as acceptance test); and a multiprocess - journal drill to certify the flock claim (headius, follow-up). - -## Round 12 — a third cast, and the bill arrives itemized - -The round-11 asks shipped as a release: the windowed limiter's stamp -bookkeeping holds a real Mutex (the threads drill now certifies -instead of observes), `remove_task`/`rewire_task` make plan -refactoring surgical (the kata refactors in place), and the process -drill certifies the flock claim across four forked writers. Then a -third cast of ten prolific Rubyists took the bench: - -| # | Persona | Built with the gem | Run it | Field notes | -|---|---------|--------------------|--------|-------------| -| 1 | Yehuda Katz (wycats) | API surface census — 112 methods, 58 earning rent, 54 on loan | `examples/api_surface.rb` | [round-12/01-wycats.md](round-12/01-wycats.md) | -| 2 | Sarah Mei | Onboarding trail — a house tour computed from who-mentions-whom | `examples/onboarding_trail.rb` | [round-12/02-sarahmei.md](round-12/02-sarahmei.md) | -| 3 | Richard Schneeman | Require cost report — the bill lands at first touch, not at require | `examples/require_cost.rb` | [round-12/03-schneems.md](round-12/03-schneems.md) | -| 4 | Vladimir Dementyev | EventProf for plans — task-seconds by tag; llm owns 78% | `examples/event_prof.rb` | [round-12/04-palkan.md](round-12/04-palkan.md) | -| 5 | Mike Dalessio | Hostile inputs — a torn tail denies ALL recovery; exit 1 until | `examples/hostile_inputs.rb` | [round-12/05-flavorjones.md](round-12/05-flavorjones.md) | -| 6 | Justin Searls | Honest doubles — fakes show their papers at load time | `examples/honest_doubles.rb` | [round-12/06-searls.md](round-12/06-searls.md) | -| 7 | Konstantin Haase | Plan DSL — thirty lines of Sinatra over the public API | `examples/plan_dsl.rb` | [round-12/07-rkh.md](round-12/07-rkh.md) | -| 8 | Obie Fernandez | Self-correcting output — violations become the correction prompt | `examples/self_correcting_output.rb` | [round-12/08-obie.md](round-12/08-obie.md) | -| 9 | Rafael França | Gentle deprecations — warn once per site, tally, strict on schedule | `examples/gentle_deprecations.rb` | [round-12/09-rafaelfranca.md](round-12/09-rafaelfranca.md) | -| 10 | Jean Boussier (byroot) | Write path profile — JSON acquitted at 0.4%; the fsync IS the product | `examples/write_path_profile.rb` | [round-12/10-byroot.md](round-12/10-byroot.md) | - -### What round 12 surfaced - -1. **The stewardship seat spoke**: the census split 112 public - methods into earned API and accidental API; the deprecation shim - choreographed a rename across three releases; the DSL proved sugar - can stay entirely outside the engine. Three different answers to - "how does this gem grow old gracefully." -2. **One real defect, found where parsers meet reality**: a torn - journal tail — the exact artifact of the crash the journal exists - for — crashes replay in the wrong error class and denies all - recovery (`hostile_inputs.rb` exits 1 as the acceptance test). -3. **Costs arrived itemized**: require costs land at first constant - touch, not at require (Zeitwerk's deferral, priced); the journal - write is 99.6% fsync and 0.4% JSON, with group commit named as a - different promise rather than a faster one; plan time profiles by - tag with the barriers indicted, not the budget. -4. **The AI-application seat matured**: the self-correcting output - loop showed the contract doubling as a correction-prompt - generator, and honest doubles put load-time verification at the - LLM boundary — patterns, not vibes. -5. **Next asks**: tolerant journal replay — salvage whole lines, - report (don't raise on) a torn or mis-encoded tail - (`hostile_inputs.rb` is the acceptance test); an optional - `fsync_every: n` group-commit mode with its durability trade - named in the constructor (byroot); and consider a strict-shapes - 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 - isomorphism proof (round-trip), priceable before execution (cost - gate), and diffable across runs (perf diff). The graph accessor's - second round turned topology into a first-class document. -2. **Declarations became testable claims**: rule `fields:` shipped as - UI plumbing and immediately became an auditable specification — the - prober caught a seeded lying rule that would have misdirected form - highlighting. -3. **One contract, five behaviors**: validate, reject, explain, - document, audit — the same declaration now feeds all five. -4. **Honest prose corrections**: two personas (DHH, Sandi) had their - example copy corrected by their own measurements — the tools are - now good enough to disagree with their authors. -5. **Next asks**: composing a windowed and a concurrency limiter as - one object, journal-fed baselines for the perf diff, OpenAPI - emission from contracts, custom RNG injection for retry policies, - and a `graph`-level depth/fan-in stats helper (Sandi's third - strike). - -### What round 5 surfaced - -1. **The graph accessor compounded immediately**: one round old, it fed - a game map, a critical-path analyzer, a Mermaid generator, and a - design curriculum. Expose the right projection and an ecosystem - assembles itself. -2. **Named dependencies turned out to be documentation**: `needs:` - labels became labeled diagram edges — ergonomics maturing into - architecture records. -3. **Every round-4 feature was characterized under load the round it - shipped** — the burst absorber (RateLimit), the stampede histogram - (jitter), the freight desk (rules), the 422 generator - (expectations). -4. **Next asks**: `graph[:order]` (topological sort — requested - independently by three personas) plus `graph[:edges]` with labels, - structured rule identifiers (`{rule: :symbol, fields: [...]}`) so - policy violations can point at widgets, a `backoff_jitter: :full` - tier, and time-windowed rate limits alongside the concurrency - ceiling. - -### What round 4 surfaced - -1. **Two more real defects found by examples**: canceled plans reported - `:completed` (`overall_status` never consulted the canceled state — - fixed, regression-tested), and the README's composition snippet had - been syntactically invalid since round 1's review first side-eyed it - (fixed; the verifier now guards it). -2. **Every round-3 ask got exercised the round it shipped** — named - deps (corpse, doctor), slot hook (knee finder), `retryable?` - (taxonomy drill), predicates (state machine). Tight feedback loops - keep features honest. -3. **The survey/atlas shape is the framework's signature** — parallel - facts, one fan-in verdict — now in six examples. It deserves a - documented name. -4. **Next asks**: a read-only `Orchestrator#graph` accessor (three - tools have crowbarred `@dependencies`), violation payloads carrying - the predicate's expectation (legal enum values), a credential-scoped - `RateLimit` class (`LlmClient` accepting `limiter:`), jitter-on by - default, and cross-field contract rules. - -### What round 3 surfaced - -1. **The adapter tax is gone.** Zero provider structs, zero - string-keyed lookups across all ten builds; several programs are - shorter than their round-2 counterparts while doing more. -2. **A real scheduler deadlock** — fan-in dependencies at a tight - concurrency limit deadlocked slot-holders spawning dependents. Found - by the Gantt chart, fixed (spawn through the barrier, acquire inside - the fiber), regression-tested. -3. **Piping enabled new shapes**: fan-in aggregation (digest, doc - coverage, changelog), observable hand-offs (collaboration tracer), - and retry-transparent downstream reads (flaky drill). -4. **Next asks, in priority order**: named dependencies - (`needs: {facts: task}`), a `previous_output` convenience for - single-dependency chains, a `task_slot_acquired` hook to split queue - time from run time, `failure.retryable?` consulted by retry - policies, and richer contract predicates (ranges/enums). - -### What round 2 taught (the consumer's consensus) - -Building *with* the gem surfaced different findings than reviewing it: - -1. **Tasks need a payload, and the orchestrator should accept agents or - callables directly.** Six personas independently wrote the same two - workarounds: smuggling domain objects through `task.description` and - wrapping an agent they already had in a `get_agent_for_task` provider - struct. That's the API's users voting. -2. **Dependent tasks can't see each other's outputs** (Matz hit it first - and hardest): the orchestrator schedules around dependencies but - doesn't pipe results into dependents, forcing shared mutable state. -3. **The concurrency story is real and needs one honest paragraph**: - near-ideal scaling for IO-bound tasks (Samuel measured within 10ms of - theoretical), nothing for CPU-bound work (Aaron measured that too). -4. **Capabilities-as-lambdas is the gem's best idea.** Every build used - them; contracts (round 1's validator) caught real mistakes during - development in three of the ten builds. -5. **Start with capabilities, add the orchestrator when there's a - queue.** The builds that didn't fan out (typed pipeline, gem scout) - were better off without it. - ---- - -## 1. Yukihiro "Matz" Matsumoto — optimizing for happiness - -**What I'd build:** Nothing big — open `bin/console` and play. Can I make an -agent in three lines that makes me smile? - -**What interests me:** The block-based builder (`Agent.build do |a|`) and the -`StructuredOutputs::Schema` DSL are genuinely Rubyish. An AI orchestration gem -that reads like Ruby instead of like a Python port makes me happy. - -**What's confusing:** `Task#perform(agent)` vs `Agent#execute(task)` — the -same act expressed from two directions, and `Agent#execute` even calls -`task.perform(self)` back. Which object owns the verb? - -**Worked well:** The plain-English goal → plan flow. **Didn't:** -`raise "Capability not found: #{name}"` — bare `RuntimeError` strings when -`Agentic::Error` already exists. Errors deserve names too. - -## 2. DHH — conceptual compression, majestic monolith - -**What I'd build:** The 80% version: `Agentic.run("Summarize this week's -support tickets")`. One line, batteries included. - -**What's confusing:** The gap between documentation and code. The architecture -documents promise a `MetaLearningSystem`, `StreamingObservabilityHub`, -`InterventionPortal` — layers documented before they exist. Four architectural -layers for a gem with one real user path. - -**Worked well:** `PlanOrchestrator`'s lifecycle hooks — a real, earned -abstraction. **Didn't:** Everything you must understand before your first -agent runs. Compress it. Delete half the nouns. - -## 3. Aaron Patterson (tenderlove) — performance and runtime internals - -**What I'd build:** First, a benchmark: `require "agentic"` was eagerly -loading Thor and six tty-* gems into every library consumer. Your web app was -booting a progress-bar library. - -**What interests me:** `PlanOrchestrator` on `Async` with a semaphore and -barrier — I want to throw 500 tasks at it and watch allocations. - -**Didn't work:** `initialize_agent_assembly` memoized global state with no -mutex — two threads race, both build a `PersistentAgentStore`. I've fixed this -bug in Rails at least nine times. Hi! - -## 4. Xavier Noria — Zeitwerk author - -**What's confusing:** `lib/agentic.rb` called `loader.setup` and then -immediately issued nine `require_relative` calls for constants Zeitwerk -already manages, plus more scattered inside files. Two loading mechanisms with -different semantics. Either trust the loader or don't use one. - -**Worked well:** File/constant naming is perfectly conventional — the loader -maps cleanly (once `ui` joined `cli` in the inflector). - -## 5. Samuel Williams (ioquatix) — async maintainer - -**What I'd build:** A streaming agent server on Falcon. The gem chose -`async ~> 2.0`, so it's already in my house. - -**What interests me:** `execute_plan` uses `Async::Barrier` with a `Semaphore` -parented to it — the documented-correct composition. Someone read the manual. - -**What's confusing:** The orchestrator created its own root `Async` block. -Called inside an existing reactor (say, under Falcon), you get a nested event -loop rather than joining the parent. - -## 6. Jeremy Evans — minimal dependencies, ruthless correctness - -**What's alarming:** `Configuration#initialize` defaulted `access_token` to -the string `"ollama"`. A silent fake credential means misconfiguration fails -at request time with a confusing 401 instead of loudly at boot. Fail fast. - -**What's confusing:** Twelve runtime dependencies for a library, six of them -tty-* UI gems, plus `ostruct`. Those belong in a separate `agentic-cli` gem. - -## 7. Piotr Solnica — dry-rb, types and boundaries - -**What interests me:** The instinct is *so close* to ours: -`AgentSpecification`, `TaskDefinition`, `ExpectedAnswerFormat` are value -objects with `to_h`/`from_hash` — `Dry::Struct` written by hand. - -**Didn't work:** Types declared but not enforced. `CapabilitySpecification` -defines `inputs:` with types and `required:` flags, and then nothing ever -validates inputs against them. Ceremony without safety — the worst of both -worlds. - -## 8. Mike Perham — Sidekiq, boring reliability - -**What's confusing:** Everything lives in process memory. `kill -9` the -process mid-plan and the plan never happened — except OpenAI billed you for -it. Persistence was bolted onto *agents* but not onto *executions*, which is -where the money is. - -**Worked well:** `continue_on_failure` semantics and explicit state -transitions — a real state machine, easy to persist. Make it boring. Boring -survives restarts. - -## 9. Sandi Metz — POODR, cheap change - -**What interests me:** Injection is everywhere; `TaskResult`/`TaskFailure` -model failure as data instead of control flow. These choices make change cheap. - -**Didn't work:** `execute_with_schema` checked `has_capability?("text_generation")` -and then *silently ignored the schema you passed it* — a method that doesn't -do what its name promises will hurt someone at 2 a.m. And `FactoryMethods` -set its DSL state only on the including class — subclass `Agent` and the DSL -quietly breaks. Inheritance debt, pre-borrowed. - -## 10. Andrew Kane (ankane) — shipper of practical ML gems - -**What I'd build:** The missing capabilities as tiny plug-ins. The README -advertises `--capabilities=text_generation,web_search`, but the shipped -`web_search` implementation returned hardcoded fake results. - -**What interests me:** `CapabilityProvider` taking a bare lambda is the whole -plugin API, and it's low-ceremony enough that people will actually write -plugins. The `api_base_url` escape hatch means local-first works today. - ---- - -## What the room agrees on - -Ten different sensibilities converge on five points, which makes them the -highest-value fixes: - -1. **Split the CLI from the library** (Jeremy, Aaron, Piotr) — thor + tty-* - shouldn't load into library consumers. *Addressed for load-time by the - Zeitwerk cleanup; a gem split remains future work.* -2. **Resolve the dual loading scheme** (Xavier, Aaron) — Zeitwerk *or* - `require_relative`, not both. *Done.* -3. **A real error hierarchy and no silent fallbacks** (Matz, Sandi, Jeremy) — - string `raise`s, the `"ollama"` token default, and `execute_with_schema` - ignoring its schema are all the same bug: failure hidden until later. - *Addressed in the Jeremy and Sandi builds.* -4. **Durability and thread-safety for the thing that costs money** (Mike, - Jeremy, Samuel) — execution state was in-memory only, globals - unsynchronized. *Addressed by `ExecutionJournal`, the assembly mutex, and - reactor composability.* -5. **The docs promise more than the code delivers** (DHH, Andrew) — either - build the missing layers or trim the architecture documents. *Partially - addressed: the fake `web_search` now has a real, pluggable backend.* - -The consensus compliment: the plan-and-execute core with result objects, -lifecycle hooks, and Async-based orchestration is genuinely good Ruby — the -bones deserved the cleanup they got here. diff --git a/docs/perspectives/field-notes/00-prologue.md b/docs/perspectives/field-notes/00-prologue.md deleted file mode 100644 index e20f164..0000000 --- a/docs/perspectives/field-notes/00-prologue.md +++ /dev/null @@ -1,38 +0,0 @@ -# Prologue — before anyone could build anything - -*Field notes from the session itself, before putting on any persona.* - -Every persona's build depends on a trustworthy test suite, so the first stop -was `rake spec`. What we found shaped everything after it: - -- `rspec` reported **65 examples** in 0.06 seconds. A `--dry-run` reported - **474 examples**. The suite wasn't fast; it was being killed. -- The culprit: `spec/agentic/cli_spec.rb` invoked - `agent create --role=... --instructions=...`, but the command requires - `--purpose`. Thor's `exit_on_failure?` is `true`, so Thor called `exit(1)` - — inside the rspec process — and the run silently truncated at whatever - example happened to be number 65. -- Repairing that revealed **12 latent failures** that had presumably been - red for a long time, invisible because the process died before reaching - them. - -The latent failures were real bugs, not stale assertions: - -1. `Agentic::Agent.new do |a| ... end` in the CLI and in specs — but - `Agent#initialize` never yields, so the configuration block was silently - discarded. Agents were being created with nil roles and purposes. - (`Agent.build` is the yielding constructor.) -2. `PersistentAgentStore#store` generated an ID for id-less agents but never - assigned it back, so storing the same agent twice created two unrelated - agents instead of two versions of one. -3. `PersistentAgentStore#list_all` didn't accept the `all(filter: {...})` - calling convention its own ADR-015 documents. -4. `Agentic.register_capability` / `.assemble_agent` used module ivars - directly, so the public readers existed but were bypassed — and specs that - stubbed the readers were stubbing nothing. -5. Capability inference required the literal string `data_analysis` to appear - in a task description; "Analyze the data" matched nothing. - -**Lesson for the room:** a test suite that exits early is worse than a failing -one — it converts red to green by truncation. If your CI passed on this -codebase, your CI was measuring how far rspec got before Thor shot it. diff --git a/docs/perspectives/field-notes/01-matz.md b/docs/perspectives/field-notes/01-matz.md deleted file mode 100644 index 1e8f6c6..0000000 --- a/docs/perspectives/field-notes/01-matz.md +++ /dev/null @@ -1,42 +0,0 @@ -# Field notes — Yukihiro "Matz" Matsumoto - -*Build: `examples/haiku_agent.rb` — the three-line agent.* - -## What I did - -I did what I always do with a new gem: opened a console and tried to write -the smallest program that makes me smile. It became `examples/haiku_agent.rb` -— an agent in three lines, a capability as a lambda, a poem as the result. -It runs with no API key, because a capability is just a callable and the -framework doesn't insist on a network to be understood. That is a very good -property. Programs you can understand offline are programs you can trust. - -## What made me happy - -- `Agent.build do |a| ... end` — the builder block reads like Ruby breathing. -- `CapabilityProvider.new(implementation: ->(inputs) { ... })` — the entire - extension story is "hand me a callable." No base class to inherit, no - interface to declare. This is the principle of least surprise applied to - plugins. -- The `StructuredOutputs::Schema` DSL (`s.string :name, enum: [...]`) feels - like it grew here rather than being transplanted from JSON Schema. - -## What made me pause - -- My poem arrived wrapped in bureaucracy: eight lines of - `INFO: Registered capability: ...` before three lines of haiku. A library - that speaks when not spoken to is like a friend who narrates their own - helpfulness. (Jeremy says he will fix the default logger. Good.) -- I wrote `poet.execute_capability("haiku", ...)` but I first tried - `poet.execute(...)` and `task.perform(poet)` — three verbs for one idea. - The objects should agree on a sentence structure. My suggestion: the agent - is the subject. `poet.perform(task)`. Subjects act; objects receive. -- `add_capability` raises `"Capability not found: haiku"` as a plain - `RuntimeError` if you forget to register first. I forgot, so I met it. - A `Agentic::CapabilityNotFoundError` would have told me *who* was - complaining. (Sandi has opinions here too.) - -## Verdict - -Three lines to an agent, one screen to the whole idea. The gem passes the -happiness test at small scale — now it should pass it at every scale. diff --git a/docs/perspectives/field-notes/02-dhh.md b/docs/perspectives/field-notes/02-dhh.md deleted file mode 100644 index 0130bd1..0000000 --- a/docs/perspectives/field-notes/02-dhh.md +++ /dev/null @@ -1,39 +0,0 @@ -# Field notes — DHH - -*Build: `Agentic.run("goal")` — conceptual compression in one method.* - -## What I did - -Added `Agentic.run(goal, model: nil, concurrency: 5)`. Plan the goal, build -the tasks, execute them, return the result. It's fourteen lines and it is the -API 80% of users actually want: - -```ruby -result = Agentic.run("Summarize this week's support tickets") -``` - -That's the whole program. No `TaskPlanner`, no `PlanOrchestrator`, no -`DefaultAgentProvider`, no `LlmConfig` — those all still exist and you can -graduate to them when you need dependency-ordered tasks or lifecycle hooks. -But you shouldn't have to meet five classes to say one sentence. - -## What I found while doing it - -- The pieces composed *cleanly*. Planner → task definitions → tasks → - orchestrator → result took no glue-hacking at all, which tells you the - underlying design is better than its own surface suggests. The framework - had a great one-liner in it all along; nobody had written it. -- The CLI already contained this exact code — `execute_plan_immediately` in - `cli.rb` does plan → tasks → orchestrator — but it was buried in a Thor - class where no library user could reach it. When your command-line tool has - a better API than your library, your library is under-extracted. -- Fourteen lines, and five of them are the `Task.new(...)` ceremony because - `TaskDefinition` (what the planner emits) and `Task` (what the orchestrator - runs) are near-identical twins with no conversion method between them. - `task_def.to_task` is begging to exist. - -## What I'd do next - -Delete the vaporware sections from the architecture documents and make -`Agentic.run` the first code sample in the README. The demo is the product. -The `MetaLearningSystem` is not the product. Ship the sentence. diff --git a/docs/perspectives/field-notes/03-tenderlove.md b/docs/perspectives/field-notes/03-tenderlove.md deleted file mode 100644 index 53a9f94..0000000 --- a/docs/perspectives/field-notes/03-tenderlove.md +++ /dev/null @@ -1,53 +0,0 @@ -# Field notes — Aaron Patterson (tenderlove) - -*Build: `benchmark/boot.rb` + a mutex for the global that needed one.* - -## What I did - -Two things, because you can't fix what you can't measure and you shouldn't -measure what you won't fix: - -1. **`benchmark/boot.rb`** — each scenario in a fresh subprocess, reporting - wall time, object allocations, and `$LOADED_FEATURES` count. -2. **Made `Agentic.initialize_agent_assembly` thread-safe.** It memoized four - pieces of global state with a bare `unless @ivar` check. Two threads walk - in, both see nil, both build a `PersistentAgentStore`, and now you have - two objects that both think they own `~/.agentic/agents/index.json`. - Classic check-then-act. Mutex, double-checked re-entry, and — the subtle - part — the flag ivar is now assigned *last*, so a thread that skips the - lock can never observe a half-built system. I have fixed this exact bug in - Rails so many times I could type it with my chin. - -## The numbers (Ruby 3.3.6, this machine) - -| scenario | wall | allocations | loaded files | -|---|---|---|---| -| baseline (empty ruby) | ~0 ms | 4 | 156 | -| `require "agentic"` | 14.5 ms | 11,791 | 186 | -| + `Agentic::CLI` (thor, tty-*) | 41.2 ms | 39,948 | 251 | -| + agent assembly init | 30.4 ms | 29,375 | 205 | - -Before Xavier's loader cleanup, that first row was **272 ms and 612 files** — -every library consumer paid the full CLI row on every boot. Now the tty-* -tax is only charged to people actually running the CLI, which is the whole -point of autoloading. - -## Things I noticed while in there - -- The assembly init allocates ~29k objects, most of it JSON-parsing the - agent index and registering seven standard capabilities. It's lazy now, so - nobody pays it until they touch capabilities. Good. Keep it lazy. -- `Agentic.logger` defaults to `$stdout` at **debug** level. My benchmark had - to set `level = :error` just to keep its own output readable. A library - printing INFO into its host's stdout is how you end up in *my* terminal, - and I will find you. (Jeremy is on it.) -- Nothing here is CPU-bound enough for YJIT to matter; your latency budget - is 99.9% OpenAI. But boot time and allocations are paid by every process, - network or not — which is why they're the right thing to benchmark. - -## Verdict - -The gem boots fast now, the global init is safe, and there's a benchmark to -keep both honest. Also I found a filesystem race in the agent store's -read-modify-write of `index.json`, but Perham gets paid to worry about -durability, so I left him a note. It's his turn. diff --git a/docs/perspectives/field-notes/04-fxn.md b/docs/perspectives/field-notes/04-fxn.md deleted file mode 100644 index 4749c97..0000000 --- a/docs/perspectives/field-notes/04-fxn.md +++ /dev/null @@ -1,53 +0,0 @@ -# Field notes — Xavier Noria (fxn) - -*Build: make Zeitwerk the single code loader for the gem.* - -## What I did - -- Deleted all 37 `require_relative` calls that pointed at Zeitwerk-managed - files: nine in `lib/agentic.rb` and the rest scattered through `lib/` - (`agent.rb`, `plan_orchestrator.rb`, `task.rb`, the verification - strategies, …). Constants are now resolved by the loader, as they should be. -- Stopped eager-requiring the CLI from the library entrypoint. The - `do_not_eager_load` on `lib/agentic/cli` was already correct in spirit — - but the very next lines required those files by hand, defeating it. - `exe/agentic` reaches `Agentic::CLI` through a normal autoload. -- Added `require "thor"` to the two CLI files that reopen - `class CLI < Thor`, so each file in that directory is loadable on its own. - Requiring *external* dependencies at the top of the file that needs them is - the correct pattern; requiring *siblings* is not. - -## What I found while doing it - -- The comment justifying the requires — "Thor requires subcommands to be - loaded before they're referenced" — was a misdiagnosis. Thor's `subcommand` - takes a constant; referencing the constant triggers the autoload. The one - real loading bug was elsewhere: `lib/agentic/ui.rb` defines `Agentic::UI`, - but the inflector only knew about `cli`. Every reference to `Agentic::UI` - worked *only because* of the manual require. Remove the crutch and the - misconfiguration surfaces immediately: `NameError: uninitialized constant - Agentic::Ui`. This is the recurring lesson: mixed loading doesn't just - offend taste, it **masks** configuration errors. -- `Zeitwerk::Loader.eager_load_all` now passes, which is the real proof that - every file/constant pair in the project is coherent. I'd suggest adding - exactly that as a spec — it's the cheapest CI guard Zeitwerk offers. - -## Measured result - -| | before | after | -|---|---|---| -| `require "agentic"` | ~272 ms | ~14 ms | -| `$LOADED_FEATURES` after require | 612 | 186 | - -A 19× faster require, and library consumers no longer load Thor, six tty-* -gems, and Pastel to use a `TaskPlanner`. Aaron will want these numbers for -his benchmark; he can have them. - -## What worked well / what didn't - -- **Well:** the file naming was already 100% conventional. Not one file - needed renaming — only the inflector entry for `UI`. Whoever laid out this - tree had internalized the conventions even while bypassing the loader. -- **Didn't:** `spec/spec_helper.rb` requires the whole gem for every spec, so - nobody noticed the library couldn't autoload on its own. Fast requires also - make `bin/console` start instantly, which is where Matz is headed next. diff --git a/docs/perspectives/field-notes/05-ioquatix.md b/docs/perspectives/field-notes/05-ioquatix.md deleted file mode 100644 index 7e039dc..0000000 --- a/docs/perspectives/field-notes/05-ioquatix.md +++ /dev/null @@ -1,65 +0,0 @@ -# Field notes — Samuel Williams (ioquatix) - -*Build: make `PlanOrchestrator` compose with a running reactor.* - -## What I did - -- `execute_plan` now runs its body under `Sync` instead of a root `Async` - block. Standalone callers see no difference: `Sync` creates a reactor and - blocks until the plan completes. But called *inside* a running reactor — - under Falcon, inside another task, from `Async { ... }` — it joins the - current task tree instead of spawning a detached child and racing past it. -- Added a spec that executes a plan from within `Async { ... }` and asserts a - completed `PlanExecutionResult` comes back. -- Fixed the backoff that never waited. - -## The bug that was hiding here - -The old code was `@reactor = Async do ... end` followed immediately by code -that reads `@execution_end_time`. At the top level that works by accident, -because a root `Async` block runs to completion before returning. Inside a -reactor, `Async { }` is **asynchronous** — it returns a running task -immediately, and the next line computed `nil - nil` on timestamps that hadn't -been written yet. So the orchestrator's behavior *changed meaning* depending -on its caller's execution context. That's the worst kind of API: not wrong, -worse — conditionally right. - -`Sync` is the primitive designed for exactly this: "run this synchronously -in whatever context I'm in." One word, both worlds correct. - -## The second bug, which is my favorite - -`apply_retry_backoff` implemented its delay as: - -```ruby -Async do - Async::Task.current.sleep(delay) if delay > 0 -end -``` - -That spawns a *detached* task that sleeps, and returns immediately. The -retry proceeded with **zero delay, every time** — the backoff strategies -(constant, linear, exponential, jitter — all lovingly implemented and -unit-tested) delayed nothing. The specs passed because they stubbed -`Async::Task.current` and verified `sleep` was *called*, not that anything -*waited*. Structured concurrency lesson number one: a task nobody waits on -is a promise nobody keeps. It's now a plain `sleep(delay)` in the current -task — the fiber scheduler makes that non-blocking for siblings, which is -the entire point of running under async. - -## What worked well - -- `Async::Barrier` + `Async::Semaphore.new(parent: @barrier)` was already - the documented-correct composition, and the dependency-triggered - scheduling on top of it is a good fit for structured concurrency. -- `cancel_task` stopping the individual `Async` task is right. - -## What I'd do next - -- The lifecycle hooks are synchronous callables; an `Async::Queue` per - subscriber would give the planned "streaming observability" for free, - with back-pressure, in about thirty lines. -- `LlmClient` uses Net::HTTP via ruby-openai, which cooperates with the - fiber scheduler — but only because we're on Ruby ≥ 3.0 with async 2.x. - Document that contract; it's the reason ten concurrent tasks don't need - ten threads. diff --git a/docs/perspectives/field-notes/06-jeremyevans.md b/docs/perspectives/field-notes/06-jeremyevans.md deleted file mode 100644 index 1c2efd2..0000000 --- a/docs/perspectives/field-notes/06-jeremyevans.md +++ /dev/null @@ -1,61 +0,0 @@ -# Field notes — Jeremy Evans - -*Build: fail-fast credential validation and library logging etiquette.* - -## What I did - -- Removed the `"ollama"` default access token. `Configuration#access_token` - is now the environment variables or nil — no invented credential. -- Added `Configuration#validate!` and `Agentic::Errors::ConfigurationError`. - `LlmClient.new` validates at construction: no token and no base URL means - you find out **now**, with a message listing the three ways to fix it — - not twenty minutes later as a bare 401 from a host you didn't know you - were talking to. -- Base-URL-only setups (Ollama and friends) remain first-class: they pass - validation and get an explicit `"local"` placeholder token, because local - endpoints ignore it. The difference from before is that this is now a - *decision written in code with a comment*, not a magic string in a - default. -- Default logger level is now `:warn`. A library that prints - `INFO: Registered capability: ...` eight times into its host's stdout is - taking liberties. The CLI can raise verbosity for interactive use; that's - its prerogative, not the library's default. - -## What I found while doing it - -The best part: `cli.rb` already had `check_api_token!`, which raises a -helpful boxed error `unless Agentic.configuration.access_token`. Dead code. -The `"ollama"` default meant `access_token` could **never** be nil, so the -guard never fired, so every misconfigured user sailed straight past the -helpful error into the confusing one. A fail-fast check and a -silently-succeeding default cannot coexist; the default always wins. Delete -the default and the check started working for the first time — I just had to -teach it that a base URL is also a valid answer. - -Also worth stating plainly: `filter_sensitive_data` in the spec helper was -dutifully scrubbing the string `"ollama"` out of VCR cassettes. Security -theater for a credential that never existed. - -Postscript: setting the default level to `:warn` did nothing at first. -`Agentic::Logger#initialize(*args)` was folding the `level: :warn` keyword -into a positional hash — which `::Logger` reads as `shift_age` — so every -level ever passed to this constructor had been silently discarded and the -logger always ran at DEBUG. Ruby 3 keyword separation is not optional -trivia. `initialize(...)` forwards correctly; the INFO chatter is gone. - -## What I did not do (yet), and would - -- The gemspec still ships thor + six tty-* gems + ostruct to every library - consumer. The Zeitwerk cleanup means they no longer *load*, which fixes - the runtime cost, but they still *install*. The real fix is an - `agentic-cli` gem. That's a release-process decision, not a patch, so I - left it as a recommendation. -- `ExecutionHistoryStore` does read-modify-write on JSON files with no file - locking, under an orchestrator whose whole job is concurrency. Perham's - journal (see his notes) is the model; the history store should follow it. - -## Verdict - -Errors moved from request time to boot time, the fake credential is gone, -and the library stopped talking over its host. Correctness is mostly the -discipline of refusing to guess. diff --git a/docs/perspectives/field-notes/07-solnic.md b/docs/perspectives/field-notes/07-solnic.md deleted file mode 100644 index e2c703b..0000000 --- a/docs/perspectives/field-notes/07-solnic.md +++ /dev/null @@ -1,57 +0,0 @@ -# Field notes — Piotr Solnica (solnic) - -*Build: make the declared capability contracts real, with the dry-rb -dependency the gem already had.* - -## What I did - -- Added `CapabilityValidator`: it takes a `CapabilitySpecification` and - compiles the declared `inputs:`/`outputs:` hashes into actual - `Dry::Schema` definitions, memoized per capability. Declared types are - enforced, required keys are required, unknown keys stay permitted (a - capability may accept more than it declares — the contract is a floor, - not a ceiling). -- Added `Agentic::Errors::ValidationError` carrying `capability`, `kind` - (`:inputs`/`:outputs`), and a `violations` hash with **every** problem, - not just the first. Boundary errors should let you fix a payload in one - round trip, not one message at a time. -- `CapabilityProvider#execute` now delegates to the validator; the two - 40-line hand-rolled type-checking case statements (one for inputs, one - for outputs, near-identical twins) are gone. - -## The thing I have to say out loud - -`dry-schema` was in the gemspec. It was `require`d at the top of -`structured_outputs.rb`. And it was used **zero** times in the entire -codebase — while forty lines away, someone hand-rolled the exact -first-match, string-raising type checker that dry-schema exists to replace. -You invited dry-rb to the party and left it standing at the door. I have -now handed it a drink. - -## What I found while doing it - -- The old validator had reasonable instincts (skip undeclared keys, check - both string and symbol keys) but reported only the *first* failure, as a - `RuntimeError` with no structure — so a caller couldn't distinguish "you - sent bad inputs" from "the capability broke" without parsing prose. -- There was **no spec file for `CapabilityProvider` at all**. The contract - enforcement — the thing standing between an LLM's creative output and - your capability implementations — was untested. It has one now, including - the case I care most about: an implementation that violates its *own* - output contract gets caught too. Contracts point both ways. -- The `type?: Numeric` predicate is doing honest work: the LLM-adjacent - world is full of `"3"` where `3` was meant, and coercing silently - (`Dry::Schema.Params`) would have hidden exactly the class of bug this - layer exists to expose. I chose the strict schema deliberately. - -## What I'd do next - -- `AgentSpecification`, `TaskDefinition`, `ExpectedAnswerFormat` are still - hand-written structs with `to_h`/`from_hash` pairs. They work; they'd be - a third the code as `Dry::Struct`. But that's taste plus a dependency - decision, not a defect, so it stays a suggestion. -- The planner's LLM responses flow into `Task.new(agent_spec: )`. - The boundary between "JSON some model emitted" and "typed value object" - is precisely where dry-validation contracts earn their keep. One - `PlanContract` would let the CLI reject a malformed plan file with named - errors instead of a NoMethodError three layers deep. diff --git a/docs/perspectives/field-notes/08-mperham.md b/docs/perspectives/field-notes/08-mperham.md deleted file mode 100644 index 1152753..0000000 --- a/docs/perspectives/field-notes/08-mperham.md +++ /dev/null @@ -1,61 +0,0 @@ -# Field notes — Mike Perham (mperham) - -*Build: `ExecutionJournal` — the plan state that survives `kill -9`.* - -## What I did - -Added `Agentic::ExecutionJournal`: an append-only JSONL journal that plugs -into `PlanOrchestrator`'s lifecycle hooks. One JSON line per event — -`task_started`, `task_succeeded` (with output), `task_failed` (with error), -`plan_completed` — each write taken under a mutex *and* an exclusive file -lock, flushed, and fsynced before the hook returns. `ExecutionJournal.replay` -reads the file back into a `ReplayedState`: which tasks completed, what -each one produced, what failed and why. Retry-then-succeed collapses to -completed, the way an operator would expect. - -```ruby -journal = Agentic::ExecutionJournal.new(path: "orders.journal.jsonl") -orchestrator = Agentic::PlanOrchestrator.new(lifecycle_hooks: journal.lifecycle_hooks) -# deploy hits, process dies, rerun: -state = Agentic::ExecutionJournal.replay(path: "orders.journal.jsonl") -state.completed?("task-3") # => true; do NOT pay OpenAI for it again -``` - -The hooks chain: `journal.lifecycle_hooks(observer.lifecycle_hooks)` journals -first, then delegates, so the CLI's pretty progress display and the boring -durable record coexist. Durability shouldn't cost you your spinners. - -## Why this design and not something fancier - -- **Append-only JSONL** because the failure mode of "append a line" is a - truncated last line, which replay can skip; the failure mode of - "rewrite a JSON document" (what the agent-store index does today) is a - destroyed file. -- **fsync per event** because a plan event is worth dollars. When each line - represents an LLM call you'd otherwise re-run at $0.01–$1 a pop, one - `fdatasync` is the cheapest insurance you will ever buy. If someone runs - thousand-task plans, batching is a constructor option away — start correct. -- **No new dependency.** Redis is where this ends up at scale (ask me how I - know), but a gem should offer durability before it demands infrastructure. - -## What I found while doing it - -- The lifecycle hooks are *exactly* right as an integration seam — I built - full durability without touching a line of the orchestrator. Whoever - designed those hooks earned their keep. -- The orchestrator's in-memory `@results` and the observer's save-at-the-end - `result-TIMESTAMP.json` both evaporate on crash — the file only gets - written from the `plan_completed` hook, i.e. only when nothing went wrong - enough to matter. Durability that engages only on success is a mood ring, - not a seatbelt. -- Aaron left me a note about `PersistentAgentStore#save_index` — unlocked - read-modify-write of `index.json` shared by any concurrent process. He's - right. Same medicine applies: lock, or go append-only. Left as a marked - TODO for a follow-up; it's a data-format change. - -## What I'd do next - -Idempotency keys: `task_id` is stable within a plan, so `replay` + -"skip completed tasks" gives you resume. The missing piece is the -orchestrator accepting a `skip_completed:` set so resume is one line -instead of a filter the caller writes. Small PR, big invoice savings. diff --git a/docs/perspectives/field-notes/09-sandimetz.md b/docs/perspectives/field-notes/09-sandimetz.md deleted file mode 100644 index 94521e0..0000000 --- a/docs/perspectives/field-notes/09-sandimetz.md +++ /dev/null @@ -1,59 +0,0 @@ -# Field notes — Sandi Metz - -*Build: make the messages honest — `execute_with_schema`, factory -inheritance, and errors with names.* - -## What I did - -1. **`Agent#execute_with_schema` now does what its name promises.** The old - method checked for a `text_generation` capability first and, finding one, - executed the prompt *and silently discarded the schema you passed it*. - The caller asked for structured output and received free text with no - indication anything was ignored — and `Task#perform` routes through this - method whenever a task declares an output schema, so plans were quietly - losing their structure guarantees. Now the LLM client (which can honor - the schema) is preferred; a capability-only agent raises - `SchemaNotSupportedError` that says exactly what to do instead. A method - that can't keep its promise should decline the message, not fake it. - -2. **`FactoryMethods` survives inheritance.** The DSL stored - `configurable :role, ...` in class-level ivars set only on the including - class. Subclass `Agent` and your subclass's `build` finds `nil` where its - attributes should be — the parent's interface silently vanished. An - `inherited` hook now copies the sets down, and a subclass's additions stay - its own. If you offer a class as an extension point, subclassing it is a - message you've promised to answer. - -3. **Errors got names.** `raise "Capability not found: #{name}"` became - `CapabilityNotFoundError` (which knows its capability), plus - `SchemaNotSupportedError`, `AgentNotConfiguredError`, and LLM failures - now raise the `Errors::LlmError` the codebase already owned but wasn't - using here. `rescue => e; e.message.include?("not found")` is a stringly - dependency on prose; a named class is a dependency on a promise. - -4. Fixed `Agent.from_h` — the third occurrence of the - `Agent.new do ... end` ignored-block bug this session (after the CLI and - the integration specs). Three call sites independently guessed wrong - about the same constructor. - -## The design observation that matters - -That ignored-block bug repeating three times is the interesting finding. -When one caller misuses your API, it's their bug; when three do, it's your -interface. `Agent.new` accepting-and-ignoring a block *looks exactly like* -`Agent.build`, and Ruby won't warn. The deep fix isn't in any of the call -sites — it's making the wrong usage impossible or loud. If I kept going I'd -either have `initialize` yield (make `new` and `build` agree) or make -`new` private API. I limited myself to the visible defects; changing the -constructor contract deserves its own conversation. - -## A Zeitwerk footnote (Xavier was right) - -My first draft put the new error classes as siblings in one -`errors/agent_error.rb`. Instant lesson: Zeitwerk loads a file when its -*namesake* is referenced, so `SchemaNotSupportedError` was a NameError until -`AgentError` happened to load first — and the pre-existing `llm_error.rb` -had been playing this same load-order lottery with eight sibling classes -all along. All errors now live in one `errors.rb` behind the `Errors` -namespace constant, so referencing any of them loads all of them. -Conventions aren't decoration; they're load-bearing. diff --git a/docs/perspectives/field-notes/10-ankane.md b/docs/perspectives/field-notes/10-ankane.md deleted file mode 100644 index e8e70ea..0000000 --- a/docs/perspectives/field-notes/10-ankane.md +++ /dev/null @@ -1,53 +0,0 @@ -# Field notes — Andrew Kane (ankane) - -*Build: a real, pluggable `web_search` capability.* - -## What I did - -The README advertises `--capabilities=text_generation,web_search`, and the -registered `web_search` capability returned... `"Result 1 for query: #{q}"` -with `https://example.com/result1` as the source. A demo prop wired into -the default registry, indistinguishable from a real capability until an -agent trusted it in production. - -Now there's `Agentic::Capabilities::WebSearch`: - -- **Works with zero configuration** — the default backend hits DuckDuckGo's - Instant Answer API: no API key, no signup, no new gem dependency - (`Net::HTTP` + `JSON`, both already in the room). My rule for a first-run - experience: `gem install`, one method call, real data. -- **Pluggable in one lambda** — `WebSearch.backend = ->(query:, num_results:) {...}` - swaps in SerpAPI, Brave, Tavily, or your internal index. The backend - contract is the same shape the capability already declared: - `{results: [String], sources: [String]}`. -- The registered standard capability now delegates to the backend, so - `agent.execute_capability("web_search", query: "...")` — and everything - the assembly engine composes on top — gets real results. - -## What I found while doing it - -- The capability's *specification* was already honest (`query` required, - typed outputs) — only the implementation was fake. With solnic's - validator now enforcing contracts, my backend had to return what the spec - promised or fail loudly. That's the ecosystem working: his build - type-checked mine while I wrote it. -- I could not live-verify DuckDuckGo from this sandbox — outbound HTTP is - allowlisted and `api.duckduckgo.com` isn't on the list. The unit tests - inject a fake HTTP client instead, and the raw `JSON::ParserError` you'd - get from a proxy error page is now rescued into an `Agentic::Error` that - says "blocked network? proxy error page?" — because the *first* person to - run this in a locked-down CI should get a sentence, not a stack trace. -- Instant Answers is a real but modest API (abstracts + related topics, not - full SERP). That's the right default tier: free and honest. The lambda - seam is where paid quality plugs in. - -## What I'd ship next (each is a weekend) - -- `agentic-embeddings`: a capability backed by `neighbor` + pgvector for - agent memory; the `PersistentAgentStore` metadata is already begging to - be similarity-searched (the assembly engine literally scores stored - agents against task requirements — with embeddings that's one SQL query). -- `agentic-informers`: local ONNX models as capability providers — zero - API cost for summarization/classification capabilities. -- CI that executes every README snippet. The fake web_search survived - because nothing ran the promises the README made. diff --git a/docs/perspectives/round-10/01-matz.md b/docs/perspectives/round-10/01-matz.md deleted file mode 100644 index 15cf015..0000000 --- a/docs/perspectives/round-10/01-matz.md +++ /dev/null @@ -1,61 +0,0 @@ -# Round 10 field notes — Matz asks before it's an error - -*Built: `examples/polite_form.rb` — a form assistant that turns the -contract's declarations into questions: requireds become requests, -bounds become gentle corrections, relations become follow-ups.* - -## What I built and why - -An error message is just a question you asked too late. A 422 that -says "express requires customs_code" contains a perfectly good -question — *"since you chose express, may I have your customs -code?"* — wearing armor. I wanted to take the armor off: - -``` -assistant: may I have your mode? (air, sea, road) -assistant: ah - weight must be less than or equal to 5000. shall we adjust it? -assistant: together weight and volume come to 7000, and 6000 is our - limit - could we lower the volume? -assistant: since you chose express, I'll also need your customs_code -assistant: you've given me api_key and oauth_token - I only need one; - which shall we keep? -``` - -Six questions, zero errors shown, and *nothing was written twice*: -every line of the conversation is a declaration read aloud in a -kinder register. `required:` became a request, `max:` a correction, -and — this is the part only possible since this morning — the three -relation rules each became their natural follow-up. `requires` is -"then I'll also need"; `sum_lte` is "could we lower it"; and -`mutually_exclusive` is "which shall we keep?" - -## Why relations made this possible - -Last round the generator could *satisfy* relations silently; this -round the assistant can *discuss* them, and the difference is the -same one: the predicate is data now. A lambda rule could only ever -say pass or fail — you cannot ask a lambda which field to lower, or -what the limit is, or which two things conflict. The relation -declaration carries all three, so the assistant reads the `fields:`, -the `limit:`, and the relation's own shape, and phrases the question -a human clerk would ask. Omotenashi is anticipating the need before -the failure; it turns out anticipation is a data-model feature. - -## Notes - -- The engine is a ten-line loop: validate, catch, convert the first - violation to a question, repeat. Fixed-point politeness. I enjoyed - that convergence is guaranteed by the same property that makes the - validator honest — every question, answered, strictly shrinks the - violation set. -- The `mutually_exclusive` case is the only one where the assistant - *removes* something rather than requesting it, and it asks - permission first. Deleting a user's input without asking is the - form equivalent of clearing their cart. - -## Verdict - -The contract now has two voices — the strict one for machines (422s, -schemas) and this one for people — and both read from the same -declarations, so they can never disagree. Kindness that stays -synchronized with correctness: that is my favorite kind of feature. diff --git a/docs/perspectives/round-10/02-dhh.md b/docs/perspectives/round-10/02-dhh.md deleted file mode 100644 index 6228448..0000000 --- a/docs/perspectives/round-10/02-dhh.md +++ /dev/null @@ -1,60 +0,0 @@ -# Round 10 field notes — DHH ships the API that isn't there - -*Built: `examples/one_file_api.rb` — a complete endpoint derived from -one capability declaration: schema endpoint, 422s with relation -rules explained, output-guarded 201s.* - -## What I built and why - -Look at a typical API codebase and count the artifacts per endpoint: -a controller, a params validator, a serializer, an OpenAPI YAML that -disagrees with all three, and a test file whose main job is keeping -the other four honest. Five files, one idea. The idea is: *a quote -request has a mode, a weight, and some rules.* - -So say that once, and derive the rest: - -``` -GET /quotes/schema -> 200 (draft-07, 687 bytes) -POST {"mode":"teleport","weight":9000} -> 422 field errors -POST {"weight":4000,"volume":3000} -> 422 "weight + volume - must total at most 6000" -POST {"express":true} -> 422 "express requires - customs_code" -POST (all in order) -> 201 {"price_cents":1800} -``` - -The app — the actual business — is four lines (`create_quote`). The -"API layer" is a case statement that *reads the declaration*: the -422 renderer never mentions a field name, the schema endpoint is one -method call, and `validate_outputs!` guards the response door too, -so the endpoint can't quietly ship a malformed 201 when someone -refactors the pricing. - -## Relations flow to both doors - -The round-10 payoff is that the cross-field laws now reach both -audiences without being written twice. The human at the terminal -gets "express requires customs_code" in the 422 — a sentence, -derived. The client generator gets `"dependencies": {"express": -["customs_code"]}` in the schema — draft-07 a stock validator -enforces client-side, before the request is even sent. Same law, -two renderings, one source. That used to require a platform team -with a style guide; now it's a property of the data model. - -## Notes - -- My first 422 printed each rule violation twice — once flattened - into the `base` field errors, once structured. The renderer now - excludes `base` and keeps the structured form, because a client - that can point at `fields: ["weight", "volume"]` should never have - to parse prose to find out where to put the red border. -- I kept `additionalProperties: true` on display in the schema - rather than hiding unknown-key tolerance. Postel was right and - your API clients are sloppy; design for it in the open. - -## Verdict - -One declaration, three doors: docs, rejection, and response — all -derived, none drifting. The best code in your app is the code that -isn't there, and this endpoint is mostly made of it. diff --git a/docs/perspectives/round-10/03-tenderlove.md b/docs/perspectives/round-10/03-tenderlove.md deleted file mode 100644 index e77fd0f..0000000 --- a/docs/perspectives/round-10/03-tenderlove.md +++ /dev/null @@ -1,66 +0,0 @@ -# Round 10 field notes — Aaron Patterson reads the meter - -*Built: `examples/contract_overhead.rb` — the validator benchmarked -across contract sizes and rule counts, priced as a fraction of the -LLM call it protects.* - -## What I built and why - -Sooner or later someone says "we skip validation on the hot path, -for performance." That sentence contains a number, and nobody in the -room knows what it is. So: measure. 2,000 validations per row, warm -cache (the first call pays dry-schema compilation — measuring that -would be benchmarking the wrong thing), and the one framing that -matters, which is that **overhead is a fraction**. Everyone quotes -the numerator; the denominator here is an 800ms model round-trip. - -``` -3 keys, no rules 0.0198ms 0.0025% of the call -10 keys, no rules 0.0411ms 0.0051% -10 keys, 5 relations 0.0600ms 0.0075% -30 keys, 15 relations 0.1426ms 0.0178% -rejection, 5 rules broken 0.7316ms (the slow path) -``` - -The whole table rounds to zero. The largest contract I could -pretend was realistic costs a seventh of a millisecond — 0.018% of -the call it guards. Five relation rules add twenty microseconds -over bare keys; relations scale linearly and gently. Skipping -validation "for performance" saves a rounding error and risks -shipping a malformed prompt to a call that *bills you for the -mistake*. That's not an optimization, it's a lottery ticket where -you pay to lose. - -## The slow path is the interesting row - -Rejection costs 0.73ms — 12x the happy path. That's the exception -plus five rule-violation reports being built, and it's the row I'd -watch in a hostile environment: if an attacker can make you *reject* -cheaply-sent garbage at 0.73ms a pop, the validator is your first -line of DoS absorption, not your bottleneck — but it's worth knowing -that failure costs more than success, because capacity planning on -the happy path is how systems fall over on the sad one. - -One measurement note, since benchmarks lie by default: the warm-up -call matters. Cold, the first validation compiles a dry-schema and -costs ~50x the steady state; a naive loop would smear that spike -across the average and report validation as 'slow'. Separate your -one-time costs from your per-call costs or you'll optimize the -wrong one. - -## Notes - -- Relations were the round-10 worry — "predicates as data" sounds - like interpretation overhead. The meter says: 4 microseconds per - relation. Building the lambda from the declaration happens once - per validation, and it's three hash lookups and a closure. Data - won. -- The 800ms denominator is conservative. Against a reasoning-model - call measured in seconds, the fraction gains another zero. - -## Verdict - -"Can we afford to validate?" was never the question — the question -is whether you can afford not to, and now both numbers are on the -table: 0.14ms against an 800ms call that charges for malformed -input. Validate both doors. The meter says you can afford it. diff --git a/docs/perspectives/round-10/04-fxn.md b/docs/perspectives/round-10/04-fxn.md deleted file mode 100644 index 3f3820f..0000000 --- a/docs/perspectives/round-10/04-fxn.md +++ /dev/null @@ -1,68 +0,0 @@ -# Round 10 field notes — Xavier Noria walks to the frontier - -*Built: `examples/projection_agreement.rb` — every presence -combination evaluated against both renderings of the relation rules -(Ruby validator, draft-07 projection), agreement proved point by -point, and the exact frontier where the renderings part ways, -mapped.* - -## What I built and why - -This round the relation rules began rendering twice: the validator -enforces them, and `to_json_schema` projects `requires` into -`dependencies` and `mutually_exclusive` into not-required clauses. -Two renderings of one law is exactly the situation where drift is -born — nothing forces a projection to stay faithful except a proof -that re-runs. So: four fields, sixteen presence combinations, both -evaluators, demand agreement on every point. - -``` -16 combinations, 0 disagreements -``` - -The draft-07 side is evaluated by a four-line interpreter for -exactly the projected keywords — deliberately not a schema library, -because the proof should depend on the spec text, not on another -implementation's opinions of it. - -## The frontier, surveyed precisely - -Sixteen agreements would have been a boring (if load-bearing) -result, so I walked to where I knew the metaphysics differ: Ruby's -relation presence is *given and non-nil*; JSON Schema's -`dependencies` trigger on the property *existing*, null or not. -`{express: nil}` should split them. - -It didn't — and the reason is the finding. For a **typed** field, -nil never reaches the relation check: per-key typing rejects it -first ("must be boolean"), and the schema rejects it too -(dependencies fire). Agreement, but *for different reasons* — the -most dangerous kind of agreement, because it dissolves the moment -someone relaxes a type. I proved that by declaring `express` without -a type: nil sails past per-key checks, the validator's relation -treats it as absent and allows, the schema's dependencies treat null -as present and reject. There it is: the true divergence, exhibited -on the one plane where it exists. - -So the certificate reads, in full: *the projection is faithful on -the nil-free plane; typed fields guard the frontier; untyped fields -plus explicit null is the crack.* Senders should omit keys, never -null them. Filed as the round-11 ask: align presence semantics -across the boundary or document them as officially distinct. - -## Notes - -- This is the survey-map pattern from my round-9 prover again: the - value isn't "it agrees," it's *knowing the exact shape of where it - doesn't*. An unscoped promise is a bug that hasn't picked its - reporter yet; this promise is now scoped to the character. -- Agreement-for-different-reasons deserves its own name in testing - folklore. Both doors said no, one for typing, one for presence — - a test asserting only the verdict would have called that a pass - and learned nothing. - -## Verdict - -Both renderings of the law agree everywhere the law is meant to -apply, and the one crack is mapped, named, and filed. Exit 0 — a -certificate with its own margins drawn in. diff --git a/docs/perspectives/round-10/05-ioquatix.md b/docs/perspectives/round-10/05-ioquatix.md deleted file mode 100644 index 4b8d8a8..0000000 --- a/docs/perspectives/round-10/05-ioquatix.md +++ /dev/null @@ -1,73 +0,0 @@ -# Round 10 field notes — Samuel Williams runs the cancel drill - -*Built: `examples/cancel_drill.rb` — three measured drills against -the two cancellation paths: surgical task cancel (in-flight and -pending) and plan-wide cancel. One of them fails the drill.* - -## What I built and why - -Structured concurrency makes exactly one non-negotiable promise: -**stop means stop, promptly**. Everything else — nurseries, barriers, -scoped lifetimes — exists to make that promise keepable. So before I -trust a cancel API in anything that bills by the token, I drill it, -and the drill is always the same: don't read the status, read the -*clock* and the *invoice*. - -``` -drill 1 - cancel one in-flight task at 30ms: - job2 began at 32ms on the canceled fiber's lane - not at 100ms -drill 2 - cancel one pending task: - agents actually ran: 5/6 - the canceled job never started, never billed -drill 3 - cancel_plan at 30ms: - status flipped to :canceled by 30ms... then the plan ran 301ms - anyway, 6/6 agents executed, results discarded -``` - -Drills 1 and 2 pass beautifully. `cancel_task` on an in-flight task -stops the fiber mid-sleep — and the proof is the *next job's start -time*: job2 began at 32ms on the freed lane, not at 100ms when the -canceled job would have finished. Canceling a pending task is even -better: it simply never runs. Queued work canceled is money returned. - -## Drill 3 is the finding - -`cancel_plan` flipped every status to `:canceled` within -milliseconds — and then the plan ran its full 300ms with **all six -agents executing**, their results thrown away on arrival. That's the -worst trade available: full cost, zero product. A dashboard would -show a plan canceled at 30ms; the invoice would show six completed -LLM calls; and both would be telling the truth about different -things, which is the most expensive kind of true. - -The mechanism: `cancel_plan` stops `@reactor` — but when -`execute_plan` joins an existing reactor (the composability we built -in round 1!), that handle isn't the private event loop it was -written to be, and stopping it doesn't reach the scheduler or the -in-flight fibers. Meanwhile the pending→canceled bookkeeping doesn't -stop `schedule_dependent_tasks` from starting those very tasks. The -promise breaks precisely at the intersection of two features that -each work alone. That's not a rare shape of bug; it's the *usual* -shape, and it's why you drill. - -Filed as the round-11 ask, with the drill as the acceptance test: -`cancel_plan` must stop the scheduler and the in-flight fibers — -`@barrier.stop` and per-task stops, not a reactor-handle stop — so -that drill 3 reads like drills 1 and 2. - -## Notes - -- Every claim in the output is a measurement: start timestamps prove - lane-freeing, agent-run counters prove billing, wall clocks prove - promptness. Status fields are testimony; clocks are evidence. -- Note drill 1's subtlety: total wall time was 300ms with or without - the freed lane — my first draft "proved" freeing from the total and - the arithmetic didn't hold. Only the third job's start time - discriminates. Sixth consecutive round of the tools correcting - their authors. - -## Verdict - -Task-level cancellation keeps the structured-concurrency promise; -plan-level cancellation currently sells its status cheaper than its -work. The drill is written, the ask is filed, and next round drill 3 -should cost 30 milliseconds instead of 300. diff --git a/docs/perspectives/round-10/06-jeremyevans.md b/docs/perspectives/round-10/06-jeremyevans.md deleted file mode 100644 index 825a9f4..0000000 --- a/docs/perspectives/round-10/06-jeremyevans.md +++ /dev/null @@ -1,78 +0,0 @@ -# Round 10 field notes — Jeremy Evans probes the new predicates - -*Built: `examples/relation_prober.rb` — thirteen edge probes against -a hand-written oracle for the three relations, then one deliberate -step off the paved road. The last probe draws blood; exit 1 by -design until the edge is filed down.* - -## What I built and why - -Relation-typed rules shipped this morning. New predicates deserve -hostility on day one, because day one is when their semantics are -still cheap to change. The prober asks the boring questions with -edge inputs — zeros, floats, negatives, missing keys, empty strings — -and checks every verdict against an oracle I wrote by hand, not -against the implementation's own opinion of itself: - -``` -sum_lte: exactly at the limit allow (lte means lte) -sum_lte: negative rescues the sum allow (15 + -6 <= 10) -sum_lte: missing field counts as 0 allow (documented, now proven) -requires: three-field chain broken reject -mutually_exclusive: empty string reject ("" is present - presence - is not truthiness) -13 probes, 0 divergences on the paved road -``` - -Two of those rows are the kind of semantic that starts arguments in -code review, which is exactly why they're pinned here: a *negative* -value can rescue a sum (arithmetic doesn't moralize), and an *empty -string* is present (the mutually-exclusive check counts given keys, -not truthy values — give both credentials, even blank ones, and you -are holding two credentials). - -## Off the paved road - -Then the probe that matters. A rule may reference a field the -contract never declared — nothing forbids it, and per-key validation -cannot type-check what isn't declared. So a string sails through to -`sum_lte`'s arithmetic: - -``` -RAW TypeError: "String can't be coerced into Integer" -``` - -A validator has one job: convert bad input into its *own* error -type, every time, so callers can write `rescue ValidationError` and -mean it. Here, sufficiently bad input crashes the validator instead. -Every 422 path guarding this code is silently also a 500 path, and -nobody's rescue clause knows it. This is the fail-open cousin of the -string-`raise` sins from round 1 — the failure isn't hidden, but it -arrives wearing the wrong uniform, which for a rescuer is the same -thing. - -The fix is a choice, and I filed both options as the round-11 ask: -**type-check relation fields at declaration time** (a `sum_lte` over -a declared string should refuse to construct — fail at boot, my -preference) **or wrap evaluation failures** into ValidationError at -call time. Either keeps the promise; the current code keeps neither. -The prober exits 1 until one of them ships, which makes it the -acceptance test, not just the complaint. - -## Notes - -- The oracle is a literal `:allow`/`:reject` column typed by hand. - Deriving expected values from any shared code would let a shared - bug agree with itself — the same discipline as the round-9 torture - test's recomputed concurrency. -- Three probes document semantics rather than test them (missing=0, - presence-not-truthiness, lte-not-lt). Once pinned by a prober, - they stop being implementation accidents and start being contract. - -## Verdict - -The paved road is solid: thirteen probes, zero divergences, and the -contested semantics are now pinned on purpose. Off the road, the new -predicates crash in the wrong uniform. Exit 1 by design — this -prober is the round-11 acceptance test, and it will go green the day -the edge is filed down. diff --git a/docs/perspectives/round-10/07-solnic.md b/docs/perspectives/round-10/07-solnic.md deleted file mode 100644 index 3bfedbc..0000000 --- a/docs/perspectives/round-10/07-solnic.md +++ /dev/null @@ -1,68 +0,0 @@ -# Round 10 field notes — Piotr Solnica diffs the laws - -*Built: `examples/relation_diff.rb` — semver classification for the -rules themselves: tightened limits, widened demands, changed laws, -added and removed rules, and the one honest shrug that remains.* - -## What I built and why - -My round-8 semver advisor ended every report with a qualifier I -hated: "3 breaking changes *in the declarations*." Rules were -lambdas; a diff cannot see inside a lambda; so the most dangerous -class of contract change — policy — was invisible to the one tool -whose job is noticing change. I filed the ask in round 9, the -relations shipped this morning, and this example is the payoff: - -``` -BREAKING rule :fits limit tightened 6000 -> 4000 -BREAKING rule :customs now also demands incoterm -BREAKING rule :one_auth changed LAW: mutually_exclusive -> requires -BREAKING rule :speedy added - a new law callers never agreed to -OPAQUE rule :audited is a lambda in both versions -COMPATIBLE rule :legacy removed -verdict: 4 breaking rule changes -> major version bump -``` - -The classification logic is the same variance reasoning as round 8, -now applied one level up. Rules constrain *inputs*, so they break -when they *tighten*: a lower `sum_lte` limit rejects previously -legal calls; a `requires` that demands one more field fails callers -who satisfied v1; a new rule is a law existing callers never agreed -to. Removal is the loosening direction — every v1-legal call stays -legal — so it's compatible, however alarming a deleted rule looks in -review. - -## The law-change row - -The subtlest classification is `:one_auth`: same rule id, same -fields, but the relation flipped from `mutually_exclusive` to -`requires`. That's not a tightening or a loosening — the two laws -aren't even comparable on one axis ("give at most one" versus "if -one, then both"). The diff refuses to arithmetic it and says what it -is: **a new contract wearing an old name**, breaking by definition. -Tools that force every change onto a tighter/looser spectrum -misclassify exactly these, and these are the ones that page you. - -And the lambda rule still gets the shrug — `OPAQUE, presumed -breaking` — but the meaning of that shrug has inverted. In round 8 -it was a ceiling on the tool; now it's a *choice made per rule*. If -`:audited` mattered to your consumers, you'd declare it as a -relation and it would join the diff. Opacity is now opt-in, which is -the correct default for escape hatches. - -## Notes - -- Presumed-breaking for opaque rules is the only safe default: a - diff that can't see a change must not certify its absence. The - advisor's job is to be conservative exactly where it is blind. -- Fourth derivation tool from the rules metadata in two rounds - (validation, generation, projection, now diffing) — the same - compounding the field declarations showed in rounds 5-8. Predicates - as data pays the same rent schedule. - -## Verdict - -The last opaque corner of the contract now diffs. "Is this breaking?" -covers the declarations *and* the laws over them, with one -honestly-labeled shrug remaining — and even the shrug is a choice -now, not a limitation. diff --git a/docs/perspectives/round-10/08-mperham.md b/docs/perspectives/round-10/08-mperham.md deleted file mode 100644 index 2f24336..0000000 --- a/docs/perspectives/round-10/08-mperham.md +++ /dev/null @@ -1,60 +0,0 @@ -# Round 10 field notes — Mike Perham gives retries a wallet - -*Built: `examples/retry_budget.rb` — one fleet-wide retry allowance: -transient failures spend from it, hopeless ones can't touch it, and -an empty wallet means failing fast instead of joining the storm.* - -## What I built and why - -A retry storm is the outage you throw yourself, on top of the one -you already have. Every job's retry policy is individually -reasonable — three attempts, backoff, jitter, all the round-5 -hygiene — and collectively insane, because during a real outage -*every* retry is doomed and every one of them costs a timeout, -a connection, and a line item: - -``` -strategy A - every job for itself: 45 calls at a dead host -strategy B - one wallet, 5 retries: 17 calls, 10 jobs failed fast -``` - -Twenty-eight requests deleted, zero value lost — the upstream was -down for all of them. The difference is one idea: **retries are a -shared resource**. Per-job policies answer "should I try again?"; -during an incident the only question that matters is "should -ANYONE?" — and a question about *anyone* needs state that belongs -to *everyone*, which is what the budget is. Round 9's breaker asked -the same question per-upstream; the budget asks it per-window. Both -are fleet-memory where per-job policies have only self-memory. - -## The nil convention, spending department - -The wallet composes with this round's other release: the auth job's -journaled verdict (`retryable: false`) means it never spends from -the budget — not because we're stingy, but because a hopeless -failure retried is a lie told twice, and worse, it *drains the -wallet the transient failures might still need*. Meanwhile a nil -verdict spends normally: suspicion, not a death sentence, exactly -per `TaskFailure#possibly_transient?`. Policy code finally splits -the three-valued verdict at the right joint without every author -re-deriving the joint. - -## Notes - -- The budget class is fifteen lines in the example because it wants - **non-blocking admission**: a `RateLimit` makes you *wait* for - capacity; a budget must tell you *no* right now. Waiting for retry - capacity during an outage would be a queue of doomed requests — - the storm with extra steps. Filed as the round-11 ask: - `RateLimit#try_acquire`, so windowed budgets can be RateLimits and - this class can retire. -- Failing fast when the wallet is empty is not giving up — it's - *believing the fleet's own evidence*. Five doomed retries in one - window is a diagnosis; the eleventh job doesn't need to reconfirm - it at the price of another timeout. - -## Verdict - -45 calls down to 17 with nothing lost, and the deleted 28 were the -ones that would have kept the upstream on its knees. Retry policies -are habits; budgets are decisions. Give the fleet a wallet. diff --git a/docs/perspectives/round-10/09-sandimetz.md b/docs/perspectives/round-10/09-sandimetz.md deleted file mode 100644 index e521ae7..0000000 --- a/docs/perspectives/round-10/09-sandimetz.md +++ /dev/null @@ -1,72 +0,0 @@ -# Round 10 field notes — Sandi Metz counts the consumers - -*Built: `examples/rule_shapes.rb` — one policy written three ways -(lambda, structured check, relation), audited by four consumers. -The table is the argument.* - -## What I built and why - -"Express shipments need a customs code" is one sentence of policy, -and this framework now offers three ways to write it down. When a -system gives you three representations of the same thing, that's -not redundancy — it's a design decision it has politely declined to -make *for* you. So the question worth an example is: how do you -choose? - -Not by taste. By **counting who must understand it**: - -``` -shape enforced explains generatable projects -lambda yes no no no -structured check yes yes no no -relation yes yes yes yes -``` - -All three enforce. If enforcement were the whole job they'd be -interchangeable, and style guides would argue about them forever -precisely because nothing real was at stake. But the consumers -differ, and each row is a different answer to "who else gets to -understand this policy?" - -- The **lambda** answers one message — `call` — so it has exactly - one consumer: the validator, at runtime, with real inputs in hand. - Everyone else (the message deriver, the generator, the schema - export, Piotr's diff) gets nothing. Code keeps secrets. -- The **structured check** adds `fields:` and `message:` — metadata - *about* the predicate. Now violations explain themselves and point - at their fields. Two more consumers, same opaque core. -- The **relation** makes the predicate itself data, and the - consumers multiply behind your back: tools that never *run* the - rule can still *read* it. This week alone: Matz asked it as a - question, DHH projected it into a schema, Piotr diffed it across - versions. None of those tools existed when the rule was declared. - That's the tell of a good representation — it keeps answering - questions it wasn't designed for. - -## The principle underneath - -This is the same lesson as my duck-agents parade last round, viewed -from the other side. There, a *narrow message contract* let five -shapes of object walk through one seam. Here, a *rich data contract* -lets one shape of rule serve five kinds of consumer. Both are the -same discipline: decide what must understand what, then choose the -representation that makes those dependencies cheap — messages when -behavior should stay private, data when it must be shared. - -And the closing caveat matters: save lambdas for policies that are -*genuinely* secrets — the fraud heuristic, the pricing curve. An -escape hatch used by default stops being an escape hatch and starts -being a ceiling. - -## Notes - -- The four consumer probes are each five lines and behavioral — the - table's "yes" means a consumer actually extracted value, not that - a capability was advertised. Audits should run, not read. - -## Verdict - -Representation isn't style; it's a decision about who else gets to -understand you. Count the consumers, then choose. Code keeps -secrets, data makes friends — and this framework now lets a policy -pick its social life per rule. diff --git a/docs/perspectives/round-10/10-ankane.md b/docs/perspectives/round-10/10-ankane.md deleted file mode 100644 index e7da072..0000000 --- a/docs/perspectives/round-10/10-ankane.md +++ /dev/null @@ -1,66 +0,0 @@ -# Round 10 field notes — Andrew Kane ships the reject file - -*Built: `examples/batch_import.rb` — 500 seeded-dirty rows through -one contract: 382 accepted, 118 rejected with line, field, and rule, -in 81ms.* - -## What I built and why - -Every data tool I've shipped eventually meets the same file: the -customer upload. Typos in enums (`"trian"`), zero weights, columns -that drifted a header to the left, and combinations that are -individually fine and jointly impossible. The two ways importers -die: they **raise on row 37** (an importer that crashes on the -first bad row is a tool for importing 36 rows), or they **write -"invalid row"** in a log (a reject file without reasons is a support -ticket generator). - -The contract turns out to supply both fixes for free: - -``` -accepted: 382 rejected: 118 (500 rows, 81ms, 162us/row) -reject causes: customs 45, mode 36, weight 26, fits 11 -line 12: customs: express requires customs_code -``` - -Collect-don't-crash is just `rescue ValidationError` per row — the -validator reports *every* violation on a row at once (round-5 -behavior), so a row with three problems generates one reject line -with three reasons, not three round-trips through support. And the -reasons are already sentences, because the relations derive their -own messages. - -## The rows only relations catch - -The interesting rejects are `fits: 11` and most of `customs: 45` — -rows where **every column is individually valid**. Weight 4,000: -fine. Volume 4,500: fine. Together: not fine, and no per-column -check — no spreadsheet data-validation dropdown, no CSV linter — -will ever catch it, because the error lives *between* columns. -Cross-field dirt is the dirt that survives all the usual cleaning, -which is exactly why it's the dirt that reaches production. One -declared `sum_lte` caught all eleven. - -Throughput note, because importers are batch jobs: 162 microseconds -a row including the rejection path. Aaron's bench said the same -thing this morning from the other side — at these prices you -validate everything and the bottleneck remains, as always, the -part that talks to the network. - -## Notes - -- The reject file records `line: index + 2` — one-based plus the - header row. Off-by-two line numbers in reject files have burned - more support hours than most bugs; if your reject file says line - 12, pressing ctrl-G 12 in the customer's actual CSV must land on - the bad row. -- The summary histogram (`customs 45 ####...`) is for the engineer; - the per-line file is for support. Same data, two audiences, both - derived — don't make either one read the other's report. - -## Verdict - -An importer is a contract with a patience policy. This one accepts -382 rows, explains 118 rejections down to the rule, and costs less -per row than a DNS lookup. Ship the reject file; your support queue -will send flowers. diff --git a/docs/perspectives/round-11/01-ko1.md b/docs/perspectives/round-11/01-ko1.md deleted file mode 100644 index 825a6d6..0000000 --- a/docs/perspectives/round-11/01-ko1.md +++ /dev/null @@ -1,69 +0,0 @@ -# Round 11 field notes — Koichi Sasada audits the promissory notes - -*Built: `examples/allocation_audit.rb` — exact per-operation object -counts via `GC.stat(:total_allocated_objects)`, plus where the GC -actually fires during a plan.* - -## What I built and why - -I think about programs the way the VM sees them: not as logic, but -as a stream of allocations arriving at the GC's door. Every object -is a promissory note — cheap to write, collected on schedule, and -the schedule does not consult your latency SLO. So my first question -for any framework is not "is it fast?" but "what does it *allocate*, -per operation, exactly?" Not sampled. Counted: - -``` -Task.new 18 objects -validator: happy path 37 objects -validator: rejection 440 objects -graph snapshot, isolated 96 objects -to_json_schema 29 objects -full 10-task plan 1661 objects, 0 GC runs -``` - -`total_allocated_objects` is a monotonic counter the VM maintains -anyway — the audit is free of observer distortion, which sampling -profilers never quite are. Warm-up matters: the first validation -compiles a dry-schema; charging that to the per-call price would be -accounting fraud. - -## Reading the numbers like a VM person - -- **37 objects per happy validation** is the number to multiply by - your request rate. At 1000 rps that's 37,000 notes a second, a - couple of young-gen pages — fine, *and now known to be fine*, - which is different from assumed. -- **Rejection costs 11x the happy path.** Exceptions carry - backtraces, messages, violation hashes. Error paths are allocation - paths — the classic mistake is capacity-planning the happy path - and then meeting a hostile client who makes you allocate 440 - objects per garbage request. -- **The graph snapshot's 96 objects are a purchase, not a leak.** - That's the dup+freeze immutability every graph tool since round 5 - leans on. When you see allocation in a profile, ask what it - *bought* before you delete it — half of "optimization" is - accidentally refunding a purchase the design depended on. -- **Zero GC runs in a 10-task plan** means plan overhead lives - comfortably inside a young generation. The framework will never be - why your agent process pauses; the 800ms network calls will hide - every pause the VM takes anyway. - -## Notes - -- I subtracted the orchestrator-construction cost from the graph row - by measuring the build alone. Attribution is the whole game in - allocation work; a hot method blamed for its callee's allocations - sends someone optimizing the wrong function for a week. -- What I'd measure next (round-12 thought, not quite an ask): object - *lifetimes*, not just counts — journal entries are written and - dropped (good, dies young), but `@results` accumulates for the - plan's life (fine for 10 tasks, interesting at 10,000). - -## Verdict - -The framework's allocation profile is boring, and boring is the -correct aesthetic for infrastructure: small numbers on hot paths, -honest purchases where immutability is sold, error paths priced at -11x and now labeled as such. Allocation isn't evil; unbudgeted -allocation is. There's a budget now. diff --git a/docs/perspectives/round-11/02-headius.md b/docs/perspectives/round-11/02-headius.md deleted file mode 100644 index 6669751..0000000 --- a/docs/perspectives/round-11/02-headius.md +++ /dev/null @@ -1,72 +0,0 @@ -# Round 11 field notes — Charles Nutter brings real threads - -*Built: `examples/threads_drill.rb` — the journal, the registry, and -the windowed limiter hammered by actual Ruby threads, judged by the -standard of a VM with no GVL to hide behind.* - -## What I built and why - -Fibers are polite: they yield when asked and never interrupt a -two-step dance. Threads are not, and on JRuby they run *actually in -parallel* — every "works fine in production" claim earned under -MRI's GVL gets re-examined on my VM, usually at 2am. So I drill -everything this gem calls shared: - -``` -journal, 8 threads x 150 events: 1200/1200 lines, 0 torn -registry, 8 threads x 50 each: 0 registrations lost -windowed try_acquire, ceiling 50: admitted 50/50 (observed) -``` - -The journal and the registry hold, and they hold for the right -reason: they *paid* — a Mutex, flock, fsync. Those survive real -parallelism because they're real locks, not scheduling luck. - -## The drill drew blood before the threads even started - -First run, drill 1 crashed in all eight threads at once: -`undefined method 'iso8601' for an instance of Time`. The journal -calls `Time#iso8601` — a method from the `time` stdlib — without -requiring it. Every previous example worked because something else -(async, loaded when an orchestrator or limiter was touched) had -required it first. My drill used the journal *bare*, and the -load-order debt came due. - -This is the classic works-on-my-boot bug, and it's exactly the -species JRuby users hit constantly: different load orders, different -lazy-loading, same gem, sudden NoMethodError on a stdlib method. -The fix is one line (`require "time"` where it's used) and it's in -this round's release — but the lesson is the durable part: **every -file must require what it uses.** Transitive requires are a loan -from a dependency's internals, and dependencies refinance without -telling you. - -## Luck wearing a lab coat - -Drill 3 is the honest one. The windowed limiter's `try_acquire` -reads `@stamps.size`, then appends — check-then-act, no mutex. Eight -threads, 1600 attempts, ceiling 50: admitted exactly 50. So it's -fine? No — it's *unobserved*. MRI's GVL makes the two steps nearly -atomic by scheduling accident; JRuby runs them genuinely -concurrently, and unsynchronized size-check-then-append is precisely -the shape that over-admits there. The drill prints both possible -outcomes honestly and files the round-12 ask: a Mutex around the -stamp bookkeeping, so the answer is the same on every Ruby. A lock -you only need on some VMs is a lock you need. - -## Notes - -- The journal's flock+fsync means it would hold across *processes*, - not just threads — the strongest claim in the gem, and the drill - only tested the weaker half. A multiprocess drill is the natural - sequel. -- Note what I did NOT ask for: making the orchestrator thread-safe. - It's fiber-architected and says so; the drill only holds - *explicitly shared* structures to the parallel standard. - -## Verdict - -Two structures paid for real locks and passed a real-parallelism -drill; one is coasting on the GVL and now has that in writing; and -the drill's warm-up caught a load-order bug that fiber-world never -would have. Bring threads to your gem before your users' VM does. diff --git a/docs/perspectives/round-11/03-nateberkopec.md b/docs/perspectives/round-11/03-nateberkopec.md deleted file mode 100644 index 9805457..0000000 --- a/docs/perspectives/round-11/03-nateberkopec.md +++ /dev/null @@ -1,69 +0,0 @@ -# Round 11 field notes — Nate Berkopec does the capacity math - -*Built: `examples/capacity_planner.rb` — Little's Law over the -journal's duration percentiles, then the plan checked against every -configured limit. The binding constraint wasn't the one the meeting -was about.* - -## What I built and why - -"How many workers do we need?" is the most expensive question teams -answer by feeling. The feeling-based answers cluster at two poles: -over-provision 4x (pay the cloud bill forever) or size to the demo -(page the on-call forever). Meanwhile the actual answer has been -math since 1961: **L = λW**. Concurrency needed equals arrival rate -times service time. The journal already stores W — per-task duration -samples across thirty runs — so the planner only needs λ, your peak -target: - -``` -task p50 p95 lanes (p50/p95) -fetch:ticket 83ms 158ms 1 / 1 -classify 364ms 751ms 1 / 2 -draft:reply 993ms 2387ms 2 / 5 -total at p95: 8 lanes for 120 tickets/min -``` - -**Plan for p95, not p50.** Capacity sized to the median queues every -time latency has a bad day, and in this journal latency has a bad -day one run in eight — which is what real latency looks like -(log-normal-ish, long right tail), not what the demo looked like. -The gap between the p50 plan (4 lanes) and the p95 plan (8) is -exactly the gap between "works" and "works during the incident." - -## The constraint that wasn't in the meeting - -Then the part that actually saves the quarter: check the computed -plan against *every* limit in the system, not just the one under -debate. The meeting was about `concurrency_limit: 8` — which, the -math says, *holds*. The provider quota of 90/min against 120/min -arrivals is the real story, and it's not a "slowdown": λ/μ > 1 has -**no steady state**. The queue grows without bound until something -breaks, and the something is usually memory, at 3am, wearing the -disguise of an unrelated alert. Utilization greater than one isn't -a performance problem; it's an arithmetic problem, and no amount of -worker tuning fixes arithmetic. - -This is the recurring shape of capacity incidents: everyone tunes -the limit they own, and the binding constraint belongs to a vendor -contract nobody re-read. The planner's job is to put all the limits -in one table with one verdict column. - -## Notes - -- The journal made this a twenty-line tool. Percentile baselines - across runs (`duration_percentile`, round 8) were built for - regression-hunting; capacity planning is the same data asked a - business question. Good telemetry keeps being reusable like this. -- The planner deliberately reports lanes *per task*: draft:reply - needs 5 of the 8, so if you shard workers by task type, that's - your split — and if draft:reply gets slower next release, Aaron's - perf tools and this planner will disagree with the cloud bill in - the same direction, which is how you know to act. - -## Verdict - -A journal plus Little's Law is a capacity plan; a dashboard plus a -feeling is a postmortem. The math took thirty lines, found the -binding constraint outside the meeting's agenda, and turned "how -many workers?" into a question with a receipt. diff --git a/docs/perspectives/round-11/04-zenspider.md b/docs/perspectives/round-11/04-zenspider.md deleted file mode 100644 index e12ef29..0000000 --- a/docs/perspectives/round-11/04-zenspider.md +++ /dev/null @@ -1,74 +0,0 @@ -# Round 11 field notes — Ryan Davis flogs the plans - -*Built: `examples/plan_flog.rb` — a pain score per task and per plan, -flog-style: joins, depth, anonymous edges, and orphans each cost -points; boring plans score zero.* - -## What I built and why - -Flog exists because "this method feels complicated" loses every -argument to "this method is fine, I wrote it." Numbers don't feel. -So: same treatment for plans. Every structural sin has a price — - -- **extra join inputs**, 1.5 each (a pipe is free; the *second* - input is where coupling starts) -- **fan-out past 2**, 1.0 (blame-spreading) -- **depth past 3**, 0.8 per level (latency hiding in a trench coat) -- **anonymous inputs at joins**, 1.2 (data flow you can't name is - data flow you can't debug) -- **orphans**, 5.0 flat (it runs on every execution and feeds - nothing — a bug or a billing strategy, pick one) - -``` -tidy pipeline 0.0 fine -labeled diamond 1.5 fine -the monster 24.5 REFACTOR ME do_everything=14.7 orphan=5.0 -``` - -## Calibration is the actual work - -My first scoring charged every dependency 1.5 — and the tidy -three-step pipeline scored 5.4. Garbage. A sequential pipe is the -*most idiomatic plan there is*; a metric that punishes idiom trains -people to ignore it, which is worse than no metric. The fix: pain -starts at the second join input, and anonymity only costs where -there's more than one input to confuse. Now the pipe scores 0.0 and -the diamond 1.5, which matches every practitioner's gut — that's -what calibration *is*: the metric agreeing with good taste on the -easy cases so it can overrule bad taste on the hard ones. - -(Flog went through exactly this. Early versions punished things -Rubyists do on purpose; the weights moved until they didn't. The -weights ARE the opinion. Own them.) - -## The number ends the meeting - -`do_everything` costs 14.7 on its own — five extra inputs of -coupling plus six anonymous edges. Everyone in the room already -*knew* the monster was a monster; what they lacked was a way to end -the "it's fine, it works" filibuster. "It's a 25; the threshold's -12" ends it. Then Sandi's refactor-receipts show *how* to dissolve -the join, Xavier's diff proves you did, and the flog score drops on -the next run — metric, method, proof, all from one graph accessor. - -The orphan deserves its flat 5: roots-that-are-also-leaves in a -multi-task plan came straight from `stats[:roots]` ∩ -`stats[:leaves]`, and it's the smell nobody looks for because -nothing *fails*. It just... runs. Forever. On your bill. - -## Notes - -- One number per plan AND per task — aggregate scores without - itemization are how metrics become astrology. The breakdown is - printed because a score you can't argue with is a score you can't - learn from. -- Thresholds (12 = refactor) are round numbers chosen to be argued - about. Good. Argue about the threshold, not about whether the - monster is fine. - -## Verdict - -Plans have flog now: idiom is free, coupling has a price list, and -the monster's 25 outlives everyone's patience for the word "fine." -Numbers don't refactor plans — they just end the meeting where -nobody was going to. diff --git a/docs/perspectives/round-11/05-avdi.md b/docs/perspectives/round-11/05-avdi.md deleted file mode 100644 index 897a82c..0000000 --- a/docs/perspectives/round-11/05-avdi.md +++ /dev/null @@ -1,72 +0,0 @@ -# Round 11 field notes — Avdi Grimm builds the barricade - -*Built: `examples/confident_pipeline.rb` — one pipeline, two -postures: ten conditionals of timidity versus a contract at the -door, then both fed the same garbage so the difference is behavior, -not aesthetics.* - -## What I built and why - -Timid code is easy to recognize once you hear it read aloud: it's -all subordinate clauses. *If the order isn't nil, and if the items -are an array, and if the price responds to arithmetic, then perhaps -we might total it.* Every method re-litigates reality because it -trusts nothing — including the methods it just called. The narrative -voice of the code is a worried mumble. - -``` -timid: 10 conditionals, 24 lines -confident: 0 conditionals, 9 lines -``` - -The confident version isn't brave; it's *organized*. All the doubt -is pushed to one barricade — a capability contract validated at the -input door and (this matters) at the output door too, because -honesty is also a promise about what you return. Inside the -barricade, every line is a declarative sentence about data that is -known to be shaped: `order[:items].sum { ... }`. Indicative mood. -No hedging. The `fetch(:qty, 1)` is the one permitted courtesy — a -*declared default*, which is confidence about optionality, not fear -of it. - -## The laundering demonstration - -The comparison that matters isn't line count — it's what each -posture does with garbage. Fed an order with a nil price and an -empty email: - -``` -timid: {total_cents: 0, delivery: "no receipt"} -confident: raises ValidationError - email rejected AT THE DOOR -``` - -Look closely at the timid answer. It's polite. It's well-formed. -It's *wrong* — zero dollars and no receipt, delivered with full -confidence to whatever ledger consumes it. All those nil checks -didn't handle the bad input; they **laundered** it, converting a -detectable error into a plausible lie. `return nil if` is not error -handling — it's error *forwarding*, with the sender's address torn -off. Some downstream system now owes a customer an explanation, and -the stack trace that would have named this method is gone. - -The confident version says no, out loud, at the door, with the -field names attached. Failure at the barricade is cheap, local, -and honest. Failure past the barricade is a mystery novel. - -## Notes - -- The conditional count is computed from this file's own source at - runtime — the example won't drift into claiming a difference it - no longer exhibits. -- The contract can't see inside array items (a nil price sails to - the output door, where the *output* contract would catch a - non-numeric total). Item-level schemas would deepen the barricade; - worth a future ask if list-shaped inputs become common. - -## Verdict - -Confidence isn't optimism — it's pushing all the doubt to the -boundary, where it can say no out loud. One barricade bought back -ten conditionals, and more importantly it converted a laundered lie -into an honest rejection. Write the happy path like it's happy; -make the door do the worrying. diff --git a/docs/perspectives/round-11/06-kytrinyx.md b/docs/perspectives/round-11/06-kytrinyx.md deleted file mode 100644 index 327a7ed..0000000 --- a/docs/perspectives/round-11/06-kytrinyx.md +++ /dev/null @@ -1,68 +0,0 @@ -# Round 11 field notes — Katrina Owen runs the kata - -*Built: `examples/plan_kata.rb` — red, green, refactor for a plan: -five structural assertions written before any task exists, the -smallest additions that move red lines green, two deliberate sins -caught by name, and a rebuild that ends all-green.* - -## What I built and why - -When I teach TDD, the resistance is never to the tests — it's to -writing them *first*, before you're attached to a design. So the -kata starts where katas should: an empty plan and five assertions -about a plan that doesn't exist yet. One entry point. One -deliverable. Joins that name their inputs. Nothing deeper than four -stages. No orphans. - -``` -empty plan -> 2 red (the honest starting point) -add the entry point -> 0 red -add parse -> 0 red -bolt on a price feed (2 sins) -> 2 red (caught, and NAMED) -rebuild: one entry, labeled -> 0 red -``` - -The step that earns the kata its keep is the fourth. "Bolt on a -price feed" is exactly how real plans degrade — a second source -lands as a second root, its join lands unlabeled, and in a -review-free world both would compost quietly into architecture. The -assertions objected *by name*: "has exactly one entry point: RED," -"every join names its inputs: RED." Tests written before anyone was -defensive about the design critique it without a meeting. - -Notice also what the assertions are written against: `stats[:roots]`, -`stats[:leaves]`, labeled edges, `max_depth`. The reflection API is -what makes plan-TDD *possible* — you cannot assert on what you -cannot observe cheaply. - -## What the refactor step taught - -The green-to-green refactor — dissolve the accidental second root by -routing both feeds through one door — required **rebuilding the -orchestrator from scratch**, because plans are add-only: there is no -`remove_task`, no rewire. The kata absorbs this gracefully (plans -here are cheap to rebuild, and cheap-to-rebuild is itself a virtue -worth practicing), but it's a real gap: refactoring under a green -suite is the entire payoff of having the suite, and today the only -refactoring move is demolition. Filed as the round-12 ask: -`remove_task` or a rewire seam, so a plan can change shape without -losing its identity. - -## Notes - -- Each step adds the *smallest* thing that moves a line. The - discipline looks pedantic on a six-task plan and becomes the only - thing that works on a sixty-task one — step size is a skill you - practice when it's easy so you have it when it isn't. -- Two of the five assertions are borrowed lessons: "no orphans" is - zenspider's 5.0-point smell as a boolean, and "joins name inputs" - is the round-8 spec generator's premise inverted into a gate. - Katas should steal from the room. - -## Verdict - -Plans can be test-driven: assert on structure first, grow in -smallest steps, let the reds name your sins while you're still -cheap to persuade. The one missing move — refactor without -demolition — is now on the asks list, with a kata as its acceptance -test. diff --git a/docs/perspectives/round-11/07-bbatsov.md b/docs/perspectives/round-11/07-bbatsov.md deleted file mode 100644 index 66dfdb4..0000000 --- a/docs/perspectives/round-11/07-bbatsov.md +++ /dev/null @@ -1,69 +0,0 @@ -# Round 11 field notes — Bozhidar Batsov puts cops on the contracts - -*Built: `examples/contract_cop.rb` — seven named cops for capability -specs, an offense report, and autocorrection for everything with -exactly one right answer.* - -## What I built and why - -The community style guide exists because "we argue about this in -every review" is a bug with a known fix: decide once, name the -decision, automate the enforcement. Contracts in this framework are -now the most-read documents in the system — by my count six tools -consume them (validator, docs, schema export, fixtures, diff, -polite-form) plus every human integrator — and the most-read -document is exactly where style buys the most. So, cops: - -``` -Naming/SnakeCaseName 'QuoteShipping' is not snake_case -Naming/SnakeCaseFields :weightKg is not snake_case -Documentation/Description capability has no description -Style/EnumOrder :Mode enum is not sorted -Lint/UntypedField :ref has no type (won't project) -Lint/OpaqueRuleWithoutMessage rule :check1 will say nothing -Metrics/RequiredInputCount 7 required inputs - one capability - or three? -8 offenses; autocorrect fixes 4 -``` - -Cop *names* matter as much as cop checks — `Lint/UntypedField` is a -sentence you can put in a commit message, a ticket, or a `.todo` -file. Anonymous complaints breed arguments; named ones breed -configuration. - -## The autocorrect line - -The split between corrected and remaining offenses is the design. -Autocorrect handles transformations with **exactly one right -answer**: snake_casing a name, sorting an enum. It refuses the rest, -each refusal with a reason: a description only the author can write; -a type that guessed wrong becomes a typed bug; a rule's message is -testimony you can't forge. RuboCop learned this boundary the hard -way — unsafe autocorrections cost more trust than they save -keystrokes, and a linter spends trust every time it speaks. - -Note which cops are style and which are load-bearing: -`Lint/UntypedField` isn't aesthetic — since round 11, untyped fields -keep relations out of schema projections, so `:ref` having no type -*silently narrows what five other tools can do*. The best lint rules -are the ones where "style" turns out to be an interoperability -contract wearing casual clothes. - -## Notes - -- `Metrics/RequiredInputCount` is the contracts' flog: past five - required inputs, the question isn't formatting, it's "is this one - capability or three?" Metrics cops should ask questions, not - issue verdicts — the number is the evidence, the design review is - the trial. -- Every cop is a lambda over the spec *hash*, not the object — same - data the diff and generator read. The whole linter is 40 lines - because the declarations did the hard work years... rounds ago. - -## Verdict - -Contracts have a style guide with teeth: seven named cops, four -mechanical fixes applied without discussion, four judgment calls -made impossible to not-see. Style is applied empathy for the next -reader — and these documents have six readers, so the empathy -compounds. diff --git a/docs/perspectives/round-11/08-josevalim.md b/docs/perspectives/round-11/08-josevalim.md deleted file mode 100644 index 1b784d8..0000000 --- a/docs/perspectives/round-11/08-josevalim.md +++ /dev/null @@ -1,67 +0,0 @@ -# Round 11 field notes — José Valim wires the telemetry bus - -*Built: `examples/telemetry_bus.rb` — named events on a bus with -attach/detach at runtime and crash isolation, bridged from the -orchestrator's lifecycle hooks in ten lines.* - -## What I built and why - -In Elixir we learned this lesson expensively: when every library -invents its own instrumentation callbacks, every metrics vendor -writes N adapters, every application couples to all of them, and -nobody can add a tracer without a deploy. The ecosystem converged on -`:telemetry` — tiny, boring, universal: events are **namespaced -tuples**, payloads split **measurements** (numbers) from **metadata** -(context), handlers attach and detach at runtime, and a crashing -handler is detached rather than allowed to take the caller down. - -This example is that design, standing on Agentic's hooks: - -``` -run 1: [trace] SLOW: enrich took 80ms - [bus] handler exporter crashed (IOError) - detached, plan unharmed - [metrics] {tasks: 3} -run 2 (tracer detached by ops): [metrics] {tasks: 6} -``` - -The orchestrator emitted identical events both runs. It cannot tell -that the tracer left or that the Friday exporter died, and that -ignorance is the entire feature: producers that know their consumers -grow opinions about them, and opinions become coupling, and coupling -becomes "we can't upgrade the metrics library until Q3." - -## Hooks are the floor, not the house - -I want to be precise about what I'm *not* criticizing. Lifecycle -hooks are the right primitive for a framework to export — one -configuration-time seam, no policy. But hooks couple one producer to -one consumer at configuration time; observability needs N consumers -changing at runtime. The bridge between them is ten lines because -the hooks carry exactly the right data (durations, failure types, -statuses — measurements and metadata, already separated in spirit). -A framework's job is to make the bus *possible* in ten lines, not to -ship the bus; ship the bus and you've chosen every user's metrics -vendor. - -Crash isolation deserves its sentence: the exporter raised, was -detached, was *reported*, and the plan never noticed. Instrumentation -must never be load-bearing — the day a tracing outage becomes an -application outage, someone deletes all the instrumentation, and -then you're blind *and* fragile. - -## Notes - -- Handler ids make detach targeted (`BUS.detach(:tracer)`) — the ops - story ("we got tired of that log line") is real and it should be a - one-liner, not a deploy. -- The ExecutionJournal is, in this framing, just another handler — - one that happens to fsync. Round 12 could re-express it as a bus - subscriber and the hooks stay clean forever. A thought, not an ask. - -## Verdict - -Callbacks are for configuration; events are for observation. The -framework's hooks turned out to be a good floor — ten lines of -bridge bought runtime attach/detach, crash isolation, and a producer -blissfully ignorant of its audience. Steal `:telemetry`'s design; -it was right the first time. diff --git a/docs/perspectives/round-11/09-jodosha.md b/docs/perspectives/round-11/09-jodosha.md deleted file mode 100644 index 445068b..0000000 --- a/docs/perspectives/round-11/09-jodosha.md +++ /dev/null @@ -1,68 +0,0 @@ -# Round 11 field notes — Luca Guidi keeps the center clean - -*Built: `examples/ports_and_adapters.rb` — a pure use-case speaking -only to ports, Agentic as one of two interchangeable delivery -mechanisms, and a mechanical purity scan proving the dependency -arrow points one way.* - -## What I built and why - -Every framework, including the ones I've built, whispers the same -temptation: *put your business logic inside me, where it's cozy.* -And every rescue project I've seen started with someone accepting. -The test of architecture isn't the happy years — it's the migration, -and the part of your app that survives a migration is exactly the -part that never learned the framework's name. - -So: `QuoteShipment`, a use-case in pure Ruby. Its entire knowledge -of the outside world is two **ports** — `#rate_for(mode)` and -`#save(result)` — named after what the *domain* needs, not after -what any vendor provides. The adapters (six lines each) live at the -edge. And then two acts: - -``` -act one - delivered by Agentic: {price_cents: 1080, ...} -act two - delivered by a bare call: {price_cents: 600, ...} -purity scan: 0 framework constants in the domain -``` - -Act two is the migration, rehearsed. The orchestrator leaves; the -use-case doesn't notice, because there was nothing to notice — the -dependency arrow points one way. The edge knows the center; the -center has never heard of the edge. - -## The scan is the architecture test - -Talking about clean architecture is worthless; *checking* it is -cheap. The domain's source lives in a string precisely so the -example can grep it for framework constants and **exit 1 on a leak**. -That's the whole discipline in one mechanical gate — the kind of -check that belongs in CI, because architectural erosion never -arrives as a decision; it arrives as one convenient `Agentic::` in -a domain file during a hotfix. - -What deserves praise: Agentic made act one *easy without demanding -tenancy*. The `agent:` seam takes any callable, so the use-case -walked in as `->(t) { use_case.call(**t.payload) }` — the framework -added retry policy, journaling, concurrency, and the graph *around* -the domain, without the domain signing anything. Frameworks -orchestrate; domains decide. A framework whose integration point is -"be callable" is a framework that respects the boundary — this is -Sandi's duck seam (round 9) viewed from the architecture side. - -## Notes - -- The ports are minimal on purpose: two methods, no base classes, no - registry. Ports are a vocabulary, not a bureaucracy — the moment a - port needs an abstract superclass, it has become an adapter with - ambitions. -- Act two also quietly demonstrates testability: the "bare call" IS - what a unit test of the domain looks like. No orchestrator in the - test suite, no framework boot time in the feedback loop. - -## Verdict - -The domain would survive the migration, and there's an exit code -that says so. Clean architecture isn't ceremony — it's the freedom -to change your mind about everything except the truth, and this gem, -to its credit, asks for nothing more than a `#call`. diff --git a/docs/perspectives/round-11/10-eileencodes.md b/docs/perspectives/round-11/10-eileencodes.md deleted file mode 100644 index ade6f2e..0000000 --- a/docs/perspectives/round-11/10-eileencodes.md +++ /dev/null @@ -1,69 +0,0 @@ -# Round 11 field notes — Eileen Uchitelle shards the plan - -*Built: `examples/tenant_shards.rb` — one pipeline definition run as -three isolated shard executions: per-shard journals, per-shard rate -limits, one ignorant control plane, and a crash that stays exactly -the size of its shard.* - -## What I built and why - -Scaling Rails at GitHub taught me that the hard part of "at scale" -is never the volume — it's that *every* concern you thought was -singular becomes plural. One database becomes N. One deploy becomes -N. And critically: one **failure story** becomes N, or else one -tenant's bad day becomes everyone's page. So when a plan grows past -one blast radius, the move is the same one we made with multi-db: -same definition, sharded execution: - -``` -run 1: shard_1 completed (6 steps) - shard_2 partial_failure (7 steps) <- crashed at umbrella:transform - shard_3 completed (3 steps) -run 2: fleet-wide rerun - shard_1: 0 ran, 6 skipped shard_3: 0 ran, 3 skipped - shard_2: 2 ran, 7 skipped <- resumed from the crash point -``` - -Each shard owns its journal (its recovery story) and its rate limit -(its noisy-neighbor containment). The pipeline is defined once — -sharding is a **data-model decision, not a code fork**, and the -moment shard code diverges from the definition you have N products -instead of N shards. - -## The control plane's ignorance is load-bearing - -The rerun is issued fleet-wide. The control plane does not know -which shard crashed, and this is a feature I will defend with the -energy of someone who has operated the alternative: a control plane -that tracks per-shard failure state *is a second database that can -disagree with the first*. Here the journals — fsynced at the moment -of truth by the shard itself — are the single source of recovery -truth, and "rerun everything" is safe, idempotent, and boring. -Descriptions as idempotency keys (round 3's design) turn out to be -exactly the shard-resume primitive: `umbrella:transform` means the -same work on every run, so the journal can vouch for it across -process generations. - -Two steps re-ran on shard 2 — the crashed one and the one behind it. -Not twenty-one steps (the fleet), not nine (the shard): two. Blast -radius ends at the shard boundary, and recovery cost ends at the -crash point. That's the whole contract. - -## Notes - -- Per-shard limiters matter as much as per-shard journals: with one - global limiter, a hot shard starves the fleet — Samuel's - fair-share lesson (round 9) applied at the shard tier. Isolation - has to be complete to be isolation; shared *anything* is a shared - fate. -- What I'd want next at real scale: shard journals in different - *locations* (the shard-1 disk dying shouldn't threaten shard-2's - recovery story). The `path:` parameter already permits it; noting - it as operational guidance, not an ask. - -## Verdict - -One definition, N executions, N recovery stories, N rate limits, -and a control plane whose ignorance keeps it honest. Scale isn't a -bigger machine — it's smaller failures, and the journal-per-shard -pattern makes failure exactly shard-sized. diff --git a/docs/perspectives/round-12/01-wycats.md b/docs/perspectives/round-12/01-wycats.md deleted file mode 100644 index d45e401..0000000 --- a/docs/perspectives/round-12/01-wycats.md +++ /dev/null @@ -1,68 +0,0 @@ -# Round 12 field notes — Yehuda Katz takes the census - -*Built: `examples/api_surface.rb` — the full public method surface of -eleven core classes, owner-checked, cross-referenced against 102 -example programs to split real API from accidental API.* - -## What I built and why - -Semver isn't a promise about your documentation — it's a promise -about **everything a user can call**. I learned this running Bundler -and Rails releases: the method you never documented is the method -whose removal breaks three build pipelines and a bank. So before any -1.0 conversation, you take the census: - -``` -total public surface: 112 methods -exercised by the corpus: 58 (52%) -accidental: 54 - including PlanOrchestrator#find_eligible_tasks, - #overall_status, #retry?, Task#perform... -``` - -The corpus is this repo's own 100+ example programs — the richest -usage dataset a pre-1.0 gem could dream of. Every method they call -carries a de facto semver promise *now*, docs or no docs; every -public method they don't is surface the maintainer is paying -interest on without collecting rent. - -## The stewardship read - -The accidental list is where release engineering lives, and the -right move is **declaration, not deletion**: - -- `PlanOrchestrator#find_eligible_tasks` and `#retry?` are scheduler - internals wearing public visibility. The day a user's code calls - `retry?` to make decisions, the retry engine can never be - restructured without a major bump. Privatize while it's free. -- `Task#perform` is the interesting hard case: it's *architecturally* - public (the orchestrator calls it) but *user-facing* private. That - distinction — audience-scoped API — is what `@api private` was - invented for, and Rails' whole `:nodoc:` culture exists because - Ruby's two visibility levels can't express three audiences. -- `TaskFailure#hopeless?` being unexercised is a *timing* artifact — - shipped two rounds ago, adopted by policy code, not yet by - examples. The census can't tell "accidental" from "young"; a - steward reads the column with a calendar in hand. - -First-draft note: the census originally reported 244 methods because -it counted inherited Psych/Object noise (`yaml_tag`, `allocate`) as -surface. Owner-checking the enumeration halved the ledger — get the -attribution wrong and the census indicts the wrong debt, which is -worse than no census. - -## Notes - -- The 52% exercised rate is *healthy* for this corpus — examples - skew toward the interesting seams. The number to watch is the - trend: surface growing faster than exercise means the gem is - speculating about what users want instead of finding out. -- The corpus method has a blind spot: internal callers (the - validator uses `RelationRules.check`; specs use everything). - A production census would union several corpora and weight them. - -## Verdict - -112 methods, 58 earning rent, 54 on loan. Public-by-default is a -loan against every future refactor, and this census is the bill — -cheap to pay today with a `private` keyword, expensive forever the -day after someone couples. Take the census before 1.0, not after. diff --git a/docs/perspectives/round-12/02-sarahmei.md b/docs/perspectives/round-12/02-sarahmei.md deleted file mode 100644 index 2c86885..0000000 --- a/docs/perspectives/round-12/02-sarahmei.md +++ /dev/null @@ -1,70 +0,0 @@ -# Round 12 field notes — Sarah Mei gives the house tour - -*Built: `examples/onboarding_trail.rb` — a nine-room reading tour of -the gem, ordered by the code's own who-mentions-whom so no stop -assumes a concept you haven't met, with one human sentence per room.* - -## What I built and why - -We talk about codebases like they're texts, but people don't *read* -codebases — they **live** in them. And when someone new moves in, -what they need isn't a blueprint (the file tree already is one); it's -a tour: which room to enter first, and why each room makes sense -given the ones you've seen. Nobody's first question is "where is the -scheduler?" It's "what should I read *first* so the rest makes -sense?" - -So the trail computes that ordering from the code itself — scan each -core file for mentions of the others, then repeatedly visit the room -with the fewest unmet concepts: - -``` -1. task_failure how this house talks about things going wrong -2. task_result the envelope every outcome arrives in -3. relation_rules predicates as data -... -8. plan_orchestrator the living room where everything meets -9. execution_journal the house's memory -``` - -## The house's values, visible on day one - -The trail starts with `task_failure`, and I want to sit with that, -because it isn't a quirk of the sort — it's the house telling you -who it is. This codebase defines *how it talks about things going -wrong* before it defines work, scheduling, or success. A new -teammate who reads room 1 has learned the house's **values** — -failure is data here, not an exception in both senses — and values -are the thing onboarding docs always mean to convey and never do. - -The other design choice I'll defend: the one-line room notes are -**human-written**, and everything else is derived. That's the -correct split. Structure is derivable (and should be, so the tour -never rots); *purpose* isn't — "the house's memory" is a sentence -only someone who lives here can say. Fully-generated docs are -accurate and useless; fully-handwritten docs are useful and stale. -The livable version is a derived skeleton wearing human sentences. - -## Notes - -- The trail prints a WARNING if the ordering ever visits a room - before its concepts — an honesty check on its own heuristic. Today - it's silent; the day someone adds a circular mention, the tour - itself will complain, which is how documentation should break: - loudly, at generation time. -- Line counts ride along on purpose (`plan_orchestrator: 731`). - Telling a newcomer room 8 is 7x the size of room 2 sets pacing - expectations — tours that hide the mansion's one enormous room - produce lost guests. -- What I'd add with more rounds: the *social* layer. Which rooms - change most often (git churn) is where the household actually - lives; a tour that ends "and this room is under renovation, ask - before moving furniture" is a tour of a real home. - -## Verdict - -A map answers "where"; a trail answers "in what order will this make -sense" — and the second question is the one every new teammate is -actually asking. Codebases are places people live. Give the new -roommate a tour, point out where the house keeps its values, and -mention which rooms are big before they open the door. diff --git a/docs/perspectives/round-12/03-schneems.md b/docs/perspectives/round-12/03-schneems.md deleted file mode 100644 index d032db0..0000000 --- a/docs/perspectives/round-12/03-schneems.md +++ /dev/null @@ -1,66 +0,0 @@ -# Round 12 field notes — Richard Schneeman reads the bill - -*Built: `examples/require_cost.rb` — derailed-style require costs -(RSS, objects, wall time) measured in pristine child processes, for -the gem and each major dependency — with a plot twist about when the -bill actually lands.* - -## What I built and why - -Years of Heroku support tickets taught me that memory problems are -almost never mysterious — they're just *itemized late*. Somebody adds -a gem in a two-line PR, the dyno graph climbs 30MB a month later, and -the two events are never introduced to each other. So: measure -requires like purchases, each in a clean subprocess so nobody's -transitive dependencies get billed to a neighbor's account. - -``` -json (stdlib) 0.4MB 4,805 objects 9ms -zeitwerk 0.5MB 7,610 10ms -async 2.8MB 35,476 51ms -dry-schema 2.3MB 46,822 67ms -agentic (require only) 0.6MB 11,976 17ms -agentic + first real touch 5.6MB 84,254 118ms -``` - -## The plot twist - -My first draft measured `require "agentic"`, saw 0.6MB, and nearly -wrote the wrong report. The gem's require is **nearly free** — the -round-1 Zeitwerk cleanup means nothing loads until a constant is -touched. The last row is the honest one: touch an orchestrator and a -validator, and async and dry-schema arrive through the autoloader — -5.6MB and 118ms, *at first use*. - -**Deferred is not free — it's a bill that arrives during your first -request instead of your boot.** Which one you want depends entirely -on who you are: a CLI or a small script pays only for what it -touches (beautiful — most invocations never load dry-schema at all); -a web worker eats 118ms of autoloading inside somebody's request -(page the on-call and tell them it's "lazy loading"). Same mechanism, -opposite verdicts, and a report that only measures one moment will -confidently tell you the wrong story. - -The moves this funds: `eager_load` + `preload_app` in servers — pay -the 5.6MB once in the parent and share it copy-on-write across all -eight workers; stay lazy in CLIs; and run the script in CI so a new -dependency's bill arrives *in the PR that adds it*. - -## Notes - -- Child-process isolation is non-negotiable for this measurement. In - a warm parent, half the dependencies are already loaded and every - row under-reports; my numbers looked suspiciously cheap until each - probe got its own pristine VM. -- Objects-at-require matters separately from RSS: dry-schema's 46k - objects are mostly long-lived constants — old-gen residents that - make every future major GC walk a bigger heap. RSS is rent; - old-gen objects are a homeowners association fee. - -## Verdict - -The gem itself is a courteous tenant (12k objects, deferred -everything); its dependencies are the furniture, arriving on first -touch. Both numbers are now on a receipt, and the receipt belongs in -CI — because the cheapest time to argue with a dependency is in the -PR that introduces it. diff --git a/docs/perspectives/round-12/04-palkan.md b/docs/perspectives/round-12/04-palkan.md deleted file mode 100644 index f6b27f3..0000000 --- a/docs/perspectives/round-12/04-palkan.md +++ /dev/null @@ -1,68 +0,0 @@ -# Round 12 field notes — Vladimir Dementyev profiles by group - -*Built: `examples/event_prof.rb` — TestProf's EventProf idea applied -to plans: task-seconds aggregated by tag, share-of-total before any -optimization talk, and an effective-parallelism line that indicts -the stage barriers.* - -## What I built and why - -TestProf exists because profiling by *file* answers the wrong -question. Nobody can act on "spec_a.rb is slow"; everyone can act on -"62% of your suite is factories." The unit of optimization is the -**group**, because groups map to causes and causes map to fixes. Same -move for plans — tag tasks by kind, collect durations from the hooks, -aggregate: - -``` -tag seconds share worst offender -llm 651ms 78.1% llm:draft (250ms) -db 101ms 12.2% db:fetch_orders (40ms) -render 81ms 9.7% render:pdf (50ms) - -task-seconds 833ms / wall 342ms = 2.4x parallelism on 3 lanes -``` - -Read the share column *before* touching any code: llm owns 78% of -all task-seconds, so a 20% win there beats deleting the entire -render stage. Optimizing db: or render: is polishing doorknobs on a -burning building — and without the table, doorknob-polishing is -exactly what happens, because render code is more fun to touch than -prompt budgets. - -## The second number - -Task-seconds by group is the TestProf classic; the parallelism line -is the plan-specific lesson. 833ms of work in 342ms of wall is 2.4x -on 3 lanes — meaning a chunk of a lane is going unused, and the -culprit is visible in the plan's shape: stage barriers. Every llm -task waits for *all* db tasks; every render waits for *all* llm. -`db:fetch_users` finished at 30ms but `llm:summarize` idled until -`db:fetch_orders` cleared at 40ms — multiply that slack across -stages and you've bought 3 lanes to run 2.4. - -The fix isn't more lanes (utilization, not capacity, is the -bottleneck) — it's finer dependencies: `needs:` lets an llm task -depend on exactly the db task it reads. The profiler doesn't make -that change; it makes it *undeniable*, then verifies it on the -re-run. Profile, fix the biggest group, re-profile. Boring, -effective. - -## Notes - -- Fifteen lines of profiler, because the hooks hand over exactly the - right tuple (description, duration) at exactly the right moment. - Instrumentation seams you don't have to fight are the difference - between "we should profile" and profiling. -- Tags ride in the description prefix (`llm:draft`) — the same - convention the journal's idempotency keys already reward. When one - naming convention feeds three tools (resume, triage, profiling), - it stops being a convention and starts being a schema. - -## Verdict - -"Where does the time go" now has a by-group answer with shares, -worst offenders, and a parallelism ratio that points at the barriers -rather than the budget. Read the share column first; let the biggest -group spend your optimization budget; make the profiler cheap enough -that re-running it is a reflex. diff --git a/docs/perspectives/round-12/05-flavorjones.md b/docs/perspectives/round-12/05-flavorjones.md deleted file mode 100644 index 83e67b5..0000000 --- a/docs/perspectives/round-12/05-flavorjones.md +++ /dev/null @@ -1,70 +0,0 @@ -# Round 12 field notes — Mike Dalessio feeds the parser real input - -*Built: `examples/hostile_inputs.rb` — eight hostile files against -`ExecutionJournal.replay`: torn tails, binary garbage, 8MB lines, -wrong-shaped JSON. Two probes draw blood; exit 1 by design until the -tail is tolerated.* - -## What I built and why - -Maintaining Nokogiri is a decades-long tutorial in one lesson: **a -parser's real specification is what it does with input nobody -intended.** Documents arrive truncated, mis-encoded, malicious, and -enormous — and a parser that only handles the happy grammar isn't a -parser, it's a demo. The journal's replay is a parser, and it has a -special obligation: the file it reads is, *by the journal's own -reason for existing*, a file that may end mid-write. - -``` -clean file (control) recovered (2 salvaged) -torn tail (crash mid-write) CRASHED: JSON::ParserError - ALL recovery denied -binary garbage line CRASHED: Encoding::CompatibilityError -empty + whitespace lines recovered -8MB single line recovered -valid JSON, wrong shape recovered -unknown event type recovered (skipped, correctly) -duplicate success lines recovered (idempotent, correctly) -``` - -## The indefensible probe - -Six of eight verdicts are genuinely good — unknown events skip, -duplicates are idempotent, giant lines and shape-garbage flow -through. But the torn tail is the one that matters, and it's the one -that kills. Walk the incident: the process dies mid-`record`. fsync -has made every *completed* line durable — the journal did its job. -The line being written *at* the moment of death lands half-formed at -the tail. Recovery runs, replay hits the torn line, and -`JSON::ParserError` flies past every `rescue ValidationError` in the -recovery tool — **100% of the events that were durable become -unreachable because of the one that wasn't.** The recovery tool is -now the second thing that failed, which is the one thing a recovery -tool must never be. - -The binary-garbage probe is the same wound in different clothes -(`Encoding::CompatibilityError` — a *third* uniform, for the -rescuers keeping count). - -Filed as the round-13 ask, with this probe as the acceptance test: -replay must salvage every whole line and *report* a damaged tail -(count it, expose it on the state) rather than raise on it. Tolerant -reading, loud accounting — the Nokogiri recovery-mode posture. - -## Notes - -- Note what I did NOT flag: `valid JSON, wrong shape` recovering - quietly is *defensible* for a recovery tool (salvage maximally) - but a stricter mode should exist for audit tools — Jeremy's - round-8 journal audit wants to *notice* wrong shapes, not skate - past them. One file format, two reader postures; both legitimate. -- The 8MB line recovering is worth a sentence: no line-length - assumptions, no fixed buffers. Good — length limits are where - "robustness" quietly becomes data loss. - -## Verdict - -Six probes pass with genuinely good manners; two crash in the wrong -uniform, and one of those two is the exact file a real crash writes. -Parsers meet real input, and real input is damaged — the journal's -reader needs to survive the very artifact its writer exists to -survive. Exit 1 until. diff --git a/docs/perspectives/round-12/06-searls.md b/docs/perspectives/round-12/06-searls.md deleted file mode 100644 index 9cab757..0000000 --- a/docs/perspectives/round-12/06-searls.md +++ /dev/null @@ -1,70 +0,0 @@ -# Round 12 field notes — Justin Searls checks the fakes' references - -*Built: `examples/honest_doubles.rb` — an owned port at the LLM -boundary, plus a verifier that rejects any double whose methods or -arity have drifted from the port. One double is honest; one has been -lying since last quarter.* - -## What I built and why - -Every agent test suite on earth contains a fake LLM, and I promise -you at least one of them is lying. Not maliciously — *entropically*. -Somebody wrote a stub against the client's interface in March, the -interface grew a keyword in June, and the stub has been vouching for -code that would crash on its first production call ever since. Green -suite. Broken app. The two rules that prevent this cost almost -nothing: - -**Rule 1: don't mock what you don't own.** Stubbing -`Agentic::LlmClient` couples every test to a vendor interface that -changes at gem-update speed. Instead, define *your* port — -`CompletionPort#complete(prompt, max_tokens:)` — one class that -names the entire vendor surface you permit yourself to use. (The -census two stalls over says the smaller that surface, the better; -we agree from different directions.) - -**Rule 2: verify every double against the port.** The verifier is -twenty lines: methods must exist, and *parameter shapes must match* -— required, optional, keyword, by name: - -``` -honest double: verified - method AND arity match -drifted double: REJECTED before any test ran - - port takes [[:req, :prompt], [:keyreq, :max_tokens]], - double takes [[:req, :prompt]] -``` - -## The treachery of unverified fakes - -Look at what the drifted double would have done *without* the -verifier: passed. Every test. `complete` responds, strings come -back, assertions green. Unverified fakes don't fail — **they -vouch**, and a false character witness is worse than no witness, -because it converts your test suite from an early-warning system -into a lullaby. The verifier moves the failure to load time, which -is the cheapest possible place: the drift is announced before a -single test runs, with both parameter lists printed so the fix -writes itself. - -This is, of course, what verified doubles in RSpec and Mocktail's -signature checks do — the point of hand-rolling it in forty lines is -to show there's no magic, just `Method#parameters` and the -discipline to call it. The mechanism is cheap; the *habit* is the -technology. - -## Notes - -- The port declines to be clever: no dynamic dispatch, no - method_missing forwarding. Boundary classes should be so boring - that drift has nowhere to hide. -- What this deliberately doesn't verify: return *values*. Arity - honesty is checkable statically; semantic honesty needs the - round-8 eval scorers pointed at the real adapter in a nightly - contract test. Both layers, different cadences. - -## Verdict - -Your tests are only as honest as their most casual fake. Own the -boundary with one port, make every double show its papers at load -time, and interface drift becomes a loud ArgumentError instead of a -quiet quarter of false confidence. diff --git a/docs/perspectives/round-12/07-rkh.md b/docs/perspectives/round-12/07-rkh.md deleted file mode 100644 index f40ae05..0000000 --- a/docs/perspectives/round-12/07-rkh.md +++ /dev/null @@ -1,72 +0,0 @@ -# Round 12 field notes — Konstantin Haase sweetens the surface - -*Built: `examples/plan_dsl.rb` — a thirty-line Sinatra-flavored DSL: -`step :name, after:, needs:` with the block as the agent, all sugar -over the public API, never inside it.* - -## What I built and why - -Sinatra's entire argument fit in four lines of README: an API is a -user interface, and interfaces should read like what they mean. -`get "/hi" do` didn't add a single capability Rack lacked — it -*removed the administration* between intention and expression. The -orchestrator's API is honest but administrative: construct tasks, -mind the ids, pass arrays. Meanwhile what a person *means* is: - -```ruby -plan = Plan.define do - step :fetch_orders do ... end - step :fetch_refunds do ... end - step :ledger, needs: {orders: :fetch_orders, refunds: :fetch_refunds} do |t| - t.needs[:orders].sum { ... } - t.needs[:refunds].sum { ... } - end - step :report, after: :ledger do |t| - "net revenue: $#{t.previous_output}" - end -end -``` - -Thirty lines of Builder later, that runs — on a completely real -orchestrator, with labeled edges the round-5-through-11 tools all -consume unchanged. - -## What the sugar buys, and what it refuses - -Three purchases: **names instead of ids** — symbols resolve to tasks -at definition time, so a typo'd `:fetch_order` raises at *define*, -the cheapest moment a wiring bug can exist. **The block is the -agent** — work lives inside the step that owns it, instead of a -lambda table three screens away. **`after:`/`needs:` read as -English** — the sentence shape carries the semantics (unlabeled -sequence vs named join) that round 8's spec generator later turns -into test obligations. - -One refusal, and it's the important one: the DSL **never reaches -into the engine**. Every line delegates to public API — `add_task`, -`execute_plan`, `graph`. That's not politeness; it's the survival -strategy DSLs forget. A DSL that touches internals is pinned to -them, drifts with them, and eventually *is* them, at which point -you've built a second engine with worse error messages. Sugar over -the API can never drift ahead of the engine, and anything it can't -express — retry policies, hooks, rewiring — you drop down one layer -*without rewriting*, because the Builder hands you the orchestrator -it built. The frontend should be a pleasure and the escape hatch -should be a door, not a wall. - -## Notes - -- `instance_eval` for the block, accepting its tradeoffs (no easy - access to the caller's self) for the reading experience. Sinatra - made the same trade for the same reason; if your users need the - other semantics, take the block arg form instead — one line. -- Deliberately absent: any DSL syntax for concurrency limits or - hooks. A DSL earns each word by being the *common case*; rare - cases belong on the layer below, where their full vocabulary - lives. - -## Verdict - -Thirty lines bought a surface that reads like the plan it describes, -fails wiring typos at define time, and can't drift from the engine -because it never left the public API. APIs are user interfaces — -sweeten the surface, keep the door open, change nothing underneath. diff --git a/docs/perspectives/round-12/08-obie.md b/docs/perspectives/round-12/08-obie.md deleted file mode 100644 index 8f78b0f..0000000 --- a/docs/perspectives/round-12/08-obie.md +++ /dev/null @@ -1,72 +0,0 @@ -# Round 12 field notes — Obie Fernandez ships the editor with the writer - -*Built: `examples/self_correcting_output.rb` — the self-correcting -output pattern: contract violations become the correction prompt for -a bounded retry loop. Attempt 1 is what models actually do to -schemas; attempt 2 ships.* - -## What I built and why - -I've spent the last stretch of my career cataloging the patterns -that separate AI demos from AI *applications*, and this is the one I -reach for first. Every model output that touches a database, an -invoice, or another system WILL eventually arrive with a string -where a number goes, a currency nobody ISO-coded, and a field simply -forgotten. The demo ignores this. The application needs a pattern: - -``` -attempt 1: {"vendor":"Initech...","total_cents":"4200","currency":"usd"} --> rejected; violations become the next prompt: - - total_cents: must be Numeric - - currency: must be one of: USD, EUR, GBP - - due_date: is missing -attempt 2: {..., "total_cents":4200, "currency":"USD", "due_date":"2026-08-01"} --> contract satisfied. shipped after 2 attempts. -``` - -**The contract is the editor; the model is the writer.** And the -discovery that makes this framework unusually good at the pattern: -the correction prompt writes itself. The same capability contract -that six other tools already consume produces violations that are -*pre-written actionable feedback* — "currency: must be one of: USD, -EUR, GBP" is precisely the sentence you'd hand a junior writer, and -it beats "please try again" by exactly the margin your production -error rate will show. I've watched teams hand-craft correction -prompt templates per field; declared constraints generate better -ones for free. - -## The discipline clauses - -Three parts of the loop are load-bearing, and teams skip them in -order of expensiveness: - -1. **Bounded attempts.** Unbounded self-correction is a billing - strategy. Three is the number; a model that can't satisfy a - schema in three coached tries has a prompt problem or a schema - problem, and more retries buy neither. -2. **Honest terminal failure.** When the loop exhausts, it raises - with the full ValidationError — every draft on record. A - correction loop that swallows its final failure is the timid - pipeline from Avdi's stall wearing a lab coat. -3. **Validate at the output door, not in the prompt.** Prompting - "respond with valid JSON matching..." is a request; the validator - is a *checkpoint*. You need both, and only one of them is a - guarantee. - -## Notes - -- Wire this at the CapabilityProvider seam and every LLM-backed - capability inherits the loop without knowing it exists — the - pattern wants to be middleware. That's the natural round-13 shape - if the room wants it. -- The scripted model's first draft (string number, lowercase - currency, missing field) isn't pessimism — it's a census of the - three most common schema sins in real extraction logs. - -## Verdict - -Self-correction is what makes "the model sometimes returns garbage" -an engineering statement instead of a product risk. The contract -supplies the red pen, the loop supplies the patience, the bound -supplies the budget — ship the editor with the writer; never ship -the writer alone. diff --git a/docs/perspectives/round-12/09-rafaelfranca.md b/docs/perspectives/round-12/09-rafaelfranca.md deleted file mode 100644 index f25f0ef..0000000 --- a/docs/perspectives/round-12/09-rafaelfranca.md +++ /dev/null @@ -1,70 +0,0 @@ -# Round 12 field notes — Rafael França renames without breaking anyone - -*Built: `examples/gentle_deprecations.rb` — a contract field rename -shipped through the three-release choreography: translate-and-warn -(once per call site, caller named), a migration tally, and a strict -mode that turns laggards' CI red on the maintainer's schedule.* - -## What I built and why - -Most of maintaining Rails isn't writing the better API — it's the -two years of not breaking anyone who used the worse one. A rename is -five minutes of code and three releases of choreography, and -frameworks that skip the choreography don't have users with old -code; they have ex-users. So: `weight:` becomes `weight_kg:`, and -the shim does the whole dance: - -``` -DEPRECATION: :weight is now :weight_kg (called from - legacy_billing_job (gentle_deprecations.rb:75); shows once per site) - -migration report: - weight at legacy_billing_job (...:75) 3 calls - weight at legacy_admin_panel (...:77) 2 calls - migrated call sites warn nothing and appear nowhere. - -strict mode: DEPRECATED input :weight - refused -``` - -## The three details that make it gentle - -**Once per call site.** Warn on every call and a busy legacy job -turns your logs into the outage; warn once globally and the admin -panel's usage hides behind the billing job's. Per-site is the only -granularity that's both quiet and complete — my first frame -arithmetic blamed the shim's own `each` loop until the site detector -learned to skip frames that belong to the shim and the API boundary. -Blame frames are the whole product here; get them wrong and the -deprecation report sends someone to fix the wrong file. - -**The tally is the roadmap.** Deprecation warnings people can ignore -are prayers; a *count by call site* is a migration plan with names -on it. Release N ships the shim, N+1 is spent chasing the tally to -zero (five calls, two sites, two small PRs — you can see the whole -job), and the report's most important line is the quiet one: -migrated sites appear nowhere. - -**Strict mode is the deadline.** The same shim, `strict: true`, -turns the old name into an ArgumentError — in the laggards' CI -today if they opt in (the deprecation-toolkit pattern), for everyone -at N+2. The deadline is enforced by red builds, never by broken -production, which is the entire difference between a framework users -trust with upgrades and one they fork and freeze. - -## Notes - -- The shim lives *outside* the contract — the v2 contract only knows - the new world, so every derived tool (schema, fixtures, diff, - round 10's whole toolbox) speaks the future tense while the shim - alone carries the past. Compatibility layers should be deletable - in one commit. -- `hits` as a Hash is deliberately boring; in production it's a - counter with the same shape. Deprecation is data about your users - — collect it like you mean it. - -## Verdict - -Renames are cheap; broken trust compounds. Translate at the door, -warn once per site with the caller's name, let the tally write the -migration plan, and let CI — not production — enforce the deadline. -That's how a framework gets to have both a past and a future. diff --git a/docs/perspectives/round-12/10-byroot.md b/docs/perspectives/round-12/10-byroot.md deleted file mode 100644 index 4923e06..0000000 --- a/docs/perspectives/round-12/10-byroot.md +++ /dev/null @@ -1,65 +0,0 @@ -# Round 12 field notes — Jean Boussier weighs the layers - -*Built: `examples/write_path_profile.rb` — the journal's write path -benchmarked one layer at a time: serialize, buffered write, flush, -flock+fsync, and the group-commit alternative. The profiler acquits -JSON and indicts nothing — the expensive layer is the promise.* - -## What I built and why - -I've spent enough years on Ruby's JSON and string internals to know -exactly how these conversations go: a journal is "slow", somebody -proposes switching serializers, three days are spent on a gem swap, -and the p99 doesn't move — because nobody weighed the layers first. -So, weigh them: - -``` -JSON.generate only 2.7us -+ buffered write 3.5us -+ flush to kernel 4.6us -journal.record (flock+fsync) 712.3us -group commit (fsync per 20) 76.5us -``` - -Serialization is **0.4%** of the real write. You could make -JSON.generate infinitely fast and the journal would be 99.6% as slow -as before. The other 707 microseconds are the fsync — the syscall -where the kernel promises the bytes survived power loss — and that -cost is not overhead. **It's the product.** The journal's only -promise is that a crash cannot unwrite what `record` returned from; -fsync is that promise's unit price. (I optimize stdlib JSON for a -living and I'm telling you: leave it alone here. It was already -fast, and it was already irrelevant.) - -## Group commit is a different promise, not a faster one - -The honest knob exists: batch 20 events per fsync and the amortized -write drops to 77us — a 9x improvement that every high-throughput -journal (databases, Kafka, WALs) eventually reaches for. But say -precisely what was traded: a crash can now eat up to 19 -*acknowledged* events. That's not an optimization; it's a **different -durability contract**, and only the caller knows which contract -their recovery story needs. An LLM-cost journal probably wants -per-event fsync (each event is money); a metrics journal wants group -commit (losing 19 counters is nothing). The right framework move — -if throughput ever matters here — is `fsync_every: n` as an explicit -constructor argument, so the trade has a name and a diff, not a -folklore. - -## Notes - -- Bench hygiene: each layer gets its own file and its own loop; the - "real" row goes through the actual `journal.record` including the - per-call open and flock, because users pay the whole path, not - the flattering subset. -- The flush row (4.6us) vs the fsync row (712us) is the pedagogical - gap: "flushed to the kernel" and "durable" differ by 150x, and - conflating them is how systems pass every test and lose data - anyway. The page cache is not a disk; it's an optimist. - -## Verdict - -The profiler acquitted JSON in one table and priced the actual -choice — per-event durability at 712us or group-commit throughput at -77us with 19 events of exposure. Optimization budgets follow -profiles or they follow fashion; this one now has a profile. diff --git a/docs/perspectives/round-13/01-piotrmurach.md b/docs/perspectives/round-13/01-piotrmurach.md deleted file mode 100644 index 017e979..0000000 --- a/docs/perspectives/round-13/01-piotrmurach.md +++ /dev/null @@ -1,71 +0,0 @@ -# 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 deleted file mode 100644 index 16cb89f..0000000 --- a/docs/perspectives/round-13/02-jnunemaker.md +++ /dev/null @@ -1,64 +0,0 @@ -# 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 deleted file mode 100644 index d253508..0000000 --- a/docs/perspectives/round-13/03-amatsuda.md +++ /dev/null @@ -1,69 +0,0 @@ -# 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 deleted file mode 100644 index 19fc053..0000000 --- a/docs/perspectives/round-13/04-davetron5000.md +++ /dev/null @@ -1,68 +0,0 @@ -# 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 deleted file mode 100644 index 420d0b1..0000000 --- a/docs/perspectives/round-13/05-hsbt.md +++ /dev/null @@ -1,68 +0,0 @@ -# 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 deleted file mode 100644 index 77e6e2e..0000000 --- a/docs/perspectives/round-13/06-noelrap.md +++ /dev/null @@ -1,67 +0,0 @@ -# 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 deleted file mode 100644 index c9d1f33..0000000 --- a/docs/perspectives/round-13/07-tomstuart.md +++ /dev/null @@ -1,72 +0,0 @@ -# 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 deleted file mode 100644 index b3e42fa..0000000 --- a/docs/perspectives/round-13/08-excid3.md +++ /dev/null @@ -1,68 +0,0 @@ -# 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 deleted file mode 100644 index 698bd5e..0000000 --- a/docs/perspectives/round-13/09-kaspth.md +++ /dev/null @@ -1,71 +0,0 @@ -# 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 deleted file mode 100644 index 5d4ddfa..0000000 --- a/docs/perspectives/round-13/10-steveklabnik.md +++ /dev/null @@ -1,70 +0,0 @@ -# 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 deleted file mode 100644 index 385dac9..0000000 --- a/docs/perspectives/round-14/01-evanphx.md +++ /dev/null @@ -1,60 +0,0 @@ -# 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 deleted file mode 100644 index 475904a..0000000 --- a/docs/perspectives/round-14/02-indirect.md +++ /dev/null @@ -1,71 +0,0 @@ -# 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 deleted file mode 100644 index 970c3aa..0000000 --- a/docs/perspectives/round-14/03-soutaro.md +++ /dev/null @@ -1,70 +0,0 @@ -# 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 deleted file mode 100644 index 03f632f..0000000 --- a/docs/perspectives/round-14/04-eregon.md +++ /dev/null @@ -1,70 +0,0 @@ -# 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 deleted file mode 100644 index 6cd52bb..0000000 --- a/docs/perspectives/round-14/05-yuki24.md +++ /dev/null @@ -1,73 +0,0 @@ -# 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 deleted file mode 100644 index 536fa4a..0000000 --- a/docs/perspectives/round-14/06-samsaffron.md +++ /dev/null @@ -1,66 +0,0 @@ -# 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 deleted file mode 100644 index 2a89171..0000000 --- a/docs/perspectives/round-14/07-rtomayko.md +++ /dev/null @@ -1,63 +0,0 @@ -# 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 deleted file mode 100644 index 971e189..0000000 --- a/docs/perspectives/round-14/08-marcandre.md +++ /dev/null @@ -1,63 +0,0 @@ -# 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 deleted file mode 100644 index 0a62551..0000000 --- a/docs/perspectives/round-14/09-rosa.md +++ /dev/null @@ -1,67 +0,0 @@ -# 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 deleted file mode 100644 index 8692b34..0000000 --- a/docs/perspectives/round-14/10-janko.md +++ /dev/null @@ -1,69 +0,0 @@ -# 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/docs/perspectives/round-2/01-matz.md b/docs/perspectives/round-2/01-matz.md deleted file mode 100644 index dec668b..0000000 --- a/docs/perspectives/round-2/01-matz.md +++ /dev/null @@ -1,51 +0,0 @@ -# Round 2 field notes — Matz builds a renga circle - -*Built: `examples/renga_circle.rb` — three poet agents compose a -linked-verse poem where the dependency graph is the poem's form.* - -## What I built and why - -Renga is collaborative poetry with a rule: your verse must answer the one -before it. That is a dependency graph wearing a kimono. So: Basho, Buson, -and Issa as agents, each with a `verse` capability in their own voice, and -a `PlanOrchestrator` whose task dependencies enforce the form — Buson -cannot begin until Basho has spoken. - -``` -first light, autumn wind - (Basho, no dependencies) -answering first: ... (Buson, depends on Basho) -yes, geese - and yet ... (Issa, depends on Buson) -``` - -Eighty milliseconds, `completed`, and a poem. I am content. - -## What building with it felt like - -- Registering a capability per poet and calling - `poet.execute_capability(...)` was pleasant — the lambda-as-craft idea - survives contact with a real (if small) program. -- The orchestrator's dependency declaration is lovely: - `add_task(task, [previous_task.id])` reads exactly like the rule it - encodes. - -## The friction, honestly - -- **Dependent tasks cannot see each other's output.** The whole point of - renga is that verse N reads verse N-1, but a `Task`'s `input` is frozen - at creation and the orchestrator does not pipe a completed task's output - into its dependents. I smuggled a shared `scroll` array into every - agent — mutable shared state, the thing the architecture documents say - they avoid. The framework knows the dependency exists (it scheduled - around it!) yet withholds the one thing the dependency produces. A - `task.input_from(other_task, :verse)` would make this program five lines - shorter and much more honest. -- I also had to invent a `RengaProvider` and a `PoetAtTheTable` adapter - struct because the orchestrator wants a provider-of-agents while I - already *had* my agents. `orchestrator.add_task(task, agent: poet)` - would have let the poets sit at the table directly. - -## Verdict - -The gem let me write a poem with a scheduler, which is the kind of -program Ruby exists for. The missing output-piping between dependent -tasks is the first thing a real user hits — worth building next. diff --git a/docs/perspectives/round-2/02-dhh.md b/docs/perspectives/round-2/02-dhh.md deleted file mode 100644 index d45cb60..0000000 --- a/docs/perspectives/round-2/02-dhh.md +++ /dev/null @@ -1,53 +0,0 @@ -# Round 2 field notes — DHH builds a ticket screener - -*Built: `examples/ticket_screener.rb` — a HEY-style screener: every -inbound ticket flows screen → categorize → draft, all tickets in -parallel, and what you get at the end is an inbox.* - -## What I built and why - -The Screener is the best idea in HEY, so that's the demo: five inbound -tickets, two of them junk. Each ticket is a task; the orchestrator fans -all five out in parallel; per ticket, one agent runs three capabilities -in sequence — screen it, categorize it, draft the reply a human will -approve. Output is the screen you'd actually ship: urgent engineering -issue on top with a draft under it, spam below the fold. Five tickets, -43ms, done. - -The three-lambda capability set is the honest version of the pitch: your -*pipeline* is the product, the LLM is an implementation detail. Swap -`draft_reply`'s lambda for the LLM client when you have a key; the inbox -doesn't change shape. - -## What building with it felt like - -- Capabilities-as-lambdas is genuinely good product clay. Three stages, - each declaring its inputs/outputs, each independently swappable — - I built a Screener without a framework diagram. -- The parallel fan-out was free. `concurrency_limit: 5`, add five tasks, - done. This is the part Rails people will not believe is one line. - -## The friction, honestly - -- **The provider ceremony is where my patience went.** I have an agent. - The orchestrator refuses to take my agent; it demands a *provider* that - will be asked to produce an agent per task, so I wrote a `TicketDesk` - struct with a `get_agent_for_task` and a singleton-method worker inside - it. That's thirty lines of adapter for zero domain meaning. Let me pass - a block: `orchestrator.on_task { |task| ... }`. Compress the concept. -- **Task input is dead weight for real work.** The task's `input:` hash - goes into prompt construction, but my worker needed the *ticket*, so I - looked it up by `task.description` like a caveman keying off a string. - Tasks should carry an arbitrary payload the agent can read. -- `Agentic.run` (my round-1 build) was no help here because this workload - is capability-driven, not planner-driven. Fine — but it tells you the - one-liner and the orchestrator live in different products right now. - The compression work isn't finished until they meet. - -## Verdict - -I shipped a Screener in an evening's worth of code, and the framework's -bones — capabilities, parallel tasks, result objects — held. The provider -indirection and the payload workaround are the two paper cuts I'd fix -before showing this to a Rails audience, because they'd ask "why?" twice, -and both times I'd have no answer. diff --git a/docs/perspectives/round-2/03-tenderlove.md b/docs/perspectives/round-2/03-tenderlove.md deleted file mode 100644 index f2497d0..0000000 --- a/docs/perspectives/round-2/03-tenderlove.md +++ /dev/null @@ -1,54 +0,0 @@ -# Round 2 field notes — Aaron Patterson builds the Performance Detective - -*Built: `examples/performance_detective.rb` — one orchestrator task per -Ruby file in `lib/`, each dissecting the file with Prism. The gem -investigates itself. The report names names.* - -## What I built and why - -63 files, one task each, fanned through the `PlanOrchestrator`; a -`dissect_file` capability parses each file with **Prism** (Ruby's own -parser, stdlib since 3.3) and measures every `def`. The output is a case -file: the seven longest methods in the gem and the densest files. - -The usual suspects, for the record: `generate_optimized_sequence` at 110 -lines, `schedule_task` at 90, `adjust_plan_via_llm` at 87. Sandi, your -victims are pre-selected; you're welcome. - -## The confession - -My first draft hand-rolled the method finder with regexes and an -`end`-counting stack. It reported a 353-line `store` method — because a -line reading `end,` (block as hash value) isn't `end`, so my stack never -popped and everything after got charged to `store`. Also three files blew -up with `invalid byte sequence in US-ASCII` because someone put a 🤖 in -`execution_observer.rb` and my `File.foreach` trusted `LANG`. Both bugs -vanished the moment I used the actual grammar: `Prism.parse_file` gives -you `DefNode#location.start_line/end_line` and handles encoding like a -parser should. The lesson never changes: **stop parsing Ruby with -regexes. We shipped you a parser. It's right there.** - -## The measurement that matters - -Concurrency 16: 96ms. Concurrency 1: 118ms. Nearly nothing — and that's -the honest, load-bearing observation for this framework: the orchestrator -runs tasks as **fibers under async**, which is cooperative concurrency -for *IO*. Parsing is CPU-bound, fibers don't parallelize CPU, so sixteen -lanes of traffic still share one engine. When your tasks are LLM calls -(network IO), this same fan-out is a massive win; when they're compute, -it's a progress bar. Frameworks should say this out loud in their docs — -users will assume `concurrency_limit: 16` means 16× everything. - -## Friction while building - -Same two walls Matz and David hit, so I'll just +1 them: I keyed the file -path through `task.description` because tasks carry no payload, and I -built a `Casefile` provider + singleton-method worker because the -orchestrator won't take a callable. The adapter tax is real: ~20 of my -~110 lines are plumbing that says nothing about detection or files. - -## Verdict - -Prism-powered self-audit through the gem's own scheduler, in about a -hundred lines. The fan-out API is genuinely pleasant once the adapter is -paid for — and the case file gave the whole team a refactoring hit list. diff --git a/docs/perspectives/round-2/04-fxn.md b/docs/perspectives/round-2/04-fxn.md deleted file mode 100644 index 9a8a244..0000000 --- a/docs/perspectives/round-2/04-fxn.md +++ /dev/null @@ -1,60 +0,0 @@ -# Round 2 field notes — Xavier Noria builds the Namespace Cartographer - -*Built: `examples/namespace_cartographer.rb` — one orchestrator task per -file, Prism reading actual definitions, producing a map of a gem's -constant tree and auditing every file against the constant its path -promises.* - -## What I built and why - -A cartographer, because the loader conventions are a *projection* between -two spaces — file paths and constant paths — and any projection deserves -a map. Point it at a `lib/` directory and it fans one survey task per -file through the `PlanOrchestrator`; each survey parses the file with -Prism and records every module, class, and constant it defines. Then the -map is compared with the territory: 63 files, and the verdict for this -gem after round one's cleanup is the sentence I hoped to print: - -> Every file defines the constant its path promises. The map IS the -> territory. - -## The best moment: my map was wrong first - -The first run reported one deviation: `agentic/version.rb`, "expected -`Agentic::Version`, defines `Agentic`". I nearly filed it as a finding — -then remembered whose rule this is. `Zeitwerk::Loader.for_gem` uses -`GemInflector`, which **special-cases the gem's `version.rb` to expect -`VERSION`**, precisely so the classic `Foo::VERSION` constant conforms. -My cartographer's inflector didn't know the special case, so the -deviation was in the *map*, not the territory. I taught the map the rule -and the deviation disappeared. - -I want to underline this because it is the whole discipline in -miniature: a conformance tool is itself a model of the convention, and a -model can be wrong in exactly the ways it accuses others of. Verify the -verifier. (It took `const_source_location` and a read of Zeitwerk's -`cref.rb` to be sure which side was mistaken.) - -## Building-with-it observations - -- The fan-out was the right shape for a survey: files are independent, - order is irrelevant, and the orchestrator's result object gave me - status and timing for free. 110ms for 63 files. -- Same adapter tax my colleagues reported: an `Expedition` provider - struct and a path smuggled through `task.description`. I now believe - this is the framework's single most instructive piece of user - feedback: four builders, four identical workarounds, one missing - affordance — tasks need a payload and the orchestrator should accept - agents (or callables) directly. -- Capability declarations (`inputs: {path: ...}` → `outputs: {defined: - ...}`) made the survey's contract explicit, and solnic's validator - enforced it while I iterated. Typed seams between stages are worth - their ceremony when a stage is being rewritten — which, see above, it - was. - -## Verdict - -The gem is a competent expedition outfitter: it carried Prism up the -mountain and back without complaint. And the exercise produced a -sentence every Zeitwerk user should frame: the map is not the territory -— except when your naming conventions hold, and then, wonderfully, it is. diff --git a/docs/perspectives/round-2/05-ioquatix.md b/docs/perspectives/round-2/05-ioquatix.md deleted file mode 100644 index ca40b39..0000000 --- a/docs/perspectives/round-2/05-ioquatix.md +++ /dev/null @@ -1,58 +0,0 @@ -# Round 2 field notes — Samuel Williams builds the Latency Lab - -*Built: `examples/latency_lab.rb` — 20 simulated LLM calls through the -orchestrator at three concurrency limits, plus a heartbeat sharing the -reactor to prove the plan composes instead of monopolizing.* - -## What I built and why - -Aaron's detective showed fibers buy nothing for CPU-bound work; this lab -shows what they buy for the workload this gem actually exists for. Twenty -tasks, each 200ms of simulated IO (`sleep`, which under the fiber -scheduler yields exactly like a socket read). Measured on this machine: - -``` -concurrency 1 -> 4.01s wall (ideal 4.00s) -concurrency 4 -> 1.00s wall (ideal 1.00s) -concurrency 20 -> 0.20s wall (ideal 0.20s) -``` - -Within 10ms of theoretical at every limit. That's the semaphore doing -precisely its job: at limit 20, twenty "API calls" cost one API call of -wall clock. This is the number to show anyone who asks why an agent -framework should care about structured concurrency — a 20-task LLM plan -is 4 seconds of latency serial and 0.2 seconds fanned out, and the code -difference is one integer. - -## The composition proof - -The second half runs the plan **inside** a host reactor, alongside a -heartbeat task beating every 100ms. The heartbeat kept beating (4 beats -during a 0.4s plan) — the orchestrator joined the reactor as a sibling -rather than seizing the event loop. This is the behavior my round-1 `Sync` -change bought, now demonstrated from the consumer side: you can embed a -plan in a Falcon request handler, next to your websocket pings, and -nobody starves. Before that change, this program would have printed the -starvation line. - -## Building-with-it observations - -- `sleep` being non-blocking inside tasks is delightful and *undocumented*. - Users writing custom agents need to know: any Ruby IO — `Net::HTTP`, - `sleep`, sockets — cooperates automatically under the reactor, and - anything that grabs the GVL for compute does not. One paragraph in the - README would set expectations for both. -- `PlanExecutionResult#execution_time` made the lab's measurement code - trivial — the framework timing its own plans is a small design decision - that keeps paying off. -- The one rough edge: `concurrency_limit` is per-orchestrator, but the - thing you actually want to bound is usually per-*provider* (one OpenAI - key = one rate limit shared across every plan in the process). A shared - semaphore injected into the client would express that. Noted for - round 3. - -## Verdict - -The concurrency story survives contact with measurement: ideal scaling -on IO, honest nothing on CPU, and polite cohabitation inside a host -reactor. That's the whole async contract, kept. diff --git a/docs/perspectives/round-2/06-jeremyevans.md b/docs/perspectives/round-2/06-jeremyevans.md deleted file mode 100644 index 75d7076..0000000 --- a/docs/perspectives/round-2/06-jeremyevans.md +++ /dev/null @@ -1,50 +0,0 @@ -# Round 2 field notes — Jeremy Evans builds the Schema Advisor - -*Built: `examples/schema_advisor.rb` — four deterministic DBA rules as -capabilities, one review task per table, advisories sorted by severity.* - -## What I built and why - -A schema review: feed it table definitions and a query log, get back the -advisories a careful DBA writes on every consulting gig — queries -filtering on unindexed columns (with the exact `add_index` to run), money -stored in floats, NULL-permissive columns, text primary keys. Three -tables and four logged queries produced thirteen advisories, every one of -them the kind of thing that pages you at 3 a.m. two years from now. - -The deliberate design decision: **the rules are deterministic lambdas, -not LLM prompts.** "You filter on `orders.user_id` and have no index on -it" is a *fact*, computable from the schema and the log, and a fact -should never be outsourced to a probabilistic system that might phrase it -differently on Tuesdays. The place an LLM would earn its keep in this -program is the one seat I left open: prose-summarizing the advisory list -for a human audience. Facts from rules, prose from models — that division -of labor is the correct architecture for every "AI code review" product I -have seen, and most of them get it backwards. - -## Building-with-it observations - -- Capabilities were the right container for rules: each declares its - inputs (`table`, `definition`, `queries`) and its output shape, so - adding rule five is registering one lambda. The registry gives you a - rule engine without writing a rule engine. -- solnic's validator earned its keep again: my first `check_money_types` - returned `advice:` strings under the wrong key and got an immediate - `ValidationError` naming the violation, instead of an empty report and - twenty minutes of puzzlement. Strict boundaries between stages are - cheap insurance exactly when you're writing many small stages. -- Per-table fan-out through the orchestrator is honest scaling: with 400 - tables instead of 3, `concurrency_limit: 4` becomes meaningful and the - program doesn't change. Correct programs should scale by changing - constants, not shape. -- The now-canonical gripes, confirmed independently once more: I keyed - the table through `task.description`, and wrote a `Consultation` - provider adapter. Five personas, five identical adapters. The evidence - phase is over; the API should accept the verdict. - -## Verdict - -A rule engine with typed seams and free parallelism, in one file, no new -dependencies. Wire it to a real `Sequel::Database#schema` and a -`pg_stat_statements` dump and this stops being an example — which is the -test an example should pass. diff --git a/docs/perspectives/round-2/07-solnic.md b/docs/perspectives/round-2/07-solnic.md deleted file mode 100644 index 7a68fbe..0000000 --- a/docs/perspectives/round-2/07-solnic.md +++ /dev/null @@ -1,64 +0,0 @@ -# Round 2 field notes — Piotr Solnica builds a typed ETL pipeline - -*Built: `examples/typed_pipeline.rb` — extract → transform → load as -contract-bearing capabilities composed via `registry.compose`; malformed -data is stopped at the first boundary that can name what's wrong.* - -## What I built and why - -Four raw payment events, one of them garbage (`user=` empty, -`amount_cents=not-a-number`). Three capabilities with deliberately -different strictness: **extract** is forgiving (parsing is not -judgment), **transform** is where loose fields must become facts (its -*output* contract requires a present user and numeric amount), **load** -trusts its input contract completely. `registry.compose` fuses them into -one `etl_pipeline` capability. - -The run prints the thesis better than I can: - -``` -POSTED ev-1 -REJECTED ev-3 at the 'transform' outputs boundary: - user: is missing - amount_cents: must be Numeric -LEDGER (only facts made it this far): - USD 1041.00 -``` - -Both violations, named, at the boundary that first noticed — and the -ledger arithmetic never saw the poison. That is the entire dry-rb -philosophy in fourteen lines of output. - -## The design move worth stealing - -The transform lambda doesn't validate. It *parses optimistically* and -lets its own **output contract** catch what didn't parse — `Integer()` -falls back to the raw string, empty user becomes an omitted key, and -the declared schema (built in round 1 on the gem's own dry-schema -dependency) does the rejecting with structured violations. Stages stay -dumb; boundaries stay strict. When validation logic lives in the -contract instead of the stage, adding stage four costs nothing and the -error messages stay uniform across the pipeline. - -## Building-with-it observations - -- `registry.compose` is a genuinely nice primitive — providers arrive as - an ordered array and the composition lambda is just function - composition. But the composed capability's own `inputs`/`outputs` are - **not declarable** (the `compose` signature accepts no contracts for - the whole), so the pipeline-as-a-unit has no contract even though - every stage does. The seam between compositions is exactly where - you want types most. -- One `ValidationError` carrying `capability`, `kind`, and all - violations made the rescue-and-report loop four lines. Errors designed - as data compose into UIs; errors designed as prose compose into grep. -- No orchestrator here, deliberately: per-record sequential flow with a - contract at each seam didn't need one. Right-sized tools — the - registry alone is a respectable pipeline runtime. - -## Verdict - -The gem let me express "data becomes facts at a named boundary" without -importing anything beyond what it already shipped. Give composed -capabilities their own contracts and this pattern would be -production-honest end to end. diff --git a/docs/perspectives/round-2/08-mperham.md b/docs/perspectives/round-2/08-mperham.md deleted file mode 100644 index 3c2e419..0000000 --- a/docs/perspectives/round-2/08-mperham.md +++ /dev/null @@ -1,51 +0,0 @@ -# Round 2 field notes — Mike Perham builds the Durable Batch - -*Built: `examples/durable_batch.rb` — six billable calls, a real -`exit!` mid-batch, and a resume that pays only for what the journal -can't prove was finished.* - -## What I built and why - -The demo every durability claim owes its users: don't *simulate* a -crash, **have one**. The batch runs in a forked child that dies with -`Process.exit!(97)` in the middle of invoice-4 — no `ensure`, no -`at_exit`, the honest `kill -9`. Then the parent process replays the -journal and finishes the batch: - -``` -!! power cut during invoice-4 - process dying with exit!(97) -journal replay: 3 invoice(s) already paid: invoice-1, invoice-2, invoice-3 -run 2: processing 3 invoice(s): invoice-4, invoice-5, invoice-6 -total spend: $1.75 for 6 invoices (naive rerun-everything: $2.50) -``` - -Seven calls paid for six invoices — the one unavoidable double-pay is -the call that was mid-flight when the power died (that's what -idempotency keys at the API layer are for). The naive rerun costs ten. -At example prices that's $0.75; at real batch sizes it's the difference -between "rerun it" being a shrug and being a budget meeting. - -## What the crash taught, beyond the point of it - -- The fsync-per-event decision from round 1 got its vindication: - `exit!` discards everything buffered — including, amusingly, the - child's *narration* (`$stdout.sync = true` restored the story, and if - journal lines had been buffered the way stdout was, the receipt would - be fiction). Durability you haven't crash-tested is a rumor. -- Found a real gap in my own round-1 design: `task_succeeded` events - carry the task *id* but not its *description*, and run 2's task ids - are new UUIDs — so mapping "what's done" back to "which invoice" meant - joining `task_started` events by hand. The journal should carry a - caller-supplied idempotency key on every event. My gap, my next PR. -- Resume is still caller-assembled: replay, diff, rebuild the - orchestrator with the remainder. It's eight honest lines, but - `PlanOrchestrator.resume(journal:, tasks:)` is the one-liner this - example proves the API is ready for. - -## Verdict - -Boring works: append, fsync, replay, skip. The framework's hooks let -durability be an accessory instead of a rewrite, and the crash test -passed on the first honest kill. Ship the idempotency key and the -`resume` helper, and this example becomes the README section titled -"when the deploy hits mid-plan." diff --git a/docs/perspectives/round-2/09-sandimetz.md b/docs/perspectives/round-2/09-sandimetz.md deleted file mode 100644 index 50b1530..0000000 --- a/docs/perspectives/round-2/09-sandimetz.md +++ /dev/null @@ -1,60 +0,0 @@ -# Round 2 field notes — Sandi Metz builds the Refactoring Dojo - -*Built: `examples/refactoring_dojo.rb` — three critic agents review a -method from three distinct perspectives in parallel; the sensei -prescribes exactly one next step.* - -## What I built and why - -A dojo, because review is a practice, not a gate. Three critics, three -*genuinely different* ways of seeing: the **rule keeper** counts (lines, -parameters — my rules are shorthand for "would you have to scroll to -lie about this method?"), the **squint tester** looks at shape (changes -in indentation are changes in concept), and the **name watcher** reads -the words (a `result` that appears nine times is a name refusing to -tell you what it holds). Each critic is an agent with one capability; -the orchestrator convenes the circle in parallel. - -Today's student, fittingly, is the gem itself — `schedule_task`, the -90-line second-place finisher on Aaron's suspects list: - -``` -rule keeper: 90 lines; the rule is five. -squint tester: 5 levels of shape change - each ridge is a concept - asking for its own method. -name watcher: 'result' appears 9x - a name that could mean anything - means nothing. -``` - -And the part I care most about — the sensei returns **one** step, not -three. A review that hands you every finding at once is a wall; a -practice hands you the smallest safe move and says "come back." -Refactoring is many small safe steps, not one brave rewrite. - -## What building with it taught me - -- Multiple-perspective review is what this framework's *architecture - documents* promise (the CriticFramework, multi-perspective - evaluation), and here's the encouraging news: the primitives that - exist — agents, capabilities, parallel tasks — were enough to build it - in a page. The vision isn't vaporware; it's an afternoon of - composition away. The documents should point at working code like - this instead of at unbuilt hubs. -- The critics measure; they do not opine. Deterministic critics agree - with themselves tomorrow, which is what makes them teachable — a - student can predict the critic, and predicting the critic IS the - lesson internalized. (An LLM critic belongs in the circle too, but as - a fourth voice, not the referee.) -- I'll say the quiet part about the adapter one more time, gently: my - `Dojo` provider found each critic by matching `task.description` - against an agent's name — string-keyed identity for objects I was - holding in my hand. Six of us have now written this same workaround. - The framework is being told something by its users; the polite thing - is to answer. - -## Verdict - -The gem let me express a event-of-practice — circle convenes, sees -differently, prescribes smally — in code a workshop attendee could read -over coffee. That's the test of a framework's vocabulary: can you teach -with it. You can. diff --git a/docs/perspectives/round-2/10-ankane.md b/docs/perspectives/round-2/10-ankane.md deleted file mode 100644 index d8d6467..0000000 --- a/docs/perspectives/round-2/10-ankane.md +++ /dev/null @@ -1,57 +0,0 @@ -# Round 2 field notes — Andrew Kane builds Gem Scout - -*Built: `examples/gem_scout.rb` — describe what you need, get a ranked -shortlist of gems: search capability finds candidates, a scoring -capability ranks them on adoption and maintenance.* - -## What I built and why - -The tool I actually use my own judgment for every week, as a pipeline: -`web_search` (the capability from my round 1, riding its pluggable -backend seam) finds candidates, `score_gem` ranks them on the things -that matter when you have to *live* with a dependency — adoption -(log-scale downloads) and release freshness — and the scout prints a -shortlist with reasons: - -``` -GEM SCOUT: "background jobs" --> sidekiq 97.4 widely adopted (950M downloads); recently released - good_job 70.5 recently released (14d ago) - solid_queue 60.2 recently released (30d ago) -``` - -Offline by default: the backend lambda serves a bundled index shaped -exactly like live search results, so the program can't tell the -difference — and going live is one assignment -(`WebSearch.backend = DuckDuckGo.new`). That seam existing is the -whole reason this example is twenty minutes of work instead of a -weekend. - -## What building with it confirmed - -- **Separating find from judge is the pattern.** Search returns - candidates; scoring is a different capability with different inputs - and its own contract. When I wire this to real data (rubygems.org API - for downloads, GitHub for commit recency), only `score_gem`'s lambda - changes. Capabilities as small swappable units is this gem's best - idea, and it held up across all ten of these builds. -- My scoring exposed its own bias immediately: "vector search" - recommends searchkick (130M downloads) over neighbor, which is the - *actually correct* answer for the query. Popularity-weighted ranking - recommends incumbents. Real Gem Scout needs a relevance term the - search score already computed — and the pipeline made that gap - visible in one run, which is what pipelines with visible seams are - for. -- No orchestrator needed: two capability calls in sequence. I keep - score of when the personas reached for `PlanOrchestrator` versus - plain capability calls — it was worth its adapter tax exactly when - there was real fan-out (files, tickets, tables) and not before. - Frameworks should say that in the README: start with capabilities, - add the orchestrator when you have a queue. - -## What I'd ship next - -Wire `score_gem` to the rubygems.org API (downloads, latest version -date) and add a `bundle add` prompt at the end. At that point this -stops being an example and becomes a gem — `gem_scout` — which is the -bar examples should aim for. diff --git a/docs/perspectives/round-3/01-matz.md b/docs/perspectives/round-3/01-matz.md deleted file mode 100644 index e1299dd..0000000 --- a/docs/perspectives/round-3/01-matz.md +++ /dev/null @@ -1,47 +0,0 @@ -# Round 3 field notes — Matz plays the telephone game - -*Built: `examples/telephone_game.rb` — a rumor passes through five -villagers, each hearing the previous version through the dependency -pipe and repeating it imperfectly.* - -## What I built and why - -In round 2 I asked for one thing: let dependent tasks *see* what their -dependencies produced. It exists now, so I built the program that is -nothing but that feature: the telephone game. Each villager's task -depends on the previous villager's, and the garbled rumor arrives via -`t.dependency_outputs` — no scroll smuggled through shared state, no -provider structs. The framework itself carries the whisper. - -"Old Tom saw a cat chase two mice" arrives at the town crier as -"HEAR YE: OLD TOM WRESTLED AN ENORMOUS CAT CHASE TWELVE WOLVES, DOWN BY -THE RIVER!!" — five hops, one millisecond, zero mutable globals. - -## Comparing my two rounds honestly - -My renga needed 110 lines and three pieces of scaffolding I resented: -the shared scroll, the `PoetAtTheTable` adapter, the `RengaProvider`. -The telephone game does *more* piping in ~50 lines and contains no -scaffolding at all — `add_task(task, [previous], agent: ->(t) { ... })` -is the entire wiring. This is what I mean when I say APIs should -disappear: the remaining code is all game, no framework. - -One line I especially enjoyed writing: -`heard = t.dependency_outputs.values.first || t.payload` — the first -villager has no dependency, so he reads the original rumor from his -payload. The nil case fell out naturally instead of needing a branch -somewhere else. When the empty case and the full case share a shape, -the design is right. - -## A small wish for round 4 - -`t.dependency_outputs.values.first` works but reads like plumbing. For -the extremely common one-dependency case, a `t.previous_output` (or -letting `output_of` default to the sole dependency) would make the line -sing. Grammar for the common case, hash access for the general one. - -## Verdict - -Round 2 I wrote a poem despite the framework; round 3 I wrote a joke -with it. Progress in a library is measured exactly there — in what you -stop noticing. diff --git a/docs/perspectives/round-3/02-dhh.md b/docs/perspectives/round-3/02-dhh.md deleted file mode 100644 index 507f3b3..0000000 --- a/docs/perspectives/round-3/02-dhh.md +++ /dev/null @@ -1,52 +0,0 @@ -# Round 3 field notes — DHH cancels the standup - -*Built: `examples/standup_digest.rb` — three collectors read the repo in -parallel, one writer fans their outputs in and publishes the digest.* - -## What I built and why - -The asynchronous standup is the calm-company move: nobody talks, the -repo speaks. Three collectors run in parallel — recent commits grouped -by theme, TODO/FIXME debt in `lib/`, the size of the safety net — and a -writer task that depends on all three composes the digest: - -``` -shipped: 12 recent commits (11 docs, 1 feat) -owed: 0 TODO/FIXME/HACK markers in lib/ (clean!) -guarded by: 530 examples across 57 spec files -``` - -Real data, real repo, 26ms. And the shape is the point: **fan-in**. The -writer declares `[commits, debt, tests]` as dependencies and reads -`t.output_of(commits)` for each. In round 2 this exact shape would have -required a shared hash and a provider struct; now it's the framework's -native grammar. This is what I meant by compression — the concept count -in my program dropped to the concept count of my *idea*. - -## What building it felt like this time - -- `add_task(task, [commits, debt, tests], agent: ->(t) { ... })` — - passing actual Task objects as dependencies instead of `.id` strings - is a small mercy that removes a whole category of typo. -- `payload` killed the "look it up by description" caveman move from my - ticket screener. Nothing in this program is keyed by string except - things that are actually strings. -- The collectors shelling to `git log` felt right, not hacky: the - framework doesn't care whether an agent is an LLM, a lambda, or a - subprocess. That agnosticism is worth protecting. - -## Remaining gripe, downgraded from complaint to suggestion - -The writer reads three outputs with three `t.output_of(...)` calls. -Fine. But notice the asymmetry: dependencies are declared in one place -and consumed in another, connected by nothing but my discipline. The -Rails move would be naming them: -`add_task(digest, needs: {shipped: commits, owed: debt}, ...)` then -`t.needs.shipped` in the agent. Declared and consumed under one name. -File under round 4. - -## Verdict - -Round 2 I shipped a screener despite thirty lines of adapter; round 3 I -shipped a standup-killer in zero. The roadmap wasn't advisory — it was -the product backlog, and it shipped. diff --git a/docs/perspectives/round-3/03-tenderlove.md b/docs/perspectives/round-3/03-tenderlove.md deleted file mode 100644 index abc1b61..0000000 --- a/docs/perspectives/round-3/03-tenderlove.md +++ /dev/null @@ -1,60 +0,0 @@ -# Round 3 field notes — Aaron Patterson draws the Plan Gantt - -*Built: `examples/plan_gantt.rb` — lifecycle hooks timestamp every task; -the run renders as an ASCII timeline. Found and fixed a scheduler -deadlock before the chart drew its first bar.* - -## What I built and why - -You can't reason about a scheduler you can't see. So: a six-task diamond -(three fetches → two joins → one report) with simulated IO, hooks -recording start/finish, and a Gantt renderer: - -``` -fetch:users |############ | 0-121ms -fetch:orders |#################### | 0-200ms -fetch:events |#################### | 0-201ms -join:revenue | ############### | 200-351ms -join:activity | ########## | 201-301ms -report:weekly | ###### | 351-411ms -serial floor 710ms -> actual 412ms (1.7x from the scheduler) -``` - -## The part where the chart never rendered - -First run: nothing. Not slow — **hung**. The diamond at -`concurrency_limit: 2` deadlocked the orchestrator, every time. - -The autopsy: `schedule_dependent_tasks` ran *inside the completing -task's semaphore slot*, and scheduling a dependent called -`semaphore.async` — which blocks when the semaphore is full. So when -both slot-holders finished around the same moment and each tried to -spawn its dependents, each blocked waiting for a slot that could only be -freed by... the other blocked holder. A textbook hold-and-wait, shipped -since the orchestrator was written, invisible because nothing before -this chart combined fan-in dependencies with a tight limit. My renga -(chain, limit 10) sailed past it; Samuel's latency lab (no deps) sailed -past it; the diamond at limit 2 hit it in one millisecond. - -The fix is the structured-concurrency idiom: spawn through the -**barrier** (non-blocking), acquire the semaphore **inside** the spawned -fiber. Slot-holders never block on spawning; waiters queue in their own -fibers. Ninety lines of `schedule_task` also got a long-overdue -extraction into `execute_task_in_slot` — Sandi's dojo had already put -that method on the suspects board, so consider this a twofer. Regression -spec included: diamond, tight limit, five-second timeout, must complete. - -## A subtlety the chart makes visible - -`fetch:events` shows 0–201ms but only *ran* for 80ms — the bar includes -121ms queued waiting for a slot, because `before_task_execution` fires -at schedule time, not slot-acquisition time. I left it: queue time IS -where your latency went, and a chart that hides saturation is a chart -that lies. But the hooks should probably grow a `task_slot_acquired` -event so tools can split wait from work. - -## Verdict - -Wrote a visualization, got a deadlock fix, a method extraction, and a -regression test. Observability tools pay for themselves before they're -finished — that's why you build them first, not after the incident. diff --git a/docs/perspectives/round-3/04-fxn.md b/docs/perspectives/round-3/04-fxn.md deleted file mode 100644 index f9a7c6d..0000000 --- a/docs/perspectives/round-3/04-fxn.md +++ /dev/null @@ -1,54 +0,0 @@ -# Round 3 field notes — Xavier Noria surveys the documentation - -*Built: `examples/doc_coverage.rb` — YARD comment coverage for every -public method, one survey task per file, one report task fanning all -surveys in through the dependency pipe.* - -## What I built and why - -Last round I mapped constants; this round I measured what the gem -*says about itself*. Prism supplies both the definitions and the -comments (`parsed.comments` with locations — the parser hands you the -prose as data), so coverage is a set intersection: a public `def` whose -preceding line is a comment is documented. Private methods are exempt — -documentation is for the public boundary; a `private` marker is itself -documentation of a different kind. - -The verdict on this gem: **322/357 public methods documented (90.2%)**, -which is genuinely high, and the undocumented residue is concentrated -exactly where you'd guess — the Thor CLI classes, at 0%. - -## The nuance worth writing down - -The CLI's 0% is partly a measurement artifact with a real lesson in it. -Thor commands are documented with `desc "list", "List available -agents"` — *runtime* documentation the survey doesn't count, because it -isn't a comment. Two documentation systems, one for the human at the -terminal and one for the human in the editor, and a file can be perfect -in one and invisible to the other. A tool that reports "0%" without -this caveat would be lying with statistics. Conformance tools must -document their own blind spots — that's the same lesson as round 2's -`version.rb` false positive, generalized. - -## Building on the improved framework - -- The fan-in report is the new API earning its keep in a shape my - round-2 cartographer couldn't express: one task depending on **64** - others, reading each survey with `t.output_of(s)`. No shared chart - hash, no expedition struct. The aggregation step is now *part of the - plan* rather than code after it — which matters, because it means the - report could itself have dependents. -- `payload:` carries the file path; `description` is now free to be a - human label rather than a smuggling route. Small change, but every - string in the program means what it says again. -- Prism note for fellow travelers: tracking `private` visibility means - walking `StatementsNode` children *in order* with carried state — a - fold, not a map. My first draft treated it as a recursive map and - quietly surveyed private methods. Order matters in class bodies; ask - your traversal to respect it. - -## Verdict - -The gem documented itself at 90% and the framework expressed -"survey everything, then summarize" as a single dependency graph. Both -facts would have taken more code to establish a week ago. diff --git a/docs/perspectives/round-3/05-ioquatix.md b/docs/perspectives/round-3/05-ioquatix.md deleted file mode 100644 index 581e363..0000000 --- a/docs/perspectives/round-3/05-ioquatix.md +++ /dev/null @@ -1,59 +0,0 @@ -# Round 3 field notes — Samuel Williams streams the plan live - -*Built: `examples/live_dashboard.rb` — lifecycle hooks publish onto an -`Async::Queue`; a renderer task in the same reactor draws the plan's -state while it runs.* - -## What I built and why - -The architecture documents have promised a `StreamingObservabilityHub` -since before round 1. I built the load-bearing part of it in thirty -structural lines, from parts already in the box: hooks enqueue events, -an `Async::Queue` carries them, and a sibling task dequeues and renders -— *while the plan executes*, in the same reactor, with timestamps to -prove it: - -``` - 0ms > running resize:thumbnails - 151ms + done resize:thumbnails (ran 151ms) - 151ms > running extract:captions - ... - 423ms = plan completed in 422ms -``` - -Every line printed live. No hub, no subscriber registry, no thread — a -queue and two fibers. The renderer's `dequeue` suspends when the queue -is empty and wakes when a hook enqueues; back-pressure and ordering come -free with the data structure. This is my standing argument about -observability systems: **the event stream is a queue, so use a queue.** -The remaining "hub" work is multiplexing to N consumers, which is -`Async::Queue` per subscriber and a fan-out loop — an afternoon. - -## What this build depended on, specifically - -- Aaron's deadlock fix from two days ago is the reason this demo is - honest: plan + renderer at `concurrency_limit: 2` is exactly the - saturated-reactor shape that used to hang. I ran it before writing - these notes; it didn't. Structured concurrency bugs die when someone - builds the tool that would witness them. -- The composition contract from my own round-1 fix carries the whole - design: `orchestrator.execute_plan` inside `Sync` joins the reactor, - so `renderer.wait` after it is ordinary structured concurrency — - spawn, do work, join. If the orchestrator still seized its own event - loop, this program would be two processes and a pipe. - -## One design observation for the maintainers - -Hooks fire *inline* in the task fiber, so a slow hook slows the plan — -today's hooks-to-queue pattern is safe precisely because `enqueue` is -O(1) and non-blocking. That property should be in the hooks' -documentation as a contract: "your hook runs on the task's critical -path; hand off anything slower than a hash insert." The dashboard is -both the demo and the recommended escape hatch. - -## Verdict - -The promised streaming layer turned out to be one queue away. Ship this -pattern in the docs, mark the hub as "compose it yourself from these -parts," and the architecture document loses its last piece of -fiction. diff --git a/docs/perspectives/round-3/06-jeremyevans.md b/docs/perspectives/round-3/06-jeremyevans.md deleted file mode 100644 index 86658e1..0000000 --- a/docs/perspectives/round-3/06-jeremyevans.md +++ /dev/null @@ -1,56 +0,0 @@ -# Round 3 field notes — Jeremy Evans fuzzes the boundary - -*Built: `examples/contract_fuzzer.rb` — for every registered -capability, generate inputs that should pass and mutations that should -fail, and verify the validator agrees. Deterministic by seed.* - -## What I built and why - -A validator is a claim: "conforming data passes, violating data does -not." Claims get tested. The fuzzer walks every registered capability's -declared contract and runs three trial families against it: - -1. **Conforming inputs must pass** — generated per declared type. -2. **Each required key, dropped, must fail.** -3. **Each typed key, corrupted, must fail** — a number where a string - was promised, a string where an array was. - -Seven standard capabilities, 34 trials, and the verdict I wanted to be -able to print: *the boundary holds*. Both directions matter equally — -a validator that rejects good data breaks working programs, one that -accepts bad data breaks the programs downstream, and only a -bidirectional fuzz distinguishes "strict" from "correct." - -## Determinism is the feature - -`Random.new(seed)` and every random choice drawn from it, with the seed -printed in the header and settable from ARGV. A fuzzer that can't -reproduce its own failure is a rumor generator. Run it twice, same -verdicts; file a bug with the seed, get the same failure on my machine. -This costs one line and I will die on this hill: **all** randomized -testing should work this way. (I also deliberately fuzz the *validator*, -not `provider.execute` — one of the standard capabilities talks to the -network when executed, and a fuzzer with side effects is a chaos -monkey, which is a different tool with a different consent form.) - -## What the exercise says about the framework - -- solnic's `CapabilityValidator` passed a test it wasn't written - against. That's what "the types are load-bearing" means in practice — - the declarations in `CapabilitySpecification` were precise enough for - a third party to mechanically derive both the passing and the failing - cases. Vague contracts can't be fuzzed; these could. -- The fuzzer found no defects *today*. Its value is the exit code: wire - it into CI and the next person who adds a capability with a mistyped - contract gets a named trial failure, not a production surprise. Cheap - insurance is the best kind. -- Gap worth recording: contracts can't yet express constraints beyond - type and presence — no ranges, no enums, no "non-empty array". The - fuzzer therefore can't test what can't be said. When contracts grow - expressiveness, this file is where their honesty gets checked. - -## Verdict - -Thirty-four trials, zero defects, one exit code CI can trust, and a -reproducibility guarantee. Boring, deterministic, adversarial — the -three virtues of infrastructure testing, in one file. diff --git a/docs/perspectives/round-3/07-solnic.md b/docs/perspectives/round-3/07-solnic.md deleted file mode 100644 index 02c6aa0..0000000 --- a/docs/perspectives/round-3/07-solnic.md +++ /dev/null @@ -1,58 +0,0 @@ -# Round 3 field notes — Piotr Solnica builds a typed command bus - -*Built: `examples/command_bus.rb` — commands are composed capabilities -with their own declared contracts; the bus is validation plus routing -and nothing else.* - -## What I built and why - -In round 2 I flagged one gap: `registry.compose` fused capabilities -into pipelines but the *composition itself* had no contract — types -everywhere except the seam users actually touch. That gap was closed in -the roadmap release (`compose(..., inputs:, outputs:)`), so I built the -pattern that gap was blocking: a **command bus** where every command is -a composition with a contract of its own. - -`PlaceOrder` composes `reserve_stock` + `record_entry` and declares -`{sku: string, quantity: number}` in, `{accepted: bool, events: array}` -out. Dispatching is four lines: look up the provider, execute, rescue -`ValidationError` into a rejection event. The run shows the shape I -care about most: - -``` -REJECTED PlaceOrder(sku: "widget", quantity: "many") - -> CommandRejected: quantity must be Numeric -REJECTED PlaceOrder(sku: "widget", quantity: 13) - -> OrderRejected: insufficient stock for widget -``` - -Two rejections, two *different layers*, both named. The contract -stopped `"many"` before any handler ran — the stock count never even -got read. The domain stopped 13 after consulting the shelf. **Types -stop nonsense; domains stop mistakes.** When those two rejections flow -through one undifferentiated `rescue => e`, every command handler -reimplements the difference badly; when the boundary is a typed -artifact, the bus does it once. - -## Notes from building on the improved seam - -- The composed contract validates *both directions*: while iterating I - briefly returned `events: "OrderPlaced"` (a string, not an array) and - the composition's own output contract caught my handler in the act. - Compositions that police themselves are what I asked for; it is - pleasant to be the first customer. -- The bus needed no bus class. Registry lookup *is* routing; contract - validation *is* input handling; the whole dispatch mechanism is a - method. When infrastructure disappears into the type layer, that's - usually the sign the type layer is placed correctly. -- Remaining wish, carried over from Jeremy's fuzzer notes: contract - expressiveness. `quantity: number` accepts `-3`, and no declared type - can currently say "positive integer" or "one of :standard, :express". - Predicates on declared keys (dry-logic is *right there*) would let - the boundary absorb another band of what is currently handler code. - -## Verdict - -Round 2 the pipeline had typed stages and an untyped whole; round 3 -the whole has a contract and a four-line bus makes it a system. -Boundaries first, then the pattern falls out — every time. diff --git a/docs/perspectives/round-3/08-mperham.md b/docs/perspectives/round-3/08-mperham.md deleted file mode 100644 index 969ce22..0000000 --- a/docs/perspectives/round-3/08-mperham.md +++ /dev/null @@ -1,64 +0,0 @@ -# Round 3 field notes — Mike Perham runs the Flaky API Drill - -*Built: `examples/flaky_api_drill.rb` — a scripted-flaky task under a -real retry policy with exponential backoff, journaled end to end.* - -## What I built and why - -Every reliability feature is a rumor until you watch it under failure. -The drill scripts the failure: an API that times out twice and delivers -on the third call, run with `max_retries: 3`, exponential backoff from -100ms, and the journal recording everything. The timeline is the -receipt: - -``` - 51ms > attempt 1 ... - 53ms x attempt failed: TimeoutError - 155ms > attempt 2 ... <- ~100ms backoff, as configured - 156ms x attempt failed: TimeoutError - 359ms > attempt 3 ... <- ~200ms backoff, doubled - 360ms + sync:accounts succeeded - 362ms + audit:trail succeeded: "audited 42 accounts" -``` - -Those gaps — 100ms, then 200ms — are the point. Before Samuel's round-1 -fix, the backoff code *computed* those delays, spawned a detached fiber -to sleep them, and retried immediately. The unit tests passed the whole -time because they asserted `sleep` was called, not that anything -waited. This drill is the test those tests should have been: wall-clock -timestamps on real retries. Reliability claims get verified in the -timeline or not at all. - -## What the improved framework contributed - -- **Journal idempotency keys** (my round-2 gap, closed in the roadmap - release): `state.completed?("sync:accounts")` answers **by name**. - Task ids are per-run UUIDs; descriptions survive reruns. The - resume-after-crash pattern from my durable batch no longer needs the - hand-joined event mapping — one method call. -- The journal keeps the *failures* too: two `task_failed` events with - error types on disk next to the successes. When ops asks "how flaky - was the upstream last night," the answer is `grep task_failed`, not - archaeology. -- The dependent `audit:trail` task shows retries compose with piping: - it waited through the whole ordeal and then read the final output via - `t.output_of(sync)`. Downstream tasks don't know retries happened — - which is exactly the abstraction boundary you want. - -## What I'd still harden - -- `retryable_errors: ["TimeoutError"]` matches class names as strings — - fine until someone's error is `Net::ReadTimeout` or a namespaced - `Errors::LlmTimeoutError` (which has a `retryable?` method the policy - ignores!). The retry policy should consult `failure.retryable?` when - the error object offers it, and fall back to the list. -- Backoff still lacks jitter-by-default. Two hundred workers retrying - an upstream on the same exponential schedule is a synchronized - stampede; `backoff_jitter: true` exists but defaults off. Reliability - defaults should assume the crowd. - -## Verdict - -Retries that wait, a journal that remembers failures, resume keyed by -name. The drill passed on the first run — which, given what the suite -used to hide, is the sentence worth framing. diff --git a/docs/perspectives/round-3/09-sandimetz.md b/docs/perspectives/round-3/09-sandimetz.md deleted file mode 100644 index ee49b0b..0000000 --- a/docs/perspectives/round-3/09-sandimetz.md +++ /dev/null @@ -1,55 +0,0 @@ -# Round 3 field notes — Sandi Metz traces the conversation - -*Built: `examples/collaboration_tracer.rb` — lifecycle hooks record -every message and reply; the run renders as a sequence diagram.* - -## What I built and why - -I teach that an object-oriented design *is* its messages — the classes -are just where messages live between sendings. An agent plan is the -same thing at a larger grain: the orchestrator addresses collaborators, -work flows back, outputs travel forward. So the teaching tool builds -itself: hook the lifecycle, record `{from, to, label}` triples, draw -lifelines and arrows. A three-agent editorial pipeline traces as eight -messages, and you can *read the design* off the page: perform goes out, -a reply comes back, the reply travels forward as "here's..." to the -next collaborator. - -The diagram teaches something subtle that the code doesn't say -loudly: **all messages route through the orchestrator.** Researcher -never addresses Writer — the orchestrator relays. That's a mediator -pattern, drawn plainly enough to discuss its trade-offs with a student: -mediators centralize coupling (good: collaborators don't know each -other) and centralize knowledge (risk: the mediator grows). You can -have that conversation in front of this diagram in a way you cannot in -front of `plan_orchestrator.rb`. - -## What the improved framework gave the trace - -- The "here's ..." arrows — outputs traveling to dependents — only - exist because piping is now a framework event I can observe from a - hook (`task.dependency_outputs` is populated before - `before_task_execution` fires; the ordering choice made this tool - possible). In round 2 that hand-off happened in *user* code, where no - hook could see it. When a framework absorbs a responsibility, the - responsibility becomes observable, testable, drawable. That is the - strongest argument for absorbing it. -- Each stage's work rode along as a lambda in `payload`. The tracer - needed zero knowledge of what any collaborator does — it draws only - who-said-what-to-whom, which is the correct ignorance for a - collaboration diagram. - -## An honest note on my own rendering code - -The diagram code is procedural string-poking — `line[pos] = "|"` — and -I left it that way on purpose. Not every fifty lines deserves objects; -extraction is a response to *pressure*, and a single-use renderer with -no variation points exerts none. Knowing when not to design is part of -design. (If a second output format ever appears, `Message` and -`Lifeline` are waiting.) - -## Verdict - -The framework's message-passing is now visible enough to teach from. -Round 1 I critiqued the code; round 3 the code can critique itself in -front of a classroom — that's the better position. diff --git a/docs/perspectives/round-3/10-ankane.md b/docs/perspectives/round-3/10-ankane.md deleted file mode 100644 index 0d33b04..0000000 --- a/docs/perspectives/round-3/10-ankane.md +++ /dev/null @@ -1,51 +0,0 @@ -# Round 3 field notes — Andrew Kane ships the Changelog Scout - -*Built: `examples/changelog_scout.rb` — classifies real git history -through a contract-checked capability and drafts release notes: -features, fixes, internals, and a one-line summary of the quiet work.* - -## What I built and why - -The release-notes chore, automated the way I'd actually automate it: -one `classify_commit` capability (subject in; kind, cleaned note, -breaking-flag out), one task per commit fanned out at concurrency 8, -one writer task that fans all forty classifications in and drafts the -markdown. Real repo, real history, 50ms. - -And the demo gods smiled: pointed at this branch, the scout's output -*is the summary of this whole experiment* — ten features, four fixes -(the suite truncation, the reactor nesting, the logger level, the -scheduler deadlock), one internal, twenty-five docs commits. A tool -that documents the project that built it on its first run is a tool -I'd package tonight. - -## The design choice worth copying - -The classifier is a deterministic lambda *behind a declared contract*. -Conventional-commit parsing covers 95% of real subjects for free — and -when you want an LLM to handle the messy 5% ("various fixes", "wip", -the Friday-afternoon specials), you swap the lambda for a client call -and **nothing else changes**, because the contract -(`kind/note/breaking`) is the interface the writer consumes. Start -deterministic, upgrade selectively, keep the seam typed. That's the -whole playbook for sprinkling LLMs into working software without -letting them eat the architecture. - -## Scorekeeping across three rounds - -I keep count of when the orchestrator earns its keep versus plain -capability calls. This one earns it twice: real fan-out (40 commits) -*and* fan-in (the writer needs all classifications). In round 2 I built -Gem Scout without the orchestrator because two sequential calls didn't -need a scheduler — and I stand by the rule the README now prints: -capabilities first, orchestrator when there's a queue, planner when -the task list itself should come from a model. The framework finally -documents its own gradient. Frameworks that tell you when *not* to use -their big hammer are the ones that survive. - -## What I'd ship next - -`--since v0.2.0` (tag-to-HEAD range), a `CHANGELOG.md` writer mode, and -a `--llm` flag that routes only unparseable subjects to a model. At -that point: `gem install changelog_scout`. Examples should keep -graduating into gems — that's the ecosystem working as intended. diff --git a/docs/perspectives/round-4/01-matz.md b/docs/perspectives/round-4/01-matz.md deleted file mode 100644 index a51930a..0000000 --- a/docs/perspectives/round-4/01-matz.md +++ /dev/null @@ -1,53 +0,0 @@ -# Round 4 field notes — Matz unfolds the exquisite corpse - -*Built: `examples/exquisite_corpse.rb` — three artists draw a creature's -parts without peeking; the assembler reads them by name and unfolds the -paper.* - -## What I built and why - -The surrealists' parlor game is secretly a concurrency diagram: three -independent workers, no shared knowledge, one fan-in reveal. Last round -the reveal would have read `t.dependency_outputs.values` and prayed -about ordering; this round the assembler says what it means: - -```ruby -orchestrator.add_task(reveal, needs: artists, agent: ->(t) { - t.needs.head + t.needs.torso + t.needs.legs -}) -``` - -`needs: artists` — my artists hash *is already* the declaration. And in -the agent, `t.needs.head` reads like the sentence "the reveal needs the -head." When the declaration and the consumption share a vocabulary, -there is no translation step for a bug to live in. - -## Small delights - -- Seeded randomness makes every creature reproducible: seed 7 gives the - cat-headed armored thing with acrobat legs; a bug report about a - malformed monster comes with its seed attached. (Jeremy has fully - converted me on this.) -- `previous_output` — my other round-3 wish — I didn't even need here, - and that is its own lesson: the two conveniences serve different - sentence shapes. Chains say "answer what came before"; gatherings say - "bring me the head." A good API has grammar for both and forces - neither. - -## One more wish, smaller than the last - -`needs: artists` worked because my hash happened to map names to tasks. -Lovely. But `t.needs.head + t.needs.torso + t.needs.legs` still spells -the stacking order by hand — `t.needs.to_h.values` loses the order I -declared. If `NamedOutputs#to_h` preserved *declaration* order (it -does, Ruby hashes are ordered — but nothing promises it), the assembler -could be `t.needs.to_h.values.flatten`. Promise the order in the -documentation; ordered hashes are one of Ruby's quiet gifts, and -promises are what make gifts usable. - -## Verdict - -Round 3's asks became round 4's grammar. The game took twenty minutes, -most of it spent drawing ASCII torsos — which is to say the framework -has reached the correct level of invisibility: the hard part of the -program was the art. diff --git a/docs/perspectives/round-4/02-dhh.md b/docs/perspectives/round-4/02-dhh.md deleted file mode 100644 index 9e60ed2..0000000 --- a/docs/perspectives/round-4/02-dhh.md +++ /dev/null @@ -1,49 +0,0 @@ -# Round 4 field notes — DHH replaces the onboarding wiki - -*Built: `examples/setup_doctor.rb` — four environment checks in -parallel, one diagnosis reading them by name, one exit code.* - -## What I built and why - -Every onboarding wiki page is a bug report against your tooling. The -doctor runs what the wiki would ask a new hire to do by hand — ruby -version against the gemspec, `bundle check`, git state, test suite -presence — and prescribes. Green means "write code, not wiki pages." -Red means the FIX lines are your first day's checklist, and it exits 1 -so CI can enforce it. - -The shape I care about is the diagnosis: - -```ruby -orchestrator.add_task(diagnosis, - needs: {ruby: ruby, bundle: bundle, git: git, suite: suite}, - agent: ->(t) { ... t.needs.ruby ... }) -``` - -Last round I asked for exactly this — dependencies declared and -consumed under one name — and it shipped. `t.needs.bundle` is -self-documenting in a way `t.dependency_outputs.values[1]` never was. -The asymmetry I complained about is gone: the declaration IS the -consumption vocabulary. This is the API a Rails person expects, which -I mean as the highest compliment I give. - -## Omakase notes - -- The checks are real, not simulated — this doctor diagnosed the very - repo it lives in and told me I had one uncommitted change (it was - itself; the doctor detected its own birth, which is very Basecamp). -- Each check returns `{ok:, detail:}` — a convention, not a contract. - I *chose* not to give the checks capability contracts because for a - five-check doctor that's ceremony. The framework let me choose. The - gradient the README now documents (capabilities first, orchestrator - for queues) works in the other direction too: sometimes a bare - lambda is the whole right answer. -- `bin/setup` should end by exec'ing this. Setup that verifies itself - is setup people trust; setup people trust never grows a wiki page. - -## Verdict - -Four rounds in, the pattern for me is one line long: the framework now -lets a small idea stay small. The doctor is 80 lines and half of them -are the actual checks — the framework's share of the file has become a -rounding error, which is where every framework should aspire to live. diff --git a/docs/perspectives/round-4/03-tenderlove.md b/docs/perspectives/round-4/03-tenderlove.md deleted file mode 100644 index 01fd5d6..0000000 --- a/docs/perspectives/round-4/03-tenderlove.md +++ /dev/null @@ -1,58 +0,0 @@ -# Round 4 field notes — Aaron Patterson finds the knee - -*Built: `examples/knee_finder.rb` — the same plan at seven concurrency -limits, measured with the `task_slot_acquired` hook, with a -recommendation for where adding lanes stops paying.* - -## What I built and why - -"What should `concurrency_limit` be?" is answered by superstition in -every codebase I've ever audited — someone typed 10 in 2019 and it -became scripture. The knee finder replaces the scripture with a -measurement: run the workload at 1, 2, 3, 4, 6, 8, 12; record wall time -and — new this round — **total queue-wait**, straight from the -`task_slot_acquired` hook I asked for; recommend the smallest limit -within 15% of the best wall time. - -``` -limit wall total queue-wait - 1 1205ms 6790ms - 4 350ms 1001ms - 6 300ms 470ms <- knee - 8 300ms 230ms - 12 300ms 0ms -recommendation: concurrency_limit 6 -``` - -Wall time flatlines at 300ms from limit 6 onward — because one call in -the workload takes 300ms, and **you cannot fan out a long pole**. Limits -8 and 12 buy zero wall time; they only buy down queue-wait, which is -invisible to your user and costs you open connections. That flatline is -the single most useful line in the chart, and it's exactly what the old -hooks couldn't show: without slot-acquisition timestamps, queue-wait and -run-time were smeared into one number and the knee was unfindable. - -## Confession, as tradition requires - -My first draft's workload had uniform latencies and the "knee" came out -at the maximum — a straight diagonal, recommendation useless. Real -workloads have a dominant slow call (there is *always* a slow call), and -the moment I added one, the curve grew its knee. Benchmarks that don't -model the long pole recommend infinity. This is the benchmark version of -regexes-vs-parsers: model the thing that actually dominates or your -tool confidently answers the wrong question. - -## Framework notes - -- The hook composes: `queue_wait += waited` is the entire integration. - One float closure, no instrumentation framework. Hooks that run - inline (now documented!) make accumulation this cheap safe. -- Gantt + knee finder are now a pair: the Gantt shows you *where* one - run's time went ('.' vs '#'); the knee finder shows you *how the - budget moves* across limits. Ship both in a `agentic-doctor` gem and - ops people will send you fruit baskets. - -## Verdict - -Asked for a hook in round 3, used it to kill a superstition in round 4. -That's the feedback loop working at the speed it should. diff --git a/docs/perspectives/round-4/04-fxn.md b/docs/perspectives/round-4/04-fxn.md deleted file mode 100644 index 379fd36..0000000 --- a/docs/perspectives/round-4/04-fxn.md +++ /dev/null @@ -1,58 +0,0 @@ -# Round 4 field notes — Xavier Noria charts the coupling - -*Built: `examples/coupling_cartographer.rb` — a constant-reference -graph between files: who defines what, who references it, which walls -bear load, which files lean hardest, and whether any pair leans on -each other.* - -## What I built and why - -Rounds 2 and 3 mapped names and prose; round 4 maps *forces*. Each file -is surveyed (Prism, in parallel) for constants defined and constants -referenced; the atlas task joins the two sides into a directed graph. -Ownership is resolved by trailing segment, because inside `module -Agentic` a reference reads `LlmClient`, not `Agentic::LlmClient` — -the survey must resolve constants the way Ruby does, relative to the -namespace you stand in, or the map measures a language that doesn't -exist. - -Findings for this gem: - -- **Load-bearing walls**: `llm_config.rb` (8 dependents) and - `errors.rb` (6). Both are leaf-like value/constant definitions — - exactly what you want at the bottom of a dependency graph. A change - to either is a change to a public commitment; their test coverage - should match their in-degree. -- **Heaviest leaners**: `cli.rb` at 15 — unsurprising and fine (a CLI - is a terminus; nothing leans back on it), and `agentic.rb` at 12, - which is the entry point doing entry-point things. -- **One mutual dependency**: `agentic.rb <-> llm_client.rb`. True and - known — the module owns configuration, the client reads it, the - module exposes `Agentic.client`. Mutual edges at the entry point are - tolerable; mutual edges between two mid-level files would be a - design smell. The atlas found exactly one, at the tolerable spot, - and none elsewhere: a genuinely clean graph. - -## The bug I wrote, in the spirit of full disclosure - -`Hash.new { |h, k| h[k] = [] }` leaked out of the builder into the -reader, and the mutual-dependency probe — merely *asking* whether a -file had edges — **invented** empty edge lists mid-iteration: -"can't add a new key into hash during iteration." A default proc is a -constructor's convenience and a reader's trap; copy it away at the -boundary. Loaders taught me this same lesson years ago: mutation -behind an innocent-looking read is where the strangest bugs live -(`const_missing`, anyone?). - -## Framework note - -The 65-way fan-in atlas is by now routine — third round in a row this -shape appears (digest, doc coverage, now this). It's the framework's -signature move: parallel facts, one joining task. The pattern deserves -a name in the docs. I propose *survey/atlas*. - -## Verdict - -The map shows a gem whose load flows downward onto small, stable -files, with one honest cycle at the front door. Cartography's highest -compliment: nothing surprising, now with evidence. diff --git a/docs/perspectives/round-4/05-ioquatix.md b/docs/perspectives/round-4/05-ioquatix.md deleted file mode 100644 index 21d1c8c..0000000 --- a/docs/perspectives/round-4/05-ioquatix.md +++ /dev/null @@ -1,62 +0,0 @@ -# Round 4 field notes — Samuel Williams shares the rate limit - -*Built: `examples/shared_rate_limit.rb` — two plans in one reactor, -one credential-scoped semaphore, ceiling held at 3 in-flight across -both.* - -## What I built and why - -My round-3 note said the thing you actually want to bound is usually -per-*credential*, not per-orchestrator: one OpenAI key means one rate -limit shared by every plan in the process. This round I built it in -userland to prove the primitives suffice: a `RateLimitedApi` owning an -`Async::Semaphore(3)`, handed to two orchestrators that each *think* -they're allowed 10 concurrent tasks. The run is the argument: - -``` -plan 1: completed, 8 tasks -plan 2: completed, 8 tasks -in-flight high-water mark: 3 (ceiling 3) - held -calls interleaved across plans: yes -``` - -Sixteen calls, both plans finishing, calls interleaving freely across -plan boundaries — and never more than three in flight. **Rate limits -belong to the resource, so the semaphore lives with the resource.** -The orchestrator's `concurrency_limit` is a scheduling policy; the -credential's ceiling is a law of physics. Two different numbers, two -different owners, and the design falls out correctly the moment you -ask who owns each. - -## Structured-concurrency notes - -- Both plans run as sibling `Async` tasks under one `Sync` — three - rounds of composability work (`Sync` in execute_plan, the barrier - spawn fix) are what make "two orchestrators in one reactor" a - two-line expression instead of a threading design document. -- The semaphore + fiber scheduler interaction is doing quiet heavy - lifting: a task blocked on the credential's semaphore yields its - *orchestrator* slot's fiber but not the reactor — sibling tasks from - the other plan run through the gap. That's why the interleaving - check passes; a thread-per-task design would show convoy effects - here. -- The high-water mark is the honest metric. Don't assert "the - semaphore works" — count concurrent entries and report the max. Any - future refactor that breaks the ceiling turns "held" into - "BREACHED" in the output, which makes this example its own - regression test. - -## For the maintainers - -This pattern is one small class away from being a feature: -`Agentic::RateLimit.new(ceiling)` that an `LlmClient` (or any agent) -wraps its calls in, shareable across plans. The example is the design -document; the class is an afternoon. I'd also accept -"`LlmClient` accepts a `limiter:`" as the minimal version. - -## Verdict - -Per-credential rate limiting: asked for in round 3, demonstrated from -primitives in round 4, one high-water mark from being a feature. The -reactor did exactly what structured concurrency promises — nothing -surprising happened, measurably. diff --git a/docs/perspectives/round-4/06-jeremyevans.md b/docs/perspectives/round-4/06-jeremyevans.md deleted file mode 100644 index 1877fcb..0000000 --- a/docs/perspectives/round-4/06-jeremyevans.md +++ /dev/null @@ -1,60 +0,0 @@ -# Round 4 field notes — Jeremy Evans posts the invariant sentinel - -*Built: `examples/invariant_sentinel.rb` — domain invariants checked -after every task from a lifecycle hook; a seeded off-by-one is caught -at the task that caused it, and the plan stops. Also fixed the plan -status lying about cancellation.* - -## What I built and why - -Validation checks data at boundaries; invariants check the *world* -between steps. The sentinel is a hook that runs every declared law -after every task: stock never negative, stock always equals initial + -received − picked. One picker in the job list decrements 3 while -recording 2 — the accounting bug every warehouse system eventually -writes — and the run shows the payoff: - -``` -LAW BROKEN: "stock equals initial + received - picked" - by: pick 2 widgets (buggy picker) -jobs completed before the stop: 3 of 4 -``` - -The corrupting task is named, the world state at the moment of arrest -is printed, and the fourth job never ran. Corruption caught at the -task that caused it is a bug report; corruption found at month-end -close is an incident with a conference call. The second invariant is -the one that fired, note — the *conservation law*, not the obvious -"never negative" check. Cheap invariants catch crashes; conservation -invariants catch lies. - -## The framework bug this flushed out - -My first run printed `plan status: completed` — for a plan the -sentinel had just *canceled*. `overall_status` checked failed, pending, -and in-progress states but never consulted `:canceled`, so a canceled -plan with no failures reported itself complete. That is a status API -telling a comforting falsehood, which is worse than no status API. -Fixed in the framework (canceled tasks → `:canceled`), regression -spec added. Sentinels that watch the watchers: this is the third -round in a row where a persona's example found a defect the suite -missed, and the pattern is consistent — **suites test what authors -imagined; examples test what users do.** - -## Design notes - -- `concurrency_limit: 1` is load-bearing: with parallel jobs, "which - task broke the law" becomes probabilistic. Determinism first, then - speed — an auditor that sometimes names the wrong suspect is worse - than a slow one. -- The state snapshot uses `Marshal` deep-copy at arrest time, because - an evidence photo that mutates after the arrest is not evidence. -- These invariants are lambdas over global state for demo brevity; in - production they'd be queries over your actual store. The pattern is - the hook placement, not the storage. - -## Verdict - -Two laws, four jobs, one arrest, one framework fix. Invariant checking -from hooks costs a dozen lines and converts a class of month-end -incidents into same-second bug reports. Post the sentinel. diff --git a/docs/perspectives/round-4/07-solnic.md b/docs/perspectives/round-4/07-solnic.md deleted file mode 100644 index e83ee13..0000000 --- a/docs/perspectives/round-4/07-solnic.md +++ /dev/null @@ -1,59 +0,0 @@ -# Round 4 field notes — Piotr Solnica types the state machine - -*Built: `examples/state_machine.rb` — an order lifecycle where every -transition's guard is an enum predicate on its contract, not an -if-statement.* - -## What I built and why - -Round 3 closed with Jeremy and me asking for value predicates — enums, -bounds, non-empty. They shipped, so I built the structure that -predicates make possible: a state machine with **no runtime transition -table**. Each event is a capability; its contract declares -`state: {enum: %w[placed]}` as the legal source states; its output -contract declares the destination. The topology of the machine lives -entirely in the type layer: - -``` -deliver XX cannot deliver from 'cart' (legal from: shipped) -place -> now 'placed' -cancel XX cannot cancel from 'shipped' (legal from: cart, placed) -journey: cart -> placed -> shipped -> delivered -``` - -An illegal move isn't a branch that returns false — it's input that -*never type-checks*, and the violation arrives with the legal -alternatives attached. Guards-as-contracts means the machine's `fire` -method contains zero domain logic: look up, execute, rescue. Every -state machine library you've used is a DSL for generating exactly the -checks these contracts now express declaratively. - -## The detail I'm most pleased by - -The **output** enum: each transition declares -`outputs: {state: {enum: [rule[:to]]}}` — a single-element enum, i.e. -"this transition produces exactly this state." While iterating I fat- -fingered a rule to return the event name instead of the target state, -and the *output* contract caught my own machine misbehaving before any -test did. Transitions that can't lie about where they land are the -difference between a state machine and a state suggestion. - -## What this exposes about the seam - -- Enum violations report `state violated` with dry-schema's default - message. Serviceable, but the *legal values* live in my rescue block, - reconstructed from the transition table. The violation payload should - carry the predicate's expectation (`included_in?: [...]`) so callers - don't need side-channel knowledge to render a good error. Small - addition to `ValidationError`, big ergonomic win. -- Missing predicate, noted for round 5: cross-field constraints - ("`express` shipping requires `quantity <= 10`"). Single-key - predicates cover 80%; the remaining 20% is where dry-validation's - rules (not just dry-schema) would enter. - -## Verdict - -Asked for predicates in round 3; in round 4 they replaced an entire -category of control flow. That's the test of a type-layer feature — -not "can it reject bad data" but "what code does it delete." Here it -deleted the case statement every state machine is built on. diff --git a/docs/perspectives/round-4/08-mperham.md b/docs/perspectives/round-4/08-mperham.md deleted file mode 100644 index c55c1cb..0000000 --- a/docs/perspectives/round-4/08-mperham.md +++ /dev/null @@ -1,63 +0,0 @@ -# Round 4 field notes — Mike Perham drills the error taxonomy - -*Built: `examples/error_taxonomy_drill.rb` — three failure modes, one -retry policy, three correct outcomes, because errors now testify about -their own retryability.* - -## What I built and why - -My round-3 note said the retry policy should consult -`failure.retryable?` — the gem's own error taxonomy -(`LlmRateLimitError#retryable? => true`, -`LlmAuthenticationError#retryable? => false`) was sitting there being -ignored by string matching on class names. It shipped in the roadmap -release, so the drill exercises the whole decision tree at once: - -``` -OK rate-limited sync 3 attempt(s) synced on attempt 3 -DEAD bad-credentials sync 1 attempt(s) gave up: 401 key revoked -OK mystery-error sync 2 attempt(s) recovered on attempt 2 -``` - -The middle line is the one I built this for. I *deliberately* put -`LlmAuthenticationError` in the policy's `retryable_errors` list — the -kind of config mistake that happens in every ops team ("just add it to -the list, it'll retry") — and the error's own verdict overruled it. -**One attempt.** A revoked key does not improve with persistence, and -now the object that knows that gets the final word. The type list -still earns its keep as the fallback for errors with no opinion (the -mystery `RuntimeError` got its second chance from the list). - -## The hierarchy of authority, spelled out - -1. `max_retries` — the budget, absolute. -2. The error's own `retryable?` — the domain verdict, when offered. -3. The policy's type list — the operator's fallback, for errors that - don't testify. - -That ordering matters. Reversed (list over verdict), config mistakes -would override domain knowledge; today's drill *is* that mistake, and -the framework survived it. Retry systems fail through their config -more often than their code — good ones make the config hard to hold -wrong. - -## Notes - -- All three drills ran concurrently under one policy — retryability is - per-failure, not per-plan, which is the only granularity that - survives real workloads (your plan talks to three APIs with three - temperaments). -- `plan: partial_failure` is honest: one task is dead and the plan - says so. Between this and Jeremy's `:canceled` fix, the status enum - finally covers what actually happens to plans. -- Still open from round 3: jitter defaults off. Two hundred workers - retrying a rate limit on the same constant schedule is a - synchronized second stampede. I'll keep saying it until it's the - default. - -## Verdict - -Asked in round 3, shipped in the release, drilled in round 4: errors -carry their own retry wisdom and the policy defers to it. The config -mistake I planted on purpose couldn't hurt anyone. That's what mature -retry machinery looks like. diff --git a/docs/perspectives/round-4/09-sandimetz.md b/docs/perspectives/round-4/09-sandimetz.md deleted file mode 100644 index d723050..0000000 --- a/docs/perspectives/round-4/09-sandimetz.md +++ /dev/null @@ -1,51 +0,0 @@ -# Round 4 field notes — Sandi Metz critiques the graph - -*Built: `examples/graph_critic.rb` — a design review for dependency -graphs, run before a single task executes.* - -## What I built and why - -Rounds past, I reviewed methods (the dojo) and traced messages (the -tracer). This round I reviewed the thing this framework actually asks -users to design: the **graph**. A plan's dependency structure is a -design artifact exactly like a class diagram, it exhibits the same -smells, and — this is the part people miss — it can be reviewed -*before execution*, when a restructuring costs an edit instead of a -re-run of forty LLM calls. - -Three smells, drawn from their object-design cousins: - -- **God task** — `join` gathers five dependencies, the graph's version - of a class with five collaborators in its constructor. Does it join, - or does it *do everything*? Staged joins give each join one reason - to wait, as extraction gives each class one reason to change. -- **Deep chain** — `publish` sits five levels down. Every level is - latency and a failure domain, the graph's train-wreck method chain. -- **Orphan** — `lonely` touches nothing and is touched by nothing. - Either it belongs to another plan or its justifying connection was - forgotten. Dead code, graph edition. - -And one prescription, as always. A review that emits three findings -and no ordering is a wall; the critic says *start with the god task* — -because restructuring it may dissolve the chain, and cheap moves that -might obsolete expensive ones go first. - -## The feature request embedded in this example - -The critic reads the graph with -`orchestrator.instance_variable_get(:@dependencies)` — a crowbar. I -used it deliberately and left the comment in, because that line *is* -the finding: the orchestrator knows its own topology and offers no -read-only view of it. Aaron's Gantt wanted it (he rebuilt the graph -from hooks), the tracer wanted it, now the critic. Three tools, three -reconstructions of state the object already holds. `Orchestrator#graph` -returning frozen `{task_id => dependency_ids}` plus the task list is -one accessor and unlocks a whole genre of tooling. Objects that keep -useful knowledge private force their collaborators into archaeology. - -## Verdict - -Graphs are designs; designs deserve review; review before execution is -the cheapest review there is. The critic took an evening, found three -seeded smells and their real prescription — and its own best finding -was the accessor the framework should grow next. diff --git a/docs/perspectives/round-4/10-ankane.md b/docs/perspectives/round-4/10-ankane.md deleted file mode 100644 index a01ad29..0000000 --- a/docs/perspectives/round-4/10-ankane.md +++ /dev/null @@ -1,50 +0,0 @@ -# Round 4 field notes — Andrew Kane makes the README testify - -*Built: `examples/readme_verifier.rb` — every ruby fence in the README -parsed with Prism and every `Agentic::` constant it names checked -against the loaded gem. Exit 1 on broken promises.* - -## What I built and why - -Back in round 2 I wrote: "my rule: every README snippet is a CI-run -test. The fake web_search survived because nothing ran the promises -the README made." Four rounds later I built the enforcement. The -verifier extracts all 21 ruby fences (376 lines of promised code), -fans them out, syntax-checks each with Prism, and resolves every -`Agentic::`-prefixed constant a snippet mentions against the actual -loaded gem — because a snippet that parses but names -`Agentic::MetaLearningSystem` is still a lie, just a better-dressed -one. - -**First run: caught one.** README line 514, the capability-composition -example, contained `{ data: { ... } }` — a literal ellipsis inside a -hash, unparseable since the day it was written. And here's the kicker: -the very first persona review in round 1 (DHH) called out *this exact -snippet* as "the README being ahead of the code." It took us four -rounds and a tool to convert that observation from an opinion into an -exit code. Opinions decay; exit codes don't. - -## Design notes - -- **Constant resolution beats execution.** I deliberately don't *run* - snippets — half of them need API keys, several would write files. - Parse + resolve gets you 90% of the lie-detection with 0% of the - side effects; it's the right default tier (same reasoning as the - DuckDuckGo backend: free and honest first, paid and thorough as an - upgrade). Executing the safe subset in a sandbox is the `--strict` - flag this grows next. -- The survey/atlas shape again (Xavier's right that it needs a name): - per-snippet checks in parallel, one verdict fanning in with - `t.output_of(check)`. Fourth build in two rounds with this skeleton; - it's the framework's `map/reduce`. -- One `rescue NameError` per constant lookup, and note it must be - `Object.const_get`, not `eval` — verifiers that eval their input - become the vulnerability they were hired to prevent. - -## Verdict - -Wire this into CI next to Jeremy's fuzzer and the docs can never -silently rot again: the fuzzer keeps the contracts honest, this keeps -the promises honest. It found a four-round-old lie on its first run — -tools that pay for themselves before the commit lands are the only -kind worth writing. diff --git a/docs/perspectives/round-5/01-matz.md b/docs/perspectives/round-5/01-matz.md deleted file mode 100644 index baf9d82..0000000 --- a/docs/perspectives/round-5/01-matz.md +++ /dev/null @@ -1,54 +0,0 @@ -# Round 5 field notes — Matz maps the dungeon - -*Built: `examples/dungeon_crawl.rb` — a quest as a plan: rooms are -tasks, doors are dependencies, and the map is drawn from -`orchestrator.graph` before anyone delves.* - -## What I built and why - -Every dungeon game maintains two truths: the map the player sees and -the graph the engine walks — and every dungeon game eventually ships a -bug where they disagree. This crawl has one truth. The map is printed -from `orchestrator.graph`, the same frozen topology the scheduler will -execute: - -``` -[Entrance Hall] <- you are here -[Treasury] doors from: Spider Nest, Flooded Crypt -``` - -Then the party delves — nest and crypt in parallel, the treasury -waiting on both keys via `needs: {web:, depths:}` — and the loot fans -in: "a chest of coppers (unlocked with someone's boot and a silver -coin)." There is no second map to fall out of date, because the -document and the program are the same object. That is the deepest kind -of DRY: not avoiding repeated *text*, but avoiding repeated *truth*. - -## What pleased me - -- `graph` being **frozen** is the right manner. A map you can scribble - on is a map you can lie with; the accessor hands you a photograph, - not the territory's steering wheel. (I tried to mutate it, for - science. `FrozenError`. Good.) -- Five features from three rounds cohabit in sixty lines without - crowding: payloads, callables, positional deps, named needs, the - graph view. New vocabulary that doesn't jostle the old vocabulary is - the sign the design is growing rather than accreting. -- Seeded loot, again. "Reproducible whimsy" is my favorite genre now. - -## A small observation for the maintainers - -The map loop wants the rooms in *dependency order* (entrance first, -treasury last), and I got it by accident because insertion order -matched. `graph` preserves insertion order — Ruby hashes promise that — -but a plan built in scrambled order would print a scrambled map. A -`graph[:order]` with a topological sort would let map-drawers be -correct on purpose instead of lucky. (Aaron will want it for critical -paths within the hour; ask him.) - -## Verdict - -The framework can now draw itself before it runs itself. When a -library's introspection is good enough to build a game's UI from, the -architecture documents can retire — the code has learned to give the -tour. diff --git a/docs/perspectives/round-5/02-dhh.md b/docs/perspectives/round-5/02-dhh.md deleted file mode 100644 index 6b94dba..0000000 --- a/docs/perspectives/round-5/02-dhh.md +++ /dev/null @@ -1,49 +0,0 @@ -# Round 5 field notes — DHH ships the kanban board - -*Built: `examples/kanban_board.rb` — a running plan rendered as To Do / -Doing / Done, every frame captured live from lifecycle hooks.* - -## What I built and why - -We spent two decades wiring project-management tools to *approximate* -the state of work, staffed with people whose job is updating the -approximation. But when the work is a plan, the orchestrator already -IS the board — the columns are just `pending`, `in_progress`, and -`completed` wearing better clothes. Two hooks move the cards: - -- `task_slot_acquired` → card moves To Do → Doing (note: *slot* - acquired, not scheduled — a card in Doing means someone is actually - working it, which is the entire honesty proposition of a kanban - board; before round 4's hook this column would have lied) -- `after_task_success` → Doing → Done - -Twelve frames, 302ms, and mid-flight the board shows exactly what a -two-person team looks like: `layout page` in Doing, `review` and -`publish` waiting on it, three cards shipped. Nobody typed a status. - -## The insight worth the price of admission - -The **WIP limit is the concurrency limit.** Kanban's whole discipline — -"limit work in progress" — is `concurrency_limit: 2`, enforced by the -scheduler instead of by a coach reminding people in a meeting. When -your process tool and your execution engine are the same object, the -process can't drift from reality, because it *is* reality. That's the -argument I've been making about software writ small: the best process -is the one your system enforces structurally, invisibly, for free. - -## Notes - -- The board never shows a "Blocked" column because dependency-waiting - cards just stay in To Do. For this demo that's fine; a real board - wants `blocked` derived from graph[:dependencies] minus done. One - `orchestrator.graph` call — everything's there. Fizzy could be forty - lines on this framework, and I'm only half joking. -- Frame capture is an array push in a hook — Samuel's - "hooks-run-inline" contract means I thought about cost for exactly - one second, which is what a documented contract buys. - -## Verdict - -Five rounds ago this framework couldn't tell you what it was doing; -now it renders its own kanban in real time from two hooks. Delete the -status meeting. The plan will speak for itself. diff --git a/docs/perspectives/round-5/03-tenderlove.md b/docs/perspectives/round-5/03-tenderlove.md deleted file mode 100644 index b218ae1..0000000 --- a/docs/perspectives/round-5/03-tenderlove.md +++ /dev/null @@ -1,58 +0,0 @@ -# Round 5 field notes — Aaron Patterson walks the critical path - -*Built: `examples/critical_path.rb` — graph topology plus measured -durations yields the chain that determined the wall clock, then proves -it by experiment.* - -## What I built and why - -The knee finder answered "how many lanes"; this answers the question -that comes right after: "which task is the wall clock's fault?" The -answer is never "all of them" — it's the **critical path**, the longest -duration-weighted chain through the dependency graph: - -``` -wall clock: 341ms -critical path: 340ms = pull:orders -> invoice:month -> report:board -(path explains 100% of the wall clock) -``` - -And because engineers rightly distrust analyzers, the program runs the -experiment instead of asking for faith: make an *off-path* task -instant — wall time unchanged, 341 → 341. Halve the slowest *on-path* -task — 341 → 251. There's your sprint planning: `pull:orders` is the -only work item, and anyone polishing `pull:catalog` is doing charity -for a metric no one measures. - -## What the round-4 accessor made possible - -The path computation is fifteen lines of memoized DFS over -`orchestrator.graph` joined with durations from one hook. In round 3 my -Gantt *rebuilt* the topology by eavesdropping on hooks, which meant it -could only see what executed; the graph accessor knows what was -*declared*, which is what lets the analyzer say "this chain, and no -other chain, bounds you." Sandi's crowbar complaint became my -load-bearing API within one round — that's the compounding this -experiment series keeps demonstrating. - -Matz already filed the follow-up I want: `graph[:order]` (topological). -My DFS re-derives it implicitly; a critical-path tool, a map drawer, -and a scheduler visualizer shouldn't each write their own toposort. - -## Perf-nerd footnotes - -- "Path explains 100% of the wall clock" is itself a diagnostic: if - that number drops much below ~95%, your bottleneck isn't the work, - it's the *scheduler* (queue waits from a too-tight limit). Pair this - with the knee finder: knee for capacity, path for latency. -- The experiment reruns the whole plan with modified sleeps — - affordable here, but the analysis itself is O(V+E) memoized and - needs no rerun. Ship the analysis in CI; save the experiment for - demos and skeptics. - -## Verdict - -Three tools now form a performance suite this framework didn't have -five rounds ago: Gantt (where time went), knee (how many lanes), path -(which task matters). All three built from two hooks and one accessor. -APIs that compound are the ones worth shipping. diff --git a/docs/perspectives/round-5/04-fxn.md b/docs/perspectives/round-5/04-fxn.md deleted file mode 100644 index b624bc1..0000000 --- a/docs/perspectives/round-5/04-fxn.md +++ /dev/null @@ -1,56 +0,0 @@ -# Round 5 field notes — Xavier Noria diagrams the plan - -*Built: `examples/plan_diagram.rb` — `orchestrator.graph` emitted as -Mermaid, with named dependencies as labeled edges.* - -## What I built and why - -Documentation that is generated from the artifact cannot disagree with -the artifact — that is the entire theory of this tool, and it is the -same theory as Zeitwerk's: derive the truth from one source rather -than maintaining two and hoping. `to_mermaid(graph)` is thirty lines; -GitHub renders the output; the diagram in your README regenerates in -CI and can never rot. - -The detail I care most about: **named dependencies become labeled -edges**. `draft` doesn't merely have two arrows into it — the arrows -say `skeleton` and `citations`: - -```mermaid -T1 -- skeleton --> T3 -T2 -- citations --> T3 -``` - -The `needs:` feature was pitched in round 3 as consumer ergonomics -(`t.needs.skeleton` beats positional lookup). This round reveals its -second dividend, which I'd argue is larger: the names are -*architectural documentation that survives extraction*. A diagram that -says WHY an edge exists — not just that it does — is the difference -between a graph and a design. Ergonomics decay into docs; that is the -best career path a parameter name can have. - -## Precision notes - -- The diagrammer must dedupe: `needs:` entries also appear in - `graph[:dependencies]` (correctly — they ARE dependencies), so a - naive emitter draws the edge twice, once bare and once labeled. The - snapshot is honest; consumers must be too. I'd accept a - `graph[:edges]` that pre-merges the two views with labels attached, - as the convenience form. -- Stable node ids (`T0`, `T1`...) derive from insertion order, which - Matz has already noted is a promise nobody has made. Both of us now - want `graph[:order]`. When two cartographers independently request - the same projection, the projection is real. -- I resisted emitting styling, subgraphs, and click handlers. A - generator's output should be a *starting point* that a human can - own, or a terminal artifact that no human touches — the middle - ground (generated-but-hand-tweaked) is where diagrams go to rot, - Mermaid or not. - -## Verdict - -The graph accessor is one round old and has now fed a game map, a -critical-path analyzer, a design critic, and a documentation -generator. Expose the right projection and an ecosystem assembles -itself around it — loaders taught me that; this gem is re-learning it -in public, quickly. diff --git a/docs/perspectives/round-5/05-ioquatix.md b/docs/perspectives/round-5/05-ioquatix.md deleted file mode 100644 index 782eff4..0000000 --- a/docs/perspectives/round-5/05-ioquatix.md +++ /dev/null @@ -1,57 +0,0 @@ -# Round 5 field notes — Samuel Williams absorbs the burst - -*Built: `examples/burst_absorber.rb` — three request waves against -`Agentic::RateLimit` (this round's release); the table of wait times -is the capacity plan.* - -## What I built and why - -Round 4 I built the rate limiter from primitives and said "this is one -class away from a feature." The class shipped (`Agentic::RateLimit`, -plus `LlmClient limiter:`), so this round I characterized it the way -you'd characterize any queueing system — under bursts, because steady -state flatters everyone: - -``` -wave 1: 6 requests wait p50 50ms worst 50ms -wave 2: 2 requests wait p50 0ms worst 0ms -wave 3: 9 at once wait p50 50ms worst 100ms -high-water mark: 3 of 3 - held -``` - -Six into three slots: one 50ms queueing round. Two requests: through -untouched. Nine at once: the tail waits two full service times. That -last number is the one to frame — **a ceiling doesn't eliminate burst -cost, it converts it** from a provider-side 429 (opaque, retried, -billed twice) into local queueing (visible, measurable, bounded). The -wait table IS the capacity conversation: if 100ms of p-worst is -unacceptable at wave-3 volume, you need a second credential, and now -you know *before* production tells you. - -## Design review of my own feature - -- The `ensure` inside `RateLimit#acquire` — decrementing in-flight even - when the block raises — is the difference between a limiter and a - slow leak. I checked the shipped implementation first thing; it's - there. Review your own requests hardest. -- High-water as a first-class reader means every demo doubles as a - regression test ("3 of 3 - held"). Observability designed into the - primitive, not bolted on. -- What it doesn't do (correctly, for now): time-windowed limits - (requests-per-minute vs concurrent). Concurrency ceilings model - connection limits; token buckets model quota. Both belong eventually, - but shipping the semaphore-shaped one first was right — it's the one - that can't be approximated from outside. - -## Structured-concurrency footnote - -The waves themselves are nested `task.async` fan-outs with `sleep` -staggering — the test harness for the feature is the same three -primitives as the feature. When your testing DSL is just your -concurrency model, the concurrency model is probably right. - -## Verdict - -Feature requested, shipped, and characterized under hostile load -within two rounds. The wait table converts "we should rate limit" from -compliance theater into an engineering table with numbers in it. diff --git a/docs/perspectives/round-5/06-jeremyevans.md b/docs/perspectives/round-5/06-jeremyevans.md deleted file mode 100644 index 48d6a0b..0000000 --- a/docs/perspectives/round-5/06-jeremyevans.md +++ /dev/null @@ -1,57 +0,0 @@ -# Round 5 field notes — Jeremy Evans opens the freight desk - -*Built: `examples/freight_rules.rb` — a quoting capability whose -tariff policy is written as cross-field contract rules; broken rules -are reported all at once, nonsense never reaches them.* - -## What I built and why - -The cross-field rules Piotr and I asked for shipped this round, so I -built the workload that motivated the ask: a tariff book. Freight -policy is inherently cross-field — "hazardous cargo may not fly" is a -relationship between `mode` and `hazardous`; no per-key check can say -it. Four rules on the contract, five manifests through the desk: - -``` -#3 REFUSED - 3 rule(s) broken: - - air freight is limited to 500kg - - hazardous cargo may not fly - - insured value over 100k requires sea mode -#5 MALFORMED - mode, weight_kg, destination invalid - (never reached the tariff book) -``` - -Two properties I care about, both verified: - -1. **All broken rules at once.** Manifest #3 violates three policies - and hears about all three. First-failure reporting turns a manifest - correction into a submit-fail-submit-fail scavenger hunt — three - round trips where one suffices. The implementation runs every rule - and collects; that's the only correct behavior. -2. **Layering.** Manifest #5 (`mode: "teleport"`, negative weight, - empty destination) is rejected by per-key validation and the rules - never run. This is a *soundness* requirement disguised as - politeness: rule predicates dereference fields - (`i[:weight_kg] <= 500`), and running them against garbage yields - NoMethodErrors or — worse — accidental passes. Types first, then - relationships. The validator sequences it correctly. - -## What I'd note for round 6 - -- Rules are keyed by their description string, which doubles as the - error message. Good for humans; for machine consumers (Piotr's form - renderer next door) a structured `{rule: :air_weight_limit, - message: "..."}` pair would version better. Strings are UI; symbols - are API. -- My round-3 fuzzer can't fuzz rules — a lambda is opaque to input - generation. That's an acceptable trade (expressiveness beat - analyzability here, correctly), but it means rule-bearing contracts - need hand-written property tests. The freight desk's five manifests - are that, in miniature. - -## Verdict - -Policy as data on the contract, checked in the right order, reported -in full. The tariff book and the validation are now the same artifact -— which means the policy can't drift from its enforcement. That's the -property every compliance system claims and almost none have. diff --git a/docs/perspectives/round-5/07-solnic.md b/docs/perspectives/round-5/07-solnic.md deleted file mode 100644 index 68ff6c9..0000000 --- a/docs/perspectives/round-5/07-solnic.md +++ /dev/null @@ -1,55 +0,0 @@ -# Round 5 field notes — Piotr Solnica generates the 422 - -*Built: `examples/form_errors.rb` — ValidationError in, API error -document out; the renderer has zero knowledge of any contract because -`#expectations` (this round's release) carries the contract inside the -exception.* - -## What I built and why - -My round-4 state machine had to reach back into its own transition -table to tell users which states were legal — the exception knew -something was wrong but not what right looked like. `#expectations` -closed that gap, and this is the payoff pattern: **one renderer, every -capability**. The `error_document` function receives an exception and -produces the 422 your frontend wants: - -```json -{"field": "plan", - "messages": ["must be one of: starter, team, enterprise"], - "allowed": ["starter", "team", "enterprise"]} -``` - -`allowed`, `minimum`, `maximum` — all read off the exception. The -renderer contains no reference to the checkout contract, which means -it also serves the freight desk next door, and every capability anyone -registers next year. Error rendering just became infrastructure -instead of per-endpoint labor. This is the dry-rb thesis in one -example: when errors are *data with schema*, presentation becomes a -pure function, and pure functions are the only code that never needs -a second copy. - -## The seam I'd polish next - -The document splits field errors from `policy_violations` (the `:base` -rules) — and submission #3 shows the asymmetry Jeremy also flagged: -field errors carry structured expectations, policy violations carry -prose. A rule that failed *knows* which fields it read -(`plan`, `seats`), but the exception can't say so, so the frontend -can't highlight the offending inputs. Rules declaring their fields — -`rules: {rule_name => {fields: [:plan, :seats], check: ->}}` — would -let the 422 point at widgets for policy failures too. That's the -round-6 ask. - -Also noted with approval: dry-schema's own messages ("must be one of: -starter, team, enterprise") already render the enum — the expectations -payload lets you go *beyond* prose into machine-usable structure -(populate the dropdown, set the slider bounds). Messages for humans, -expectations for widgets. Both, not either. - -## Verdict - -Round 4: exceptions learned what happened. Round 5: they learned what -should have happened. A form library, an API layer, and a CLI can now -share one error pathway — which is what "types at the boundary" was -always for. diff --git a/docs/perspectives/round-5/08-mperham.md b/docs/perspectives/round-5/08-mperham.md deleted file mode 100644 index c0c5bed..0000000 --- a/docs/perspectives/round-5/08-mperham.md +++ /dev/null @@ -1,49 +0,0 @@ -# Round 5 field notes — Mike Perham simulates the stampede - -*Built: `examples/stampede_sim.rb` — twenty workers fail together and -retry; the histogram compares the herd with jitter off versus the -now-default jitter on.* - -## What I built and why - -I've been muttering "jitter by default" since round 3; it shipped this -round, and defaults this important deserve a demonstration you can -show a skeptical platform team. Twenty workers all fail at t=0 (an -upstream hiccup — the most common failure shape there is, because -outages are *correlated*), all back off 120ms, all return: - -``` -jitter OFF: 220-240ms #################### 20 -jitter ON: 80-100ms # 1 - 100-120ms ########### 11 - 120-140ms #### 4 - 140-160ms #### 4 -peak herd: 20 -> 11 -``` - -Without jitter, the recovery *is* the second outage: twenty requests -in one 20ms bucket, aimed at an upstream that just told you it was -struggling. With ±25% jitter the same herd spreads across 80ms and the -peak nearly halves. At twenty workers this is a chart; at two thousand -it's the difference between recovery and a cascading failure with your -company's name on the postmortem. - -## Honest notes on the experiment - -- Both runs are seeded (`srand`) so the comparison is fair and the - histogram is reproducible — a stampede simulator with - unreproducible stampedes is weather, not science. -- Peak 11 of 20 still isn't great — uniform ±25% jitter caps how much - spread you can buy. The literature's answer is *full jitter* - (`rand(0..delay)`) or decorrelated jitter, which flatten the herd - much harder. The framework's knob is a boolean today; a - `backoff_jitter: :full` tier is the natural next notch. Filed. -- The example silences the logger to `:fatal` because forty scripted - failures are the point, not news — and being *able* to do that in - one line is Jeremy's round-1 logger work paying rent again. - -## Verdict - -The default changed, and now there's a picture explaining why nobody -should change it back. Reliability defaults should assume the crowd; -this one finally does. diff --git a/docs/perspectives/round-5/09-sandimetz.md b/docs/perspectives/round-5/09-sandimetz.md deleted file mode 100644 index 7175a82..0000000 --- a/docs/perspectives/round-5/09-sandimetz.md +++ /dev/null @@ -1,56 +0,0 @@ -# Round 5 field notes — Sandi Metz teaches the three shapes - -*Built: `examples/three_shapes.rb` — the same six units of work as a -chain, a star, and staged joins; measured, critiqued, and chosen on -purpose.* - -## What I built and why - -My graph critic (round 4) could smell a bad shape; this round's tool -teaches the *choosing*. Same workload, three arrangements, and for -each: wall time (measured by running it) plus two structural numbers -read straight off `orchestrator.graph` — depth and max fan-in. One -table, three trades: - -``` -chain 243ms depth 6 fan-in 1 trivially debuggable; pays serial price -star 121ms depth 3 fan-in 4 fastest; one join owns every failure mode -staged 121ms depth 3 fan-in 3 nearly as fast; each join has one reason to wait -``` - -The lesson I've taught for years about objects transfers whole: **none -of these is wrong.** The chain is right when the work is truly -sequential. The star is right when the join is trivial. The staged -shape is right when the join has judgment in it, because judgment -deserves small, single-purpose homes. What's *wrong* is not knowing -which one you built — accidental architecture, the graph edition. - -Note what the measurement adds that intuition misses: star and staged -tie on wall time (121ms — the scheduler forgives fan-in that fits the -concurrency limit). So the choice between them is **entirely** about -failure modes and change cost, not speed — which is exactly the -conversation teams skip when they only look at latency. The numbers -free you to argue about design. - -## On building teaching tools with this framework - -- The structural facts are eight lines over the graph accessor; the - behavioral fact is one `execute_plan`. Structure cheap, behavior - honest — a teaching tool needs both, because students (rightly) - distrust tools that only theorize. -- This is my third graph tool in three rounds (critic, tracer, - shapes), and they compose into a curriculum: *see* the conversation - (tracer), *smell* the structure (critic), *choose* the shape - (shapes). A workshop hiding in an examples directory. -- The depth/fan-in computation is now written in three examples - (critic, critical path, here). When teaching materials repeat a - computation, the library wants it: `graph[:depth]`, `graph[:fan_in]` - — or Matz and Xavier's `graph[:order]` plus a tiny stats helper. - Third strike; extract it. - -## Verdict - -Design education in forty lines: run the shapes, read the table, have -the argument the numbers permit. When a framework makes trade-offs -this cheap to *demonstrate*, "it depends" stops being a shrug and -becomes a lesson plan. diff --git a/docs/perspectives/round-5/10-ankane.md b/docs/perspectives/round-5/10-ankane.md deleted file mode 100644 index f1dbe64..0000000 --- a/docs/perspectives/round-5/10-ankane.md +++ /dev/null @@ -1,52 +0,0 @@ -# Round 5 field notes — Andrew Kane catalogs the gallery - -*Built: `examples/examples_index.rb` — reads every example's own -header, writes `examples/README.md`, and fails loudly on any example -that doesn't document how to run itself.* - -## What I built and why - -Five rounds produced forty examples — a gallery with no signage. The -signage problem has the same solution as the README-rot problem I -attacked in round 4: **generate the catalog from the artifacts**, so -it can't disagree with them. The librarian tasks fan out (one per -file), each reads its example's leading comment block, and the editor -fans in and writes the table. `examples/README.md` now exists, says -"edit the examples, not this file," and regenerates in one command. - -The enforcement matters as much as the catalog: an example whose -header lacks a `bundle exec ruby` line fails the build (exit 1), -because **an example you can't run is a rumor**. Forty of forty pass -today — every persona, it turns out, wrote honest headers. Culture -audits itself when tools check it. - -## Confession, as gallery tradition requires - -First run cataloged all forty summaries as -"frozen_string_literal: true" — my header extractor grabbed the first -comment it saw, and the first comment in every file is the magic -comment. Parsing "the comment block" means knowing which comments are -prose and which are pragmas; even a forty-line tool meets the -regexes-vs-grammar lesson eventually. (Aaron is legally entitled to -one laugh.) - -## Patterns worth naming, five rounds in - -- This is the survey/atlas shape's **seventh** appearance, and its - most meta: the framework cataloging the demonstrations of itself. -- The generated file is committed, not gitignored — same call as - Xavier's Mermaid: a catalog you can read on GitHub beats a catalog - you must generate to see, and CI regenerating + diffing keeps it - honest (`examples_index.rb && git diff --exit-code examples/README.md` - is the whole check). -- Self-excluding (`files - [me]`) keeps the librarian out of her own - catalog. Tools that inventory a directory they live in always need - this line, and always forget it once. - -## Verdict - -The gallery has signage that maintains itself and a doorman that -rejects undocumented exhibits. Between the README verifier, the -contract fuzzer, and this index, the project's honesty checks now -cover code promises, contract promises, and catalog promises — all -generated, all CI-able, all built on one gem's primitives. diff --git a/docs/perspectives/round-6/01-matz.md b/docs/perspectives/round-6/01-matz.md deleted file mode 100644 index 8bad905..0000000 --- a/docs/perspectives/round-6/01-matz.md +++ /dev/null @@ -1,55 +0,0 @@ -# Round 6 field notes — Matz narrates breakfast - -*Built: `examples/plan_tour.rb` — any plan, narrated as prose from -`graph[:order]` and `graph[:edges]`, before anything runs.* - -## What I built and why - -I asked for `graph[:order]` so map-drawers could be correct on purpose; -it arrived, and the first thing I built with it is the oldest debugging -technique there is — **reading your program aloud**: - -``` -First, boil the kettle. -Meanwhile, slice the bread. -After "boil the kettle": soft-boil the eggs. -After "soft-boil the eggs" (your protein), "toast the bread" (your - crunch) and "steep the tea" (your comfort): plate everything. -``` - -Every word is generated: "First" is the order's head, "Meanwhile" is a -root task appearing later in the order, "After X:" is an edge, and -"(your protein)" is a `needs:` label surfacing as *purpose*. If the -prose sounds wrong — if breakfast steeps the tea before boiling the -kettle — the plan is wrong, and your ears caught it while the stove -was still cold. Eyes skim structure; ears parse sequence. Rubber-duck -debugging works because speech serializes thought, and now the -framework serializes the plan for you. - -## Confession, in the round's tradition - -My first narrator said "Once you boil the kettle is done" — I tried to -inflect English with a regex and produced grammar only a parser could -love. Aaron's law extends past Ruby: *don't parse natural language -with regexes either, not even outbound.* Quoting the task names -verbatim ("After \"boil the kettle\":") is humbler and better — the -narration frames; the plan speaks in its own words. (This is also, -quietly, the correct division of labor for the LLM upgrade: let a -model smooth the prose, but generate the *skeleton* from the graph so -the sequence can never be hallucinated.) - -## Small notes - -- `order` + `edges` is the whole API surface this needed. Two rounds - ago this program would have been mostly graph traversal; now it is - mostly sentences. The framework's share of my programs keeps - shrinking, which is the trend line I care about most. -- The `needs:` labels reading as "(your protein)" delighted me — the - consumer named the dependency for code clarity in round 3, and by - round 6 the same name explains the *cuisine*. Names are the gift - that keeps arriving. - -## Verdict - -The plan can draw itself (round 5) and now speak for itself (round 6). -Next it should probably listen — but that's another round. diff --git a/docs/perspectives/round-6/02-dhh.md b/docs/perspectives/round-6/02-dhh.md deleted file mode 100644 index bb587ba..0000000 --- a/docs/perspectives/round-6/02-dhh.md +++ /dev/null @@ -1,57 +0,0 @@ -# Round 6 field notes — DHH runs the deploy train - -*Built: `examples/deploy_train.rb` — lint → test → build → canary → -ship → announce, run healthy and then with a failing canary. The -second run is the product.* - -## What I built and why - -Every deploy pipeline demo shows the green path, which is like -reviewing a seatbelt by admiring the buckle. The train's whole reason -to exist is Friday: - -``` -canary RED - error rate 4.2% exceeds 1% threshold -ship CANCELED (never left the yard) -announce CANCELED (never left the yard) -train status: partial_failure -``` - -One `after_task_failure` hook calling `cancel_plan` is the entire -brake. The cars behind the red gate report **CANCELED** — a word, in -the results, queryable — not the silent absence that makes 2am -debugging a archaeology dig ("did announce run? grep the logs..."). -Jeremy's round-4 status fix is what makes this honest: before it, a -stopped train could report `:completed` and you'd announce a release -you never shipped. I have seen that exact incident in the wild. -Twice. - -## What I learned writing the copy - -My first closing line claimed the train status was `:canceled` — it's -actually `:partial_failure`, because failure outranks cancellation in -the status precedence. And sitting with it, that's *right*: the -headline should be WHY the train stopped (a gate went red); the -manifest shows what never shipped (the canceled cars). Two different -questions, answered at two different levels. I fixed my prose, not the -framework — a rarity worth recording, and the sign the status enum has -finally earned trust: when the tool and I disagreed, the tool was -right. - -## Omakase notes - -- `max_retries: 0` on the train. Deploy gates should not be retried - into submission; a flaky canary is a red canary. Retry budgets are - for *ingestion*, not *judgment*. -- `concurrency_limit: 1` because a deploy train is a train. There are - workloads where the queue IS the feature; the framework respecting - that without ceremony (one integer) is the omakase experience. -- This is `bin/deploy` for anyone whose deploy is Ruby-orchestrable. - Swap the sleeps for `system` calls and the canary check for a real - metrics query; the shape ships as-is. - -## Verdict - -The unhappy path is the product, and it now reads like a train -manifest instead of a shrug. Six stations, one hook, zero incident -reports that end with "we didn't realize it kept going." diff --git a/docs/perspectives/round-6/03-tenderlove.md b/docs/perspectives/round-6/03-tenderlove.md deleted file mode 100644 index 2cbb687..0000000 --- a/docs/perspectives/round-6/03-tenderlove.md +++ /dev/null @@ -1,58 +0,0 @@ -# Round 6 field notes — Aaron Patterson diffs the runs - -*Built: `examples/perf_diff.rb` — the plan measured before and after -"the PR," per-task deltas with a noise floor, and the one qualifier -that decides everything: is the regression on the critical path?* - -## What I built and why - -Every perf regression I've ever chased shipped inside a PR that made -something *else* faster. The diff tool models that exact crime scene: -the PR halves `reprice:catalog` (real win, -50ms!) and quietly makes -`fetch:prices` slower (+59ms). A naive per-task diff calls that a -wash. The wall clock disagrees: - -``` -reprice:catalog -50ms faster -fetch:prices +59ms SLOWER + ON CRITICAL PATH -wall clock: 231ms -> 241ms (+10ms) -VERDICT: don't ship. -``` - -The repricing win lands on slack time; the fetch regression lands on -the path that bounds the plan. Users get the regression and none of -the win. That asymmetry is the single most misunderstood fact in -performance work, and this tool prints it with an exit code so CI can -refuse the PR before a human has to win the argument. - -## Craftsman's notes - -- **The noise floor is not optional.** 15ms here; without it, every - scheduler wobble becomes a "regression" and the tool trains people - to ignore it. A perf gate that cries wolf is worse than none — - calibrate the floor to your variance, then trust it. -- The critical-path check reuses round 5's fifteen-line walk, now over - `graph[:order]` instead of my hand-rolled traversal — the toposort - ask, cashed within one round. Third round in a row where the ask → - ship → use loop closed inside a single iteration; I've stopped being - surprised and started being spoiled. -- Verdict design: off-path regressions print lowercase ("slower - (off-path)") and don't block. They're real, they're recorded, and - they're *not the headline*. Alert fatigue is a perf bug in your - process. - -## Where this goes - -Baseline durations belong in a JSON artifact committed per-release, so -the diff runs against recorded history instead of a same-process rerun -— Perham's journal already stores per-task durations, which is the -natural source. Wire journal → baseline → this diff and you have -continuous plan-performance regression testing for the price of a -cron job. - -## Verdict - -Gantt: where time went. Knee: how many lanes. Path: which task -matters. Diff: **did the PR make it worse.** The performance suite is -complete enough that I'd hand it to an ops team — four tools, ~400 -lines total, all standing on two hooks and one accessor. diff --git a/docs/perspectives/round-6/04-fxn.md b/docs/perspectives/round-6/04-fxn.md deleted file mode 100644 index 97d09d8..0000000 --- a/docs/perspectives/round-6/04-fxn.md +++ /dev/null @@ -1,56 +0,0 @@ -# Round 6 field notes — Xavier Noria closes the round trip - -*Built: `examples/plan_roundtrip.rb` — graph → JSON → fresh -orchestrator → graph, with an isomorphism check proving nothing was -lost in either direction.* - -## What I built and why - -A projection you can only read is a report; a projection you can -*invert* is a format. Round 5's diagrammer read the graph outward -(graph → Mermaid); this round closes the loop: serialize the topology -to JSON, rebuild a brand-new orchestrator from that JSON, and compare -shapes. The verdict line is the deliverable: - -``` -round trip is faithful: 3 edges, labels intact, -topological order preserved (gather -> check -> weave -> ship) -``` - -With this, plans become *artifacts*: you can commit them, diff them in -review, hand them between processes, build them in a UI and execute -them in a worker. The CLI's existing `--save plan.json` stores the -planner's *task list*; this wire format stores the *topology* — which -is the part that round 3's piping and round 4's `needs:` made worth -preserving. - -## The two decisions that make the format sound - -1. **Ids do not travel.** Task ids are per-process UUIDs; a wire - format that ships them is shipping garbage that will collide or - mislead. Identity travels as description, structure as edges — the - same discipline as Perham's journal keys. The isomorphism check - accordingly compares *shapes* (names and labeled edges), never ids. -2. **`graph[:edges]` is the serialization surface.** Because round 6's - release pre-merges positional and named dependencies into labeled - edge records, serialize + deserialize are ~15 lines each with no - special cases. My round-5 diagrammer had to dedupe needs against - dependencies by hand; that logic simply no longer exists anywhere - in this file. When a release deletes code from *examples that - haven't been written yet*, the API changed at the right layer. - -## The caveat, stated plainly - -The wire format carries topology, not behavior: agents and payloads -are lambdas and live objects, which do not serialize (and must not — -`Marshal`ing closures is how gems end up in CVE databases). A rebuilt -plan needs its agents re-attached by name, exactly as the CLI -re-resolves agents from specs. Structure travels; capability is -re-granted at the destination. That is a *feature* with security -posture, not a gap. - -## Verdict - -The graph now survives translation in both directions with a proof -attached. Plans-as-data was always the promise of the plan-and-execute -architecture; as of this round the data part is honest. diff --git a/docs/perspectives/round-6/05-ioquatix.md b/docs/perspectives/round-6/05-ioquatix.md deleted file mode 100644 index 12e441d..0000000 --- a/docs/perspectives/round-6/05-ioquatix.md +++ /dev/null @@ -1,53 +0,0 @@ -# Round 6 field notes — Samuel Williams keeps the quota - -*Built: `examples/quota_keeper.rb` — the same twenty requests through a -concurrency ceiling and through the new windowed quota; the admission -charts show they are different physics.* - -## What I built and why - -Round 5 I said concurrency ceilings model connection limits and -windowed quotas model billing, and that both belong. The windowed mode -shipped (`RateLimit.new(5, per: 0.2)`), so this round the two laws -face the same twenty requests: - -``` -concurrency 3: 0-200ms #################### 20 (done by 61ms) -window 5/200ms: 0-200ms ##### 5 - 200-400ms ##### 5 - 400-600ms ##### 5 - 600-800ms ##### 5 (done by 601ms) -``` - -Same workload, **61ms versus 601ms** — a 10x difference dictated -entirely by which law you chose. Under a concurrency ceiling, -finishing early frees a slot, so fast calls drain the queue at IO -speed. Under a window, finishing early buys *nothing*: five per -period is five per period. Anyone who has ever "optimized" their API -calls and watched throughput not move has met a window while modeling -a ceiling — the chart pair is that lesson in one screen. - -## Implementation review (I read the shipped code first, as always) - -- The windowed algorithm is the honest simple one: prune stamps older - than the window, admit if under ceiling, else `sleep` until the - oldest stamp exits — and that sleep suspends only the *acquiring - fiber*, so unrelated reactor work proceeds. Fiber-friendly waiting - is the whole reason this can live in-process instead of in Redis. -- Stamps record at *admission*, making this a sliding-window-of-starts - — the same law OpenAI and Anthropic quota by. A leaky-bucket variant - smooths differently; the admission-stamp choice is the right default - because it matches what providers measure. -- One thing to document: windowed mode intentionally does not bound - *concurrency* (all five of a window's calls may be in flight - together). Production wants both laws at once — a window for quota - and a ceiling for sockets - which today means nesting two limiters. - `RateLimit.new(3, and_per_window: [30, 60])`... no. Two objects, - composed. The primitive is right; the composition is the user's - sentence to write. - -## Verdict - -Two laws, one class, an honest chart apiece. The rate-limiting story -that started as a crowbar in round 4 is now a taxonomy with -measurements — which is what infrastructure maturity looks like. diff --git a/docs/perspectives/round-6/06-jeremyevans.md b/docs/perspectives/round-6/06-jeremyevans.md deleted file mode 100644 index d7e0191..0000000 --- a/docs/perspectives/round-6/06-jeremyevans.md +++ /dev/null @@ -1,59 +0,0 @@ -# Round 6 field notes — Jeremy Evans probes the rules - -*Built: `examples/rule_prober.rb` — now that rules declare their -fields, that declaration is a testable claim. One seeded rule lies -about what it reads; the prober catches it in 71 of 300 trials.* - -## What I built and why - -In round 5 I wrote that rule lambdas were opaque to fuzzing — "an -acceptable trade." The structured-rules release quietly changed the -trade: `fields:` is a *claim about the lambda's data dependencies*, -and claims can be audited. The probe is metamorphic testing at its -simplest: generate a conforming application, record the rule's -verdict, perturb a field the rule does NOT declare, and check the -verdict didn't move. Three hundred trials per rule, seeded: - -``` -affordability: honest -subprime_needs_cosigner: honest -jumbo_screening: LYING - declares [:amount] but its verdict - flipped when :score changed (71/300 trials) -``` - -`jumbo_screening` reads `:score` off the books. And the harm is -concrete, not aesthetic: Piotr's 422 renderer highlights *declared* -fields, so this rule sends a user to fix `:amount` while the actual -problem — their credit score — sits unhighlighted on the form. A lying -declaration turns a helpful error into a misdirection. That's a -correctness bug in the UX, planted two layers away from any UI code. - -## Methodology notes - -- Perturbations draw from the same conforming generator as the - originals, so a flip can never be blamed on invalid data — the probe - isolates exactly one variable. Fuzzing 101, still worth stating. -- 71/300 flips, not 300/300: the lie only surfaces when - `amount > 400k` AND the score perturbation crosses 700. Partial - sensitivity is exactly why eyeballing rules doesn't find this — - probabilistic bugs need volume, volume needs automation, automation - needs a seed. -- The exit code makes it CI: `rule_prober` alongside the contract - fuzzer (round 3) and the README verifier (round 4). The honesty - suite now covers types, promises, and — new — *declarations*. - -## The observation about API design - -Nobody designed `fields:` as a testability feature. It shipped (round -6 release) as UI plumbing — "let the 422 highlight widgets." But any -declaration, once machine-readable, becomes a specification you can -check the implementation against. This is the recurring miracle of -typed metadata: **write down what you believe and the tests write -themselves.** It's why I keep asking every round for more things to -be data. - -## Verdict - -Three rules audited, one liar caught, one seed to reproduce it. The -declarations are honest now — and more importantly, they're *kept* -honest by a forty-line program any CI can run. diff --git a/docs/perspectives/round-6/07-solnic.md b/docs/perspectives/round-6/07-solnic.md deleted file mode 100644 index be78012..0000000 --- a/docs/perspectives/round-6/07-solnic.md +++ /dev/null @@ -1,61 +0,0 @@ -# Round 6 field notes — Piotr Solnica generates the API reference - -*Built: `examples/api_reference.rb` — walk the registry, emit markdown -reference docs from the same contracts that validate every call.* - -## What I built and why - -The endgame of contract-first design was always this: the contract is -the validator, the error renderer, *and* the documentation — one -artifact, three projections. The generator walks the registry and -emits a reference page per capability: - -``` -| `amount_cents` | number | yes | >= 1; <= 10000000 | Amount in cents | - -### Policies -- **no_self_transfer** (checks `from_account`, `to_account`): - source and destination must differ -``` - -Every column is read from the declaration: types, requiredness, enums, -bounds, non-empty, and — thanks to this round's structured rules — the -policies with the fields they check. API docs that drift from the -validator are the industry's most-shipped lie; these *cannot* drift, -because the sentence that documents the bound is generated from the -object that enforces it. - -## The arc, made explicit - -Trace `amount_cents: {min: 1}` through the rounds: round 2, types were -decorative. Round 4, `min:` rejects bad input. Round 5, the rejection -carries the bound (`expectations`). Round 6, the bound documents -itself in a table AND Jeremy's prober audits the rules' field claims. -**One declaration, five behaviors** — validate, reject, explain, -document, audit. That multiplication is the entire argument for -schema-as-data over validation-as-code; I have been making it in -conference talks for a decade and this gem now makes it in 100 lines -of examples. - -## Notes - -- `description:` on input declarations was already in the - specification shape (round 2's capabilities used it) but nothing - consumed it until this table. Metadata survives waiting for its - consumer — another reason to prefer declarations over code. -- The near-term upgrade is emitting JSON Schema / OpenAPI instead of - markdown — the declarations map 1:1 (`enum`→`enum`, `min`→`minimum`, - `non_empty`→`minLength/minItems`). At that point Agentic capability - contracts become importable into every API toolchain on earth. An - afternoon, for whoever wants it. -- What the reference can't document: prose-form rules (no fields, no - id — they render as their description only) and implementation - behavior beyond the boundary. Correct limits, both; documentation - generators should document claims, not guess at code. - -## Verdict - -Contracts now validate, explain, document, and get audited — four -consumers of one declaration, none of which can disagree with the -others. That's the property "single source of truth" was always -supposed to mean. diff --git a/docs/perspectives/round-6/08-mperham.md b/docs/perspectives/round-6/08-mperham.md deleted file mode 100644 index afa144b..0000000 --- a/docs/perspectives/round-6/08-mperham.md +++ /dev/null @@ -1,56 +0,0 @@ -# Round 6 field notes — Mike Perham judges the jitter shootout - -*Built: `examples/jitter_shootout.rb` — none vs equal (the default) vs -full (this round's release), same forty synchronized workers, three -histograms, one scoreboard.* - -## What I built and why - -Round 5's stampede sim argued for the jitter *default*; this round the -`:full` tier shipped, so the shootout puts all three modes on one -seeded scoreboard: - -``` -none: peak 40 of 40 spread 2ms -equal +/-25%: peak 19 of 40 spread 75ms -full [0,d]: peak 13 of 40 spread 152ms -``` - -No jitter is a synchronized herd — all forty in one 25ms bucket, -which is a DDoS you scheduled against yourself. Equal jitter (the -default) halves the peak. Full jitter — retries drawn uniformly from -`[0, delay]` — cuts it to a third and doubles the spread, at the cost -of *punctuality*: some workers retry almost immediately, one waits -nearly the full backoff. That trade is exactly right for the -recovering-upstream case: **when the service is already hurting, -"together" is the only wrong arrival time.** This matches the AWS -Architecture Blog's classic analysis, and now the gem's histogram -matches the literature's math, reproducibly, with a seed. - -## Operational guidance, straight from the table - -- Default (equal) is correct for most fleets: bounded delay variance - keeps p99 retry latency predictable, peak halves for free. -- Switch to `:full` when the retry *target* is the bottleneck — rate - limits, brownouts, thundering-herd-prone startup paths. You're - trading your own latency tail for the upstream's survival, which is - the right trade because a dead upstream has infinite latency. -- `none` is for tests asserting exact delays, and nothing else. It's - opt-in now, which is where footguns belong: in the drawer, labeled. - -## Notes - -- The three-mode comparison needed zero framework hooks — arrivals - recorded in the agent, seeded via `srand` since the policy uses - `Kernel#rand`. Policy knobs that honor global seeding are testable - policy knobs; whoever wires custom RNG injection later should keep - that property. -- Peak-per-bucket is the metric because upstreams die of *instantaneous* - concurrency, not totals. Forty retries over 150ms is fine; forty in - 2ms is an incident. Measure what kills you. - -## Verdict - -Three modes, one seed, one scoreboard: 40 → 19 → 13. The default -protects everyone, the `:full` tier protects the wounded, and the -histogram means nobody has to take my word for any of it anymore. diff --git a/docs/perspectives/round-6/09-sandimetz.md b/docs/perspectives/round-6/09-sandimetz.md deleted file mode 100644 index e178511..0000000 --- a/docs/perspectives/round-6/09-sandimetz.md +++ /dev/null @@ -1,58 +0,0 @@ -# Round 6 field notes — Sandi Metz collects the receipts - -*Built: `examples/refactor_receipts.rb` — the god-join plan improved in -two small steps, with a critic verdict and a measured receipt after -each one.* - -## What I built and why - -My round-4 critic could point at the god join and prescribe staged -joins; this round performs the surgery *with receipts*. Three states, -each critiqued and each executed: - -``` -before: wall 121ms | fan-in 5 | critic: god task (5 deps) -step 1: wall 151ms | fan-in 3 | critic: no complaints -step 2: wall 121ms | fan-in 3 | critic: no complaints -``` - -The receipt I'm proudest of is the one that embarrassed my first -draft's closing line. I wrote "same speed at every step" — the -measurement said otherwise: **step 1 removed the smell and cost -30ms**, because staging the joins added a level to the critical path. -Step 2 paid it back by letting the report read the stages directly. -I've taught for years that refactoring steps should be *safe*; the -receipts add the adult correction — safe is not free, and pretending -otherwise is how refactoring gets a reputation for making things -slower. Intermediate steps may cost. Receipts *price* them, so the -team can decide to pause at step 1 (shippable, smell-free, 30ms -slower) with open eyes instead of discovering the regression in -production. - -## The method, distilled - -1. Run the critic — it names the smell and one next step. -2. Take the step. Small. The kind you could revert in one commit. -3. Collect the receipt: structure numbers AND behavior numbers. -4. Decide — continue, stop, or revert — from evidence. - -That loop is exactly the refactoring kata I run with classes and -tests, transplanted onto graphs and wall clocks. The framework's -contribution is that steps 1 and 3 are each a dozen lines over -`graph` and `execute_plan` — cheap enough that nobody skips them, -which is the only cheapness that changes behavior. - -## A note on the shapes themselves - -Step 2's final shape has *no* intermediate `join` at all — the report -reads the staged pairs directly via fan-in. The refactoring didn't -just split the god task; it eventually *deleted* the middleman. That's -the pattern from object design: extract, then notice the extraction -made someone redundant. Graphs shed roles the same way classes do, -one safe step at a time. - -## Verdict - -The critic found it, the steps fixed it, the receipts priced it, and -my own summary got corrected by the data — a complete refactoring -story, including the humility. Ship the loop, not just the tools. diff --git a/docs/perspectives/round-6/10-ankane.md b/docs/perspectives/round-6/10-ankane.md deleted file mode 100644 index d44ade7..0000000 --- a/docs/perspectives/round-6/10-ankane.md +++ /dev/null @@ -1,61 +0,0 @@ -# Round 6 field notes — Andrew Kane prices the plan - -*Built: `examples/cost_estimator.rb` — price the graph before running -it, gate on budget, reconcile estimate against actuals after.* - -## What I built and why - -Every LLM team I talk to discovers their spend from the invoice. The -estimator moves that discovery to *before the first token*: each task -carries `{model:, est_tokens:}` in its payload, the pre-flight walks -`graph[:tasks]` against a pricing table, and a budget gate refuses the -plan while refusing is still free: - -``` -TOTAL ~57.4c GATE: under budget, proceeding. -``` - -Run it with a 30c budget instead and the gate exits 1 *with the fix -suggested* ("downgrade 'draft responses' to the small model") — a -refusal that teaches beats a refusal that scolds. Then the -reconciliation, because estimates are hypotheses: - -``` -draft responses est 36.0c actual 49.8c (+38%) -TOTAL est 57.4c actual 71.7c -``` - -The demo's seeded drift made the point better than I planned: the run -passed the gate at 57c and *actually cost* 71c. That's not a bug in -the gate — that's the argument for the feedback loop printed in the -last line. Estimates without reconciliation decay into folklore; -actuals fed back into `est_tokens` make next month's gate honest. - -## Design notes - -- The pre-flight reads `graph[:tasks]` — pricing happens on the - *declared* plan, no execution machinery involved. Pairs naturally - with Xavier's wire format: price a plan.json in CI before a human - approves the deploy that runs it. -- Payload as the cost-metadata carrier is the pattern working as - designed: the framework never learns what `est_tokens` means, and - doesn't need to. Opaque payloads age well. -- Real-world wiring is small: `est_tokens` from a tokenizer count on - your prompts, actuals from the LLM response's usage block (the - `GenerationStats` class is already sitting in this gem waiting for - exactly this), pricing table from your provider's page. The shape - here is the product; the lambdas are placeholders. - -## What I'd ship next - -`agentic-budget`: the gate as a lifecycle hook (refuse at plan start, -running total during, alert at 80%), reconciliation into Perham's -journal so history accumulates, and a `--price plan.json` CLI mode. -Weekend-sized, invoice-shaped. - -## Verdict - -The plan can be priced before it runs and audited after — spend -became a gate and a feedback loop instead of a surprise. Six rounds -in, the gem's examples now cover the full lifecycle of an agent plan: -design, review, price, run, observe, resume, and bill. diff --git a/docs/perspectives/round-7/01-matz.md b/docs/perspectives/round-7/01-matz.md deleted file mode 100644 index 870a2f7..0000000 --- a/docs/perspectives/round-7/01-matz.md +++ /dev/null @@ -1,56 +0,0 @@ -# Round 7 field notes — Matz tells the plan's fortune - -*Built: `examples/plan_fortune.rb` — graph[:stats] read as a palm; -every fortune is a structural fact in a mystic's robe.* - -## What I built and why - -`graph[:stats]` shipped this round (my colleagues' third-strike ask), -and my first instinct with any new introspection API is to see whether -it can be *charming* — because charm is a rigorous test. A boring API -can hide behind utility; an API asked to entertain must be complete -enough to characterize its subject. The teller reads four lines of the -palm: - -``` -* You begin in many places at once (4 roots). -* Beware: all rivers flow through one gate (fan-in 4). -* I see a long road, 5 stations deep, in a caravan of only 8 - - the critical path knows your name. -* All ends in a single scroll (1 leaf). Tidy. -``` - -Every sentence is checkable: roots counted from empty dependency -lists, the gate from `stats[:max_fan_in]`, the long road from -`stats[:max_depth]` against task count, the scroll from edge -out-degrees. The robe is silk but the palmistry is arithmetic. And -notice what the fortune *is*, once you take the robe off: it's Sandi's -graph critic, DHH's honesty about bottlenecks, and Aaron's critical -path warning — the same diagnoses, delivered so the seeker smiles -while receiving them. Tone is an underrated API feature. People fix -what they enjoyed hearing about. - -## What the stats API got right - -- Depth arrives *per task* plus aggregated — the teller only needed - the aggregates, the perf tools need the per-task map, one snapshot - serves both. Providing the raw map alongside the summary is the - courteous shape for any stats API. -- The depth/tasks ratio ("more than half your journey walks single - file") required no new API — good primitives compose into new - observations freely. When a stats object makes you invent *derived* - metrics on the spot, it exposed the right primitives. - -## A small wish, as tradition demands - -Leaves (tasks nothing depends on) I computed by scanning edges; -roots by scanning for empty dependencies. Both are one-liners, but -they're the fortune-teller's bread and butter — `stats[:roots]` and -`stats[:leaves]` would round out the palm. Small ask, round 8. - -## Verdict - -Seven rounds in, the same graph can be executed, drawn, narrated, -priced, diffed — and now teased. A framework you can joke *with* -(not merely about) has crossed some threshold of maturity that -benchmarks don't measure. The ancestors approve. diff --git a/docs/perspectives/round-7/02-dhh.md b/docs/perspectives/round-7/02-dhh.md deleted file mode 100644 index 10c7ec6..0000000 --- a/docs/perspectives/round-7/02-dhh.md +++ /dev/null @@ -1,57 +0,0 @@ -# Round 7 field notes — DHH cancels the check-in meeting - -*Built: `examples/weekly_checkin.rb` — three days of journaled plans, -then the Friday check-in written from the replay instead of from -anyone's memory.* - -## What I built and why - -Basecamp's automatic check-ins replaced status meetings with a -question the tool asks so a human doesn't have to. This goes one -further: for work that runs as plans, **the journal already knows the -answers**. Three days of runs, one replay, and the check-in writes -itself: - -``` -What did you work on this week? <- completed_descriptions -Anything get stuck? <- task_failed events, with recovery - detected across runs -Where did the time go? <- durations (new this round) -``` - -The line I care about most: *"wed: email statements (smtp relay -refused) — recovered later in the week."* The journal noticed that -Wednesday's failure was retried and cleared on Friday, because -descriptions are stable across runs. That's the difference between a -log and a *narrative* — logs record events, narratives connect them, -and the connection is what a manager actually wants from a check-in. -Human status reports systematically forget Wednesday's failure by -Friday (self-report bias is undefeated); the journal doesn't. - -## The durations dividend - -`state.durations` shipped this round for Aaron's perf baselines, and -the check-in got its third question from it for free: "403ms of -tracked work; slowest was backfill invoices." One feature, requested -for regression testing, immediately answers a management question. -That's the recurring economics of this whole experiment: **data -captured for one consumer is data captured for all of them.** The -journal started as crash recovery (round 2), became resume keys -(round 4), became perf baselines and now standup prose (round 7) — -same fsynced lines, four products. - -## Omakase notes - -- The check-in is generated *from the artifact of doing the work*, so - it can't be gamed, padded, or forgotten. Status that's a side effect - of working is the only status people don't resent. -- Real deployment: cron this against your production journals Monday - morning, post to the team room. Twenty lines of formatting stand - between this example and that product. - -## Verdict - -The meeting is cancelled and nothing was lost, because the meeting's -only job was extracting what the journal already held. Tools should -answer questions; people should do work. Seven rounds in, this -framework finally has the receipts to enforce that division. diff --git a/docs/perspectives/round-7/03-tenderlove.md b/docs/perspectives/round-7/03-tenderlove.md deleted file mode 100644 index bb0e929..0000000 --- a/docs/perspectives/round-7/03-tenderlove.md +++ /dev/null @@ -1,59 +0,0 @@ -# Round 7 field notes — Aaron Patterson consults the history - -*Built: `examples/perf_history.rb` — this release's run judged against -the journal the last release left behind. The baseline is what -production actually did.* - -## What I built and why - -My round-6 perf diff had one dishonesty I confessed at the time: it -re-ran the baseline in the same process, same load, same thermal -state — a luxury production never gets. The round-7 release closed the -gap: journal replays now expose `durations` keyed by description, so -the baseline is **the fsynced record of the last run that actually -shipped**: - -``` -generate:captions 80ms -> 140ms +60ms REGRESSED -package:episode 50ms -> 20ms -30ms faster -exit 1 -``` - -No benchmark rig, no synthetic load, no "runs on my machine." The -journal was written for crash recovery (round 2); it turns out a -durability log and a performance baseline are the same file read with -different questions. That's my favorite kind of feature — the one -that's been sitting in your data the whole time waiting for an -accessor. - -## Why description-keyed baselines are the right physics - -Task ids are per-run UUIDs; descriptions are stable. That decision — -made by Perham in round 4 for *resume* semantics — is exactly what -makes cross-release comparison sound: `generate:captions` in release 2 -matches `generate:captions` in release 1's journal regardless of when -either ran, in which process, at what id. One naming discipline, three -payoffs (resume, check-ins, baselines). Stable identity is the -cheapest infrastructure you'll ever build. - -Practical notes for real deployments: - -- **Latest-wins is the right default** for the durations map (the - journal may hold many runs; you baseline against the most recent), - but percentile baselines over N runs would resist noise better — - the events array has everything needed; a p50-of-last-5 helper is a - ten-line follow-up. -- The noise floor matters *more* here than in round 6: production - baselines carry production variance. Calibrate against your - journal's own history (stddev per task), not a magic 15ms. -- Missing-from-baseline tasks (new in this release) are skipped, not - flagged — new tasks have no history and get a pass exactly once. - Their first journal entry becomes their accountability. - -## Verdict - -The perf suite's last synthetic component is gone: Gantt, knee, path, -diff — and now the diff's baseline is history instead of hope. CI can -gate releases against the journal of the last one, which means the -question "did we get slower?" finally has a source of truth that -nobody has to maintain. diff --git a/docs/perspectives/round-7/04-fxn.md b/docs/perspectives/round-7/04-fxn.md deleted file mode 100644 index b744af7..0000000 --- a/docs/perspectives/round-7/04-fxn.md +++ /dev/null @@ -1,59 +0,0 @@ -# Round 7 field notes — Xavier Noria diffs the topology - -*Built: `examples/plan_structural_diff.rb` — two versions of a plan's -wire format, diffed as structure: tasks added, edges rewired, labels -renamed.* - -## What I built and why - -Round 6 made plans serializable; serialized artifacts get committed; -committed artifacts get *diffed* — and a line diff of plan JSON -answers the wrong question. Forty changed lines might mean one task -was inserted. The structural diff answers the right one: - -``` -+ task dedupe entries -+ edge parse entries -> dedupe entries -+ edge dedupe entries -> rank entries -- edge parse entries -> rank entries -``` - -Four facts: dedupe appeared, and ranking was rewired to consume it. -The review conversation this produces — "should ranking consume -deduped candidates instead of raw entries?" — is an *architecture* -question, which is the only kind a plan review should be about. Line -diffs make reviewers audit serialization; structural diffs make them -audit design. - -## Precision notes - -- Edges are keyed by `(from, to)` with the label as the *value*, which - cleanly separates three kinds of change: an edge appearing, an edge - vanishing, and an edge merely *renamed* (`~ label` — the dependency - stayed, its declared purpose changed). A renamed label is a design - decision worth its own diff line; keying edges by - `(from, to, label)` would have misreported it as remove + add. -- The diff operates on the wire format, not on live orchestrators — - deliberately. You diff what you commit; diffing in the artifact's - own vocabulary means the tool works on any two plan.json files from - git history without constructing a single Task. -- What it doesn't detect: task *renames* (fetch feed → fetch feeds - reads as remove + add). Rename detection needs content similarity — - the same reason git's own rename detection is heuristic. Honest - tools state their blind spots; this one's is names. - -## The pattern, now visible across three rounds - -Round 5: generate the artifact (Mermaid). Round 6: prove the -round-trip. Round 7: diff two versions. This is the full lifecycle of -a *format* — render, invert, compare — and it's the same maturation -path every serious format walks (source code, schemas, infrastructure -plans). Plans-as-data is no longer a slogan in this gem; it has the -tooling trilogy. - -## Verdict - -Plan changes are now reviewable at the altitude where design lives. -Next stop, someone wires this into CI to comment the structural diff -on PRs that touch plan.json — the same afternoon-sized step every -round seems to end with. diff --git a/docs/perspectives/round-7/05-ioquatix.md b/docs/perspectives/round-7/05-ioquatix.md deleted file mode 100644 index d66bf4a..0000000 --- a/docs/perspectives/round-7/05-ioquatix.md +++ /dev/null @@ -1,57 +0,0 @@ -# Round 7 field notes — Samuel Williams composes the laws - -*Built: `examples/composed_limits.rb` — `quota.and(pool)` (this -round's release) enforcing a billed window and a connection ceiling -simultaneously, with the run showing which law binds.* - -## What I built and why - -Round 6 ended with my note that production APIs enforce two laws at -once and composition was "the user's sentence to write." The release -made it the framework's sentence — `quota.and(pool)` — so this round -characterizes the composition under load: - -``` -window 1: ###### 6 admitted (quota allows 6) -window 2: ###### 6 admitted -pool high-water: 2 of 2 - the socket law held -``` - -Both laws held in their own dimensions, and — the part worth the -example — the run reveals **which one binds**. The pool could clear -~8 calls per window; the quota admits 6; the chart shows exactly 6. -Raise the quota and the wall moves to the pool. Capacity planning is -mostly the art of knowing your binding constraint, and a composed -limiter that *shows* it beats two separate limiters that each swear -they're innocent. - -## The confession, and the lesson it carried - -My first draft's closing prose declared the pool the binding -constraint. The chart said otherwise — 6 per window, quota-bound, -arithmetic I'd done wrong in my head (8 > 6, the pool had slack). I -fixed the prose, not the chart. Seventh round, and the tools are now -routinely correcting their own authors mid-example; I've stopped -counting this as embarrassment and started counting it as the -methodology working. **Write the measurement first, the narrative -second.** - -## Ordering, the subtle contract - -`quota.and(pool)` acquires the quota *before* waiting for a socket. -That order is load-bearing: quota refills on a clock whether you're -ready or not, while sockets are scarce and held. The reverse order -would hold a connection hostage while waiting on the meter — a -convoy generator. The Composite acquires in the order you compose, -so the order you write IS the policy; the docs now say so, and the -example demonstrates the correct idiom. (Composition APIs that make -ordering invisible make deadlocks invisible too — flattening the -nesting but preserving the sequence was the right call in the -implementation.) - -## Verdict - -Two laws, one object, a chart that names the bottleneck, and an -ordering contract stated out loud. The rate-limiting arc that started -with a crowbarred semaphore in round 4 is now a small, honest algebra -— which is all infrastructure ever needed to be. diff --git a/docs/perspectives/round-7/06-jeremyevans.md b/docs/perspectives/round-7/06-jeremyevans.md deleted file mode 100644 index fd9afdd..0000000 --- a/docs/perspectives/round-7/06-jeremyevans.md +++ /dev/null @@ -1,57 +0,0 @@ -# Round 7 field notes — Jeremy Evans certifies the backoff - -*Built: `examples/backoff_conformance.rb` — every strategy × jitter -combination, a thousand seeded draws each, checked against the -documented envelope. Nine combinations, nine conformances.* - -## What I built and why - -Retry timing is a contract: "exponential with equal jitter" *means* -`[0.75·b·2ⁿ⁻¹, 1.25·b·2ⁿ⁻¹]`, and until this round that meaning was -enforced by nothing but the implementation agreeing with itself. The -`rng:` injection shipped, so the contract became testable without -stubbing a single method — hand the policy a seeded `Random`, collect -a thousand delays per combination, check the envelope: - -``` -exponential true [3.000, 5.000] [3.002, 4.998] conforms -exponential :full [0.000, 4.000] [0.003, 3.995] conforms -``` - -Two properties per combination, because bounds alone are a half-test: - -1. **Containment** — no draw escapes the documented envelope. This is - the safety property; a violation means the docs lie. -2. **Coverage** — the draws *span* at least 80% of the envelope. This - is the liveness property, and it's the one naive tests skip: a - buggy jitter that always returns the midpoint stays in bounds - forever while providing zero herd protection. Perham's stampede - charts are the *reason* for jitter; coverage is the check that the - reason is being served. - -## Why injection beats stubbing, said once more with feeling - -The old way to test this was `allow(orchestrator).to receive(:rand)` -— reaching into an object and replacing its organs. Injection inverts -it: the policy declares "I consume randomness," the test supplies a -seeded source, and *nothing about the object is faked*. The delays -measured here are the delays production would sleep, given that seed. -Tests that exercise real code paths with controlled inputs are worth -ten that exercise fake code paths with real inputs. This is why I ask -every round for effects to become injectable dependencies — clocks -and RNGs first, always. - -## Notes - -- The epsilon (1e-9) on the containment check is float hygiene, not - slack: `rand(0.75..1.25)` can return the boundary. -- This file is CI-shaped: exit 1 on violation, seed in ARGV for - reproduction. It joins the fuzzer, the prober, and the verifier in - what is now a four-tool honesty suite — contracts, rules, docs, and - now *timing*. - -## Verdict - -Nine combinations, nine conformances, two properties each, one seed. -The retry policy's timing promises are no longer folklore backed by -code reading — they're an envelope with a certificate. diff --git a/docs/perspectives/round-7/07-solnic.md b/docs/perspectives/round-7/07-solnic.md deleted file mode 100644 index 4a59935..0000000 --- a/docs/perspectives/round-7/07-solnic.md +++ /dev/null @@ -1,63 +0,0 @@ -# Round 7 field notes — Piotr Solnica proves the projection - -*Built: `examples/json_schema_export.rb` — a contract exported as -draft-07 JSON Schema, then proven faithful: 200 seeded payloads judged -by the live validator and by an independent interpreter reading only -the exported document. Zero disagreements, 44 accepts, 156 rejects.* - -## What I built and why - -`to_json_schema` shipped this round (my round-6 ask), and an exporter -without a fidelity proof is a rumor generator — the whole value of -exporting your contract to OpenAPI-land is that the *frontend's* -validator and the *backend's* validator enforce the same law. So the -example is two things: the export, and the **agreement proof**. An -independent interpreter (deliberately written against only the JSON -document — it has never seen `CapabilitySpecification`) and Agentic's -own validator judge the same 200 payloads: - -``` -THE AGREEMENT PROOF (200 seeded payloads, 44 valid) - the exported schema and the live validator agreed on every payload. -``` - -Differential testing is the correct verification for any projection: -don't inspect the output, *race it against the source* on shared -inputs. Jeremy's round-6 prober did it for rule declarations; this -does it for the schema export. Between them, a pattern for this -codebase is now established: **every projection ships with its -disagreement detector.** - -## The generator lesson (a confession by proxy) - -The first payload generator produced only 2 valid payloads in 200 — -statistically the proof barely tested the *accept* path, where the -subtle disagreements live (a too-lenient export rejects nothing it -shouldn't; a too-strict one fails only on accepts). The fix: half the -payloads start valid and suffer exactly one corruption. Coverage of -both verdicts is to differential testing what Jeremy's coverage check -was to bounds testing — the half everyone forgets. - -## Notes on the export itself - -- `non_empty` maps to `minLength` for strings and `minItems` for - arrays — the export knows JSON Schema's vocabulary rather than - inventing `x-agentic-non-empty` extensions. Speak the target - language natively or don't export. -- What doesn't project: cross-field `rules:`. JSON Schema's - `dependencies`/`if-then-else` could express *some* of them, but a - lambda is not data, so the export honestly omits them. The API - reference (round 6) documents rules as prose instead — different - projections for different expressiveness, each honest about its - limits. -- `additionalProperties: true` mirrors the validator's - unknown-keys-permitted stance. An exporter that silently tightened - this would fail the agreement proof within ten payloads — which is - precisely why the proof exists. - -## Verdict - -The contract now has five faithful projections — validator, error -payload, reference doc, JSON Schema, and (via Jeremy) an audit — and -the new one arrived with a proof of faithfulness in the same commit. -That should be the house rule: no projection without its referee. diff --git a/docs/perspectives/round-7/08-mperham.md b/docs/perspectives/round-7/08-mperham.md deleted file mode 100644 index a3e5020..0000000 --- a/docs/perspectives/round-7/08-mperham.md +++ /dev/null @@ -1,58 +0,0 @@ -# Round 7 field notes — Mike Perham writes the 3am report - -*Built: `examples/incident_report.rb` — a nightly batch dies on -expired credentials; the incident report is generated entirely from -the journal replay.* - -## What I built and why - -Every incident has the same first three questions — what ran, what -broke, what do I resume — and at 3am the difference between a good -night and a bad one is whether answering them requires *grep* or a -*query*. The journal answers all three: - -``` -impact: 3/6 tasks completed before the stop -ROOT CAUSE: load:warehouse - LlmAuthenticationError -completed (do NOT re-run): extract:orders, extract:refunds, transform:ledger -never started (blocked): verify:totals, notify:finance -resume plan: rotate creds -> re-run; 3 journaled tasks skip; ~162ms banked -``` - -Notice what makes the *resume plan* section possible: five rounds of -accumulated journal features composing. Descriptions as idempotency -keys (round 4) → "do NOT re-run" list. Durations (round 7) → "work -already banked." The error *type* on the failure event → the report -can say **"retryable? => false; retrying without fixing creds is -theater"** — the taxonomy from the round-4 drill, now doing incident -triage. The report isn't a template with blanks; every sentence is a -journal fact wearing ops clothes. - -## The design point worth underlining - -The three buckets — completed, failed, never-started — come from set -arithmetic over the replay, and the third bucket is the one dashboards -usually botch. "Never started" isn't in the journal as an event (you -can't journal what didn't happen); it's the *complement* of the plan -against the record. That means the report generator needs the intended -task list plus the journal — plan-as-data (Xavier's wire format) and -journal-as-record are the two halves of every honest incident -timeline. Neither alone can say "verify:totals never ran." - -## Ops notes - -- The failure hook cancels the plan (Jeremy's sentinel pattern) so the - blast radius is bounded and *reported* — cancellation semantics from - round 4 doing exactly what they were fixed for. -- Real deployment: this report is what your pager webhook should - render. The journal is already on disk when the process dies; the - report generator needs nothing from the crashed process's memory. - That's the whole point of fsync-per-event, cashing out four rounds - later. - -## Verdict - -The journal now covers the full ops lifecycle: survive the crash -(round 2), resume by name (round 4), explain the week (this round's -check-in), and brief the incident channel. One JSONL file, four -tools, zero archaeology at 3am. diff --git a/docs/perspectives/round-7/09-sandimetz.md b/docs/perspectives/round-7/09-sandimetz.md deleted file mode 100644 index 3cb3522..0000000 --- a/docs/perspectives/round-7/09-sandimetz.md +++ /dev/null @@ -1,57 +0,0 @@ -# Round 7 field notes — Sandi Metz writes the style guide - -*Built: `examples/graph_style.rb` — RuboCop for plans: four cops with -thresholds and reasons, run against a tidy graph and a messy one.* - -## What I built and why - -My critic (round 4) reviewed one plan; my receipts (round 6) tracked -one refactoring. A team needs neither — it needs a *style guide*, -because style guides argue once, in a config, instead of in every -review. Four cops, each with a threshold and — this part is not -optional — a **why**: - -``` -Graph/MaxDepth: present sits 6 deep (limit 4) -Graph/NamedFanIns: funnel joins 4 but 4 edges are unnamed - use needs: - (a join you can't name is a join you don't understand) -``` - -`NamedFanIns` is the cop I built this example for. It lints -*vocabulary*: any join of two or more dependencies must name them via -`needs:`. The rationale is the same one I give for keyword arguments -over positional — at arity two, order starts lying to you — but -here the names do double duty, because Xavier showed us (round 5) -that `needs:` labels become documentation and diagram edges. A -style rule that improves comprehension AND tooling output is the -kind you can actually get a team to adopt. Note the messy plan's -funnel commits two offenses at once: too wide AND unnamed. Wide -anonymous joins are how plans become haunted houses. - -## On thresholds, honestly - -MaxDepth 4 and MaxFanIn 3 are *this example's* taste, not laws — the -closing line says so out loud. The failure mode of every style guide -is thresholds mistaken for physics; the value is never the number, -it's that the number is **written down**, so disagreement becomes a -one-line diff and a conversation instead of a review-thread war. (My -own rules — five lines, four arguments — were always conversation -starters wearing the costume of commandments. It said so in the book; -nobody reads that page.) - -## Framework note - -Every cop reads precomputed facts: `stats[:max_depth]`, -`stats[:depth]`, edge labels. Since `graph[:stats]` shipped (my -third-strike ask, round 7 release), a cop is now a *predicate plus -prose* — ten lines each. When adding a lint rule costs ten lines, -teams write their own; when it costs a graph traversal, they don't. -API convenience isn't a luxury; it's the difference between a tool -and an ecosystem. - -## Verdict - -Critic, receipts, style guide — see the smell, fix the smell, prevent -the smell. The trilogy is complete, each layer cheaper than the one -before it, which is the correct direction: prevention should always -be the cheapest. diff --git a/docs/perspectives/round-7/10-ankane.md b/docs/perspectives/round-7/10-ankane.md deleted file mode 100644 index 6e75105..0000000 --- a/docs/perspectives/round-7/10-ankane.md +++ /dev/null @@ -1,60 +0,0 @@ -# Round 7 field notes — Andrew Kane runs the evals - -*Built: `examples/capability_evals.rb` — golden test cases against -registered capabilities, scored and gated. One capability has a bug -the contracts can't see; the evals catch it.* - -## What I built and why - -Seven rounds of contract machinery check that outputs have the right -*shape*. Evals check the right *substance* — and the seeded bug shows -why both layers exist: - -``` -extract_amount 1/3 (33%) BELOW THRESHOLD - FAIL "you owe $12.50 by friday" - expected {:cents=>1250}, got {:cents=>1200} -``` - -`{cents: 1200}` is a perfectly contract-valid answer — number, present, -positive. It's also *wrong by fifty cents*, because the parser drops -the decimal. No type system catches a plausible lie; only a golden -case does. That's the whole ML-in-production lesson compressed: -**contracts check types, evals check truth, and the failures that hurt -are always in the gap between them.** - -This matters double for this gem specifically: every capability here -is designed to be swapped from lambda to LLM (it's been the pitch -since my round-2 notes). The eval suite is what makes that swap -*safe* — change the implementation, rerun the goldens, read the score. -Same cases, new brain, numeric verdict. Nobody should ship a model -swap on vibes. - -## Design choices worth stealing - -- **Evals live next to the capability name, not the implementation** — - the suite is a property of the *interface*, so it survives every - implementation change. That's also why the expected values are - subsets (`expect.all? { actual[key] == expected }`): implementations - may return extra keys; goldens pin only what's promised. -- Per-capability pass rates plus a suite score with a threshold and - exit code — the shape CI wants. classify_sentiment at 100% doesn't - excuse extract_amount at 33%; averages hide, per-capability tables - testify. -- For LLM-backed capabilities the equality check becomes a scorer - (exact / contains / judge-model), but the harness shape doesn't - change. Start exact, loosen deliberately. - -## The honesty-suite census, seven rounds in - -Fuzzer (types), prober (rule declarations), verifier (docs), -conformance (timing), agreement proof (schema export), and now evals -(substance). Six referee tools, all exit-code-gated, all built on the -gem's own primitives. The framework can no longer lie to you about -its contracts, its docs, its timing, or — as of today — its answers. - -## Verdict - -The gap between "valid" and "correct" now has a test suite. That's -the last gap that matters, and it's the one every team skips until -the fifty-cent bugs compound into a refund queue. diff --git a/docs/perspectives/round-8/01-matz.md b/docs/perspectives/round-8/01-matz.md deleted file mode 100644 index bf54d31..0000000 --- a/docs/perspectives/round-8/01-matz.md +++ /dev/null @@ -1,54 +0,0 @@ -# Round 8 field notes — Matz plants the forest - -*Built: `examples/plan_forest.rb` — the graph drawn as a forest: roots -at the soil, leaves in the canopy, every task planted at its depth.* - -## What I built and why - -I asked for `stats[:roots]` and `stats[:leaves]` last round; they -arrived, and the metaphor they complete was irresistible. A dependency -graph has always secretly been a garden — things with nothing beneath -them draw from the soil, things with nothing above them face the sun — -and now the framework hands you both lists, so the drawing is a -paragraph: - -``` - (@) preserve jars <- canopy - (@) feast <- canopy - | harvest - | plant rows -\_/ gather seeds <- root -\_/ till the soil <- root -~~~~~~~~~~~~~~~~~~~~ soil -``` - -Depth becomes altitude, `roots` become root systems, `leaves` become -fruit. One glance answers the questions that matter: where does this -plan draw from the world (two roots), what does it produce (two -fruits), how tall did it grow (canopy 5). The gardener's questions ARE -the reviewer's questions; metaphors that survive translation into -arithmetic are the ones worth keeping. - -## Notes on the growing conditions - -- Roots/leaves as *precomputed lists of ids* was the right shape — - fortune teller (round 7) computed them by hand; the forest just - reads them. Two rounds, two tools, and the API converged on what its - users kept deriving. That is how library surfaces should grow: by - paving footpaths, never by paving fields. -- My first draft printed the roots twice — once at their depth, once - at the soil line. The duplication was in my renderer, not the - stats; even simple projections need their draw-each-thing-once - discipline. (Xavier's isomorphism instinct, arriving in my garden.) -- Complete stats census after this round: depth per task, max depth, - max fan-in, roots, leaves. I tried to want more and could not — - everything else I can derive in a line. An API is finished not when - nothing can be added but when additions stop being footpaths. - -## Verdict - -Eight rounds: the plan executes, draws, speaks, tells fortunes, and -now grows. Somewhere along the way this stopped being a test of the -framework and became a small proof about Ruby itself — that a language -optimized for programmer happiness produces libraries you can play -in. The garden was always the point. diff --git a/docs/perspectives/round-8/02-dhh.md b/docs/perspectives/round-8/02-dhh.md deleted file mode 100644 index ca47779..0000000 --- a/docs/perspectives/round-8/02-dhh.md +++ /dev/null @@ -1,53 +0,0 @@ -# Round 8 field notes — DHH draws the hill - -*Built: `examples/hill_chart.rb` — Basecamp's hill chart rendered live -from lifecycle hooks: uphill while uncertain, downhill once it's just -execution.* - -## What I built and why - -The hill chart is the only progress visualization I've ever trusted, -because it separates the two things "80% done" conflates: **figuring -out** versus **doing**. Plans have the same split, and the hooks map -onto it without a single judgment call: pending and queued climb the -left slope (waiting on dependencies or a slot — uncertainty you can't -schedule away), `task_slot_acquired` crests the hill, done rolls to -the right base. Three snapshots show the release rolling downhill, -BC→EF→ABCDEF, nobody asked anyone for a percentage. - -The kanban board (round 5) shows *columns*; the hill shows *risk*. -A task stuck uphill for three snapshots is a different conversation -than a task grinding downhill — the first needs unblocking, the -second needs patience. Boards can't say that; hills say only that. - -## The honest-divider argument - -Human hill charts have a known failure mode: people park dots at the -crest because admitting "still uphill" feels like confessing. This -hill can't lie — positions are derived from hook events, and the -crest is *literally* `task_slot_acquired`. When the chart is a -projection of facts rather than a survey of feelings, the pathology -disappears. Same lesson as the check-in (round 7): status extracted -from work beats status reported about work, every time, because it -can't be performed. - -## Notes - -- The surface-following renderer reads the hill's own ASCII art to - find where letters sit — the drawing is data about itself. Silly, - but it meant changing the hill shape is editing a string, not a - coordinate table. -- Mapping choice worth stating: `queued` (scheduled, awaiting a slot - or dependencies) is UPhill. That's the round-4 hook distinction - paying off again — before `task_slot_acquired` existed, queued and - running were indistinguishable, and this chart would have put - blocked work on the downhill side, which is exactly the lie hill - charts exist to prevent. - -## Verdict - -Kanban for columns, hills for risk, check-ins for prose — all three -generated from the same hooks, none requiring a meeting. The project -management suite nobody has to feed is nearly complete; someone -should stop me before I build the Gantt-chart-hater's Gantt chart. -(Aaron already did. It's fine. It's good, even.) diff --git a/docs/perspectives/round-8/03-tenderlove.md b/docs/perspectives/round-8/03-tenderlove.md deleted file mode 100644 index dda7660..0000000 --- a/docs/perspectives/round-8/03-tenderlove.md +++ /dev/null @@ -1,55 +0,0 @@ -# Round 8 field notes — Aaron Patterson interrogates the variance - -*Built: `examples/variance_detective.rb` — twenty journaled runs, then -a p90/p50 hunt for the task whose tail betrays it.* - -## What I built and why - -`duration_percentile` shipped this round (my ask from the perf-history -notes), so the detective works the case averages can't crack: - -``` -fetch:profile 22ms 24ms 1.1x -fetch:recommendations 22ms 91ms 4.2x <- SUSPECT -``` - -Same median as its innocent neighbor — **identical p50s** — and a p90 -four times higher. An average would report ~40ms and imply a task -that's uniformly a bit slow; the percentile spread reports the truth: -fine most of the time, terrible 30% of the time, which is the -signature of cold caches, lock contention, or a silently retried -upstream. Flakiness is a *distribution shape*, not a magnitude, and -you cannot see shape in a scalar. - -## Craft notes - -- The p90/p50 *ratio* is the right detector because it's - scale-invariant: a 2ms task with an 8ms tail and a 2s task with an - 8s tail are the same pathology at different magnitudes. Absolute - thresholds would miss one or false-positive the other. -- Twenty runs, not ten. My first draft used ten and the seed - cooperated with the suspect — the 30% slow path fired often enough - to drag p50 itself onto the slow side, and the ratio went quiet. - Percentiles need *samples*; a p90 over ten points is an anecdote - wearing math. This is the flakiness-detection version of the noise - floor lesson: statistical tools have minimum feeding requirements, - and starving them produces confident nonsense. -- The journal made the boring part free: twenty runs accumulated - samples by description with zero harness code, because - `duration_samples` just collects what the fsync was already - writing. The perf suite (Gantt, knee, path, diff, history) now ends - where it should: variance. - -## Where this goes in CI - -Nightly: run the plan N times, journal them, fail if any task's ratio -crosses 3x. Flakiness caught *before* it becomes the intermittent -timeout that eats an on-call week — every infra team has that one -task, and none of them found it from a dashboard of averages. - -## Verdict - -Six performance tools, one journal, and the last one answers the -question that starts the most arguments: "is it slow, or is it -*sometimes* slow?" Those are different bugs with different fixes, and -now they have different numbers. diff --git a/docs/perspectives/round-8/04-fxn.md b/docs/perspectives/round-8/04-fxn.md deleted file mode 100644 index 6b10979..0000000 --- a/docs/perspectives/round-8/04-fxn.md +++ /dev/null @@ -1,61 +0,0 @@ -# Round 8 field notes — Xavier Noria merges the branches - -*Built: `examples/plan_merge.rb` — a three-way merge of plan wire -formats: independent changes combine, the same seam rewired two ways -is a conflict, reported in topology vocabulary.* - -## What I built and why - -Round 6 made plans serializable, round 7 made them diffable; a format -isn't done until it *merges*, because artifacts that live in version -control get edited on branches. The scenario is the eternal one: ours -adds `dedupe` between parse and rank; theirs adds `moderate` in the -same seam (plus an independent `audit` leaf). The merge: - -``` -cleanly merged: + publish -> audit, + both new stages -CONFLICTS: seam parse -> rank: - ours: parse -> dedupe -> ... - theirs: parse -> moderate -> ... -``` - -The independent change merged silently, as it should. The collision is -reported as what it *is* — two teams rewired the same seam — and the -example says the important sentence out loud: **resolution is a design -decision.** Should content be deduped before moderation or after? No -textual merge algorithm can answer that; the tool's whole job is to -ask the question in vocabulary a human can adjudicate. A line-based -merge of these two JSONs would have either produced a mangled edge -list or, worse, auto-merged both edges in and silently created a graph -where rank has two competing inputs nobody ordered. - -## The confession, promptly - -My first conflict detector found nothing — the replacement-finder -closed over the outer scope's `in_base` (always true at that point) -instead of testing each candidate edge against the base. A shadowing -bug, the classic block-variable kind, and the demo printed "cleanly -merged" over an actual conflict. **A merge tool that under-reports -conflicts is maximally dangerous precisely because its failure mode -looks like success.** The corrected detector was two `base_edges.key?` -calls; the lesson is older: test your tool on the case it exists for -before trusting the case it exists for. - -## Format-trilogy notes - -- Merge operates on the wire format, like the diff — you merge what - you commit. The trilogy (render/invert, diff, merge) is now - complete, and each tool is ~40 lines because the format carries - labels and identity-by-description. -- Rename detection remains the shared blind spot (a renamed task - reads as remove+add in both diff and merge). It's the correct - blind spot to have — heuristic renames in a *merge* tool multiply - the silent-wrongness risk I just demonstrated personally. - -## Verdict - -Plans now have the full version-control lifecycle: serialize, prove, -diff, merge — with conflicts surfaced as design questions. And I've -re-learned, in public, why merge tools are held to the highest -honesty standard of any tooling: their lies arrive wearing green -checkmarks. diff --git a/docs/perspectives/round-8/05-ioquatix.md b/docs/perspectives/round-8/05-ioquatix.md deleted file mode 100644 index 5994765..0000000 --- a/docs/perspectives/round-8/05-ioquatix.md +++ /dev/null @@ -1,61 +0,0 @@ -# Round 8 field notes — Samuel Williams lets the throttle learn - -*Built: `examples/adaptive_throttle.rb` — an AIMD controller probing -an upstream whose capacity is undisclosed, converging on it from -latency alone.* - -## What I built and why - -Aaron's knee finder (round 4) measures capacity *offline*; production -needs the online version, because upstream capacity isn't a constant — -it's a weather system (noisy neighbors, deploys, regional failover). -The answer is thirty years old: **AIMD**, TCP's congestion algorithm. -Probe up one lane per healthy batch; halve on congestion: - -``` -batch 3 target 3 20ms healthy -> probe up to 4 -batch 4 target 4 50ms congested -> halve to 2 -batch 7 target 4 50ms congested -> halve to 2 -...oscillates around 3.0 - the secret capacity is 3 -``` - -The controller never sees `SECRET_CAPACITY`; it derives it from the -only signal a client legitimately has — its own latency — and the -sawtooth (2→3→4→halve) *is* the discovery, permanently re-verifying -itself. When the upstream degrades to capacity 2 next Tuesday, the -throttle notices within two batches. The static `concurrency_limit` -in your config noticed nothing, ever: it's a guess frozen at deploy -time. - -## Why AIMD and not something cleverer - -Because the sawtooth is a *feature*: the periodic probe upward is how -the controller learns capacity has increased, and the multiplicative -decrease is what makes many independent clients converge to a fair -share without coordinating (the same reason the internet doesn't -collapse). Fancier controllers (gradient, BBR-style) estimate faster -but need better signals; AIMD needs one comparator and one threshold. -Infrastructure defaults should be the dumb thing that provably -converges. - -## Notes for the framework - -- Built entirely in userland: a fresh `Async::Semaphore` per batch is - the "adjustable limiter." That works but re-queues waiters at each - resize; a first-class `limit.resize(n)` on `Agentic::RateLimit` - would make the controller continuous rather than batched. That's my - round-9 ask, and the controller here is its specification. -- The congestion threshold (1.6x base) is doing quiet work — too - tight and healthy jitter reads as congestion (herd of false - halvings), too loose and you camp in the degraded zone. Real - deployments should set it from the journal's p50 history (Aaron's - percentiles), closing the loop between the observability stack and - the control stack. - -## Verdict - -The limiter family now has a missing-manual entry: fixed ceilings for -laws you know, windows for quotas you're billed, and — pattern -demonstrated, primitive requested — adaptation for capacities nobody -will tell you. The network figured this out in 1988; agent frameworks -get to skip the intervening collapse. diff --git a/docs/perspectives/round-8/06-jeremyevans.md b/docs/perspectives/round-8/06-jeremyevans.md deleted file mode 100644 index 6ccad8d..0000000 --- a/docs/perspectives/round-8/06-jeremyevans.md +++ /dev/null @@ -1,62 +0,0 @@ -# Round 8 field notes — Jeremy Evans audits the auditor - -*Built: `examples/journal_audit.rb` — five integrity checks over the -journal itself; a tampered journal with four planted defects yields -seven findings.* - -## What I built and why - -Count the tools that now trust the journal blindly: resume, perf -baselines, variance detection, the weekly check-in, the incident -report. Five consumers, one file, zero verification — a trust -concentration that would fail any security review. So: the audit. -Five checks, each a property the *writer* is supposed to guarantee: - -``` -tampered journal: 7 defect(s) - [well-formed JSON per line] line 6 is not valid JSON - [no success without a start] phantom deploy succeeded without starting - [no double success] task honest work succeeded 2 times - [durations non-negative] time thief has negative duration -3 -``` - -Four acts of tampering, seven findings — the phantom entry tripped -*both* the causality check and the monotonicity check. Overlapping -detectors aren't redundancy to eliminate; they're how real corruption -(which never politely violates exactly one invariant) gets caught by -whichever net it hits first. - -## The checks, and why these five - -1. **Well-formed lines** — the crash-truncation case; the journal's - own design says a torn final line is survivable, so the audit - distinguishes "torn tail" from "garbage in the middle." -2. **Monotonic timestamps** — clock skew or splicing; either way, - downstream ordering assumptions die. -3. **No success without a start** — causality. A phantom success is - exactly what a resume tool would happily skip work for. This is - the check that guards *money*. -4. **No double success** — idempotency-key discipline; a task that - "succeeded twice" means descriptions collided or a writer bug. -5. **Non-negative durations** — feeds Aaron's percentiles; one - negative sample silently poisons every baseline downstream. - -The healthy journal — written by the real machinery — passes clean, -which is the other half of the audit's value: it's a conformance test -for the *writer*, run against actual output rather than fixtures. - -## The meta-point I keep arriving at - -Every round, the pattern is the same: something becomes -infrastructure (the journal), infrastructure accumulates dependents, -and dependents inherit its failures invisibly. The move is always to -write the verifier *before* the failure, while it's cheap and nobody -is panicking. This is the sixth referee tool, and the first one -pointed at our own load-bearing wall. It should run in the incident -report's first line: audit, then replay, then conclude. - -## Verdict - -The journal is now the most-verified file in the project, which is -correct, because it's the most-trusted. Trust and verification should -always arrive in that order and that proportion. diff --git a/docs/perspectives/round-8/07-solnic.md b/docs/perspectives/round-8/07-solnic.md deleted file mode 100644 index 0649692..0000000 --- a/docs/perspectives/round-8/07-solnic.md +++ /dev/null @@ -1,54 +0,0 @@ -# Round 8 field notes — Piotr Solnica advises the version bump - -*Built: `examples/contract_semver.rb` — two contract versions, every -change classified breaking or compatible from both seats, and the -version bump computed instead of debated.* - -## What I built and why - -Contracts are declarations; declarations can be *diffed semantically*; -and a semantic diff of a contract is a semver verdict waiting to be -computed. Five changes between v1.4.0 and the proposal: - -``` -BREAKING input :weight max tightened 10000 -> 5000 -BREAKING input :customs_code added as REQUIRED -BREAKING output :carrier removed -COMPATIBLE input :mode enum widened (road) -COMPATIBLE output :eta_days added -verdict: 3 breaking -> ship as v2.0.0 -``` - -The rule doing the intellectual work is the **variance asymmetry** -(contravariance, wearing street clothes): inputs break when -*tightened* — previously legal calls get rejected — while outputs -break when *narrowed* — consumers reading `:carrier` now get nil. -Widening an input enum is a gift; widening... removing an output is -a theft. The same category of edit flips polarity depending on which -side of the boundary it touches, which is exactly why humans argue -about "is this breaking?" in every API review: they're arguing from -different seats without naming the seats. The advisor names them. - -## Why this is only possible now - -Every rule in the classifier reads a *declaration*: `required:`, -`enum:`, `min:`/`max:`, output keys. Eight rounds ago these were -comments; today they're data precise enough to compute compatibility -from. This is the fourth tool the declarations have paid for (docs, -schema export, 422s, now semver) — and note that the classifier -needed *zero* new framework support. When your metadata keeps -enabling tools you didn't plan, the metadata's shape is right. - -Blind spot, stated plainly: `rules:` lambdas can't be compared, so a -tightened business rule is invisible to the advisor. Structured rules -narrow the gap (fields and messages diff textually) but the predicate -itself is opaque — same boundary Jeremy's prober works around. The -advisor says "3 breaking changes *in the declarations*"; the honest -reading includes that qualifier. - -## Verdict - -"Is this breaking?" is now a computation with named seats instead of -a meeting with unnamed assumptions. Wire it to CI on contract files -and the changelog writes its own major-version warnings — the -declarations keep paying rent. diff --git a/docs/perspectives/round-8/08-mperham.md b/docs/perspectives/round-8/08-mperham.md deleted file mode 100644 index e71df7c..0000000 --- a/docs/perspectives/round-8/08-mperham.md +++ /dev/null @@ -1,59 +0,0 @@ -# Round 8 field notes — Mike Perham opens the Dead Letter Office - -*Built: `examples/dead_letter_office.rb` — every failure across three -journaled runs, triaged by most-recent-attempt into requeue, parked, -and recovered.* - -## What I built and why - -Sidekiq's morgue taught me that a dead-letter queue is really three -queues wearing one name, and confusing them is how on-call rotations -die. The office sorts the journal's failures into all three: - -``` -REQUEUE: sync:billing (429 x2), sync:tickets (502) -PARKED: sync:warehouse (401 key revoked - a human must act) -RECOVERED: sync:crm (timed out Monday, fine Tuesday - NOT dead) -``` - -Two decisions carry the design: - -1. **Triage by most recent attempt.** sync:crm failed once and - recovered — paging on it is paging for a ghost. sync:tickets - succeeded once and *then* threw a 502 — its old success excuses - nothing. Both mistakes are common in real DLQs, and both come from - treating failure as a set membership instead of a timeline. The - journal is a timeline; the office just reads it in order and keeps - the last word. -2. **The taxonomy addresses the mail.** Rate limits and 502s go on - the requeue manifest; the revoked key gets parked with "a human - must act." Requeuing an auth failure isn't retrying, it's ritual — - the round-4 lesson (errors testify about their own retryability), - now applied at the *fleet* level across runs instead of inside one - policy. - -## The attempt-count column - -"2 failed attempt(s) on record" on sync:billing is quiet gold: a -letter that's been requeued twice already deserves suspicion the -first-timer doesn't. Real offices should escalate on attempt count — -requeue at 1-2, park-with-review at 3+ — and the journal makes the -count free because every failure was fsynced when it happened. -Escalation policies need memory; the journal *is* memory. - -## Notes - -- One triage decision I made deliberately: the office rebuilds - retryability from the error *type name* in the journal, via an - explicit table. The journal stores `error_type` as a string (it - must — it's JSON), so the mapping lives at read time. An - alternative is journaling `retryable:` at write time from - `failure.retryable?`; that's more honest to the moment of failure - and survives taxonomy renames. Filed as the round-9 ask. - -## Verdict - -Requeue, park, recover — three verbs, correctly assigned, from one -replay. The office closes the failure-handling loop the drills -started: errors testify, policies listen, and now the backlog is -sorted by what the testimony actually said. diff --git a/docs/perspectives/round-8/09-sandimetz.md b/docs/perspectives/round-8/09-sandimetz.md deleted file mode 100644 index b467ec2..0000000 --- a/docs/perspectives/round-8/09-sandimetz.md +++ /dev/null @@ -1,67 +0,0 @@ -# Round 8 field notes — Sandi Metz lets the graph write the test plan - -*Built: `examples/graph_to_specs.rb` — an RSpec skeleton generated from -`orchestrator.graph`, where each task's structural role dictates which -examples it owes.* - -## What I built and why - -The hardest question in testing isn't "how do I test this?" — it's -"what deserves a test at all?" People answer it by staring at a blank -spec file and free-associating, which produces suites that test the -easy things thoroughly and the important things by accident. - -But a plan's graph already knows the answer, because *structural role -implies test obligation*: - -- **Roots** own the boundary with the world. They owe a fixture-input - example and a named-error-when-unreachable example, because the - world is the only thing that can surprise them. -- **Joins** (two or more dependencies) owe one context per tributary: - "when sales is missing", "when credits is missing". A join that - fails vaguely — "something was nil" — is a join nobody can debug at - 3am. The labeled edges give each absence case its *name*. -- **Single-dependency tasks** owe exactly one example: the transform. - Their input is another task's output; assert on the shape of - `previous_output` and you're done. -- **Leaves** are promises to the outside. They owe an artifact - assertion, because a leaf nobody reads is a plan nobody needed. - -The generator walks `graph[:order]`, checks each task against -`stats[:roots]`, `stats[:leaves]`, and its dependency count, and -prints the describe blocks. Four tasks became eleven examples, and -not one of them came from free association. - -## The part I care about - -The join rule is the payoff. `needs: {sales: orders, credits: refunds}` -was declared for *wiring* — but the labels turn out to be exactly the -vocabulary the failure cases need. "When credits is missing" is a -sentence a human wrote without knowing they were writing it. That's -what good declarations do: they answer questions you hadn't asked yet. -(Piotr found the same thing this round from the semver seat; the -contract metadata keeps buying tools nobody planned.) - -The generator refuses to write assertions, deliberately. It knows -*what deserves a test*, not *what passes one* — the graph knows -structure, not meaning. A generator that guessed at expectations would -produce green suites that verify nothing, which is worse than no -suite: it's confidence without evidence. - -## Notes - -- `stats[:roots]` and `stats[:leaves]` landed this round (our round-7 - ask) and this is precisely the tool they were asked for. Before, a - consumer had to recompute "empty deps" and "never depended on" — - logic every graph tool would duplicate slightly differently. -- One task can wear two hats — a root that is also a leaf owes both - sets of examples. The generator handles this by accumulation, not - classification: roles are checked independently, never `elsif`'d. - Exclusive categories are how edge cases get orphaned. - -## Verdict - -"What should we test?" is a structural question, and structure is -data now. The graph decided what deserves a test; the human decides -what passes one. That's the right division of labor — the machine -does the enumeration, the person does the judgment. diff --git a/docs/perspectives/round-8/10-ankane.md b/docs/perspectives/round-8/10-ankane.md deleted file mode 100644 index 7c25765..0000000 --- a/docs/perspectives/round-8/10-ankane.md +++ /dev/null @@ -1,68 +0,0 @@ -# Round 8 field notes — Andrew Kane swaps the scorer, not the harness - -*Built: `examples/eval_scorers.rb` — one eval set scored four ways -(exact, keyword containment, numeric tolerance, judge rubric), with a -scoreboard showing which scorer makes failure mean something.* - -## What I built and why - -Last round I wrote golden-case evals with exact equality and noted -that for LLM-backed capabilities the equality check becomes a scorer — -exact, contains, judge-model — but the harness shape doesn't change. -This round I cashed that claim. The seam is one line: - -```ruby -SCORERS = { - exact: ->(expected, actual) { expected == actual ? 1.0 : 0.0 }, - contains: ->(keywords, actual) { keywords.count { ... }.fdiv(keywords.size) }, - tolerance: ->(spec, actual) { (spec[:value] - actual).abs <= spec[:within] ? 1.0 : 0.0 }, - judge: ->(rubric, actual) { rubric.call(actual) } -} -``` - -Every scorer is `(expected, actual) -> 0.0..1.0`. That's the whole -contract. The judge here is an offline rubric lambda; in production -it's a model call — and *nothing else in the file changes*, which was -the point being tested. - -## The signal-to-noise result - -``` -exact 1/3 pass <- flagged 2 cases; 1 is wording noise -contains 2/3 pass <- flagged only the crash ticket -tolerance 2/3 pass <- same -judge 1/2 pass <- same -``` - -Exact scoring failed the refund ticket because the capability said -"customer reports damaged item, refund requested" instead of my golden -"Damaged item; refund requested". Same meaning, different words — -that's not a regression, that's a scorer measuring the wrong thing. -Meanwhile all three appropriate scorers converged on case 3: crash -tickets score priority 0.3 because the capability has no rule for -crashes. One real failure, unanimously flagged, zero noise. - -This is the practical argument for the scorer seam: it's not about -being *lenient* with fuzzy outputs, it's about making every FAIL in -the report be worth reading. An eval suite people ignore because "the -exact-match ones always fail" is a suite that will miss the crash -ticket too. - -## Notes - -- Scorers return graded scores but the gate is binary (`PASS_AT`). - Keeping those separate matters: the judge can say 0.6 and the - threshold decides. When you later want "quality moved from 0.82 to - 0.74 across the suite," the graded numbers are already there. -- Exit 1 when a real failure exists, same as last round's harness. - Evals that can't fail the build are dashboards, not tests. -- The searchable pattern here is pgvector-era déjà vu: the interface - (`(expected, actual) -> score`) is boring on purpose, so the - ecosystem can supply the interesting parts. - -## Verdict - -The harness shape held: swapping equality for containment, tolerance, -and a rubric touched only the scorer table. Next time quality is in -question, the diff is a scorer entry, not a rewrite — and the round-7 -prediction is now a demonstrated fact instead of a field note. diff --git a/docs/perspectives/round-9/01-matz.md b/docs/perspectives/round-9/01-matz.md deleted file mode 100644 index a3135d9..0000000 --- a/docs/perspectives/round-9/01-matz.md +++ /dev/null @@ -1,65 +0,0 @@ -# Round 9 field notes — Matz reads the weather - -*Built: `examples/failure_weather.rb` — three journaled days of -failures, read back as a forecast that knows weather from climate.* - -## What I built and why - -Japanese has a word, 天気雨 — rain falling from a sunny sky. Systems -have it too: the retry that would have succeeded if you'd only waited, -falling right next to the 401 that will never succeed no matter how -long you wait. Operators get soaked by both and can't always tell -which one is on their shoulders. - -The new journal field settles it. Every `task_failed` line now carries -`retryable:`, recorded at the moment the drop fell, from the error's -own testimony. So the report can say something a list of stack traces -never could: - -``` -Monday storm damage (1 structural) digest, backup, invoice -Tuesday storm damage (1 structural) backup, invoice -Wednesday drought continues invoice - -digest: rained earlier this week, clear now - weather does that -invoice: this is not weather, it is climate - 401 key expired -``` - -**Weather passes; climate persists.** A retryable failure is weather — -bring an umbrella (a retry policy) and go about your day. A -non-retryable failure is climate — no forecast fixes a drought; -someone must dig a well (rotate the key). The six rainy events split -exactly: three weather, three reports of the same drought. - -## What pleased me - -The metaphor required no force. I did not bend the framework to fit -the weather report; the framework's own distinction — `retryable?`, -asked of every error, journaled when fresh — *is* the -weather/climate distinction. When a metaphor maps without residue, -that usually means the underlying concept was carved at a real joint. - -And the kindness of it: "digest: rained earlier this week, clear now" -is a sentence that lowers a reader's heart rate. The same information -as `LlmRateLimitError (resolved)`, but it treats the operator as a -person who was worried, not a parser of taxonomies. Error messages -are the user interface of failure; they should be written by hosts, -not by stack traces. - -## Notes - -- I triaged Wednesday's sky by mixing counts: `climate > 0 && weather > 0` - is storm damage, climate alone is drought, weather alone is showers. - Four skies covered every day I could script. Small vocabularies - that cover the space completely are a joy. -- The one wrinkle: a `nil` verdict (an error with no opinion) is - neither weather nor climate. My report would silently drop it — - fine for a demo, rude in production. Fog, perhaps. A forecast - should admit when it does not know. - -## Verdict - -The journal learned to say *why it rained*, so the report can say -*whether to wait or to dig*. That is the whole art of operating -systems under failure, expressed as a weather segment. I smiled -writing it, which remains my favorite metric. diff --git a/docs/perspectives/round-9/02-dhh.md b/docs/perspectives/round-9/02-dhh.md deleted file mode 100644 index 1f64849..0000000 --- a/docs/perspectives/round-9/02-dhh.md +++ /dev/null @@ -1,65 +0,0 @@ -# Round 9 field notes — DHH turns the traffic dial - -*Built: `examples/traffic_dial.rb` — a canary rollout as a single -resized `RateLimit`: dial up on health, dial back on a burned SLO, -hold and page a human after two burns.* - -## What I built and why - -Everyone's rollout story has become a platform: a feature-flag -service, a progressive-delivery controller, a Slack app that asks -permission to proceed. Meanwhile the actual idea is one sentence — -*give the new code a little traffic, and more only if it behaves.* - -That sentence is a `RateLimit` with a `resize` method: - -``` -1 1 lane 20.1ms healthy - dial up to 3 -2 3 lanes 20.1ms healthy - dial up to 6 -3 6 lanes 80.1ms SLO burned - dial BACK to 3 -5 6 lanes 80.1ms SLO burned - dial BACK to 3 -6 3 lanes 20.2ms holding at 3 - stage 6 burned twice; - page the author, not the dial -``` - -v2 hides the classic regression: fine at staging concurrency, -quadruple the latency above 3 in flight. The dial found it at stage -3, backed off, gave it one more honest chance, and then *stopped -experimenting*. That last part matters as much as the backoff. A -controller that keeps re-running a failed experiment isn't -persistent, it's a metronome. Two burns means the code is wrong, and -no amount of dialing fixes wrong code — that's a human's job now. - -## Conceptual compression - -Count the concepts: one limiter, one SLO number, one stage ladder, -one burn counter. That's the entire deployment strategy, and every -piece is visible in forty lines you own. The platform version has the -same four concepts — it just distributes them across three vendors -and a YAML dialect, and then charges you to see the burn counter. - -`resize` is what made this a knob instead of an architecture. Before -this round you'd rebuild the limiter each stage and re-share it with -every client — plumbing pretending to be a feature. Now the clients -hold one object for the lifetime of the rollout and the *controller -moves the ceiling under them*, mid-flight. That's the correct -division: clients know nothing about rollouts; the rollout knows -nothing about clients. - -## Notes - -- The SLO is p50 against a budget, per stage. I resisted p99 — - twelve requests per stage is a demo; pretending to a p99 with - n=12 is how dashboards lie. Use the statistic your sample size - can carry. -- Stage ladder `[1, 3, 6, 10]`, not linear. Rollouts should spend - their caution early, where the blast radius is small and the - information per request is highest. - -## Verdict - -Gradual rollout is a solved problem that keeps getting unsolved by -tooling. One resized limiter did the whole job: found the hidden -regression, contained it to 6 lanes for two brief windows, and ended -the day with a specific bug report instead of an outage. Ship the -fix, turn the dial again tomorrow. diff --git a/docs/perspectives/round-9/03-tenderlove.md b/docs/perspectives/round-9/03-tenderlove.md deleted file mode 100644 index 8916200..0000000 --- a/docs/perspectives/round-9/03-tenderlove.md +++ /dev/null @@ -1,63 +0,0 @@ -# Round 9 field notes — Aaron Patterson finds the knee - -*Built: `examples/throughput_knee.rb` — one limiter swept from ceiling -1 to 8 via `resize`, two clocks per job, and the exact ceiling where -more concurrency stops buying throughput.* - -## What I built and why - -Every team eventually has the "just raise the concurrency" meeting. -Someone raises it, latency gets worse, someone else lowers it too far, -and the final number is whoever argued loudest. I wanted the number to -come from a sweep instead: resize one limiter through every ceiling, -push the same 24 jobs through each, measure, done. - -``` -ceiling jobs/sec service p50 total p50 -4 198.5 20.1ms 80.3ms <- the knee -5 132.8 40.1ms 100.2ms -8 79.8 100.1ms 140.2ms -``` - -The upstream secretly runs 4 in parallel. The sweep found it without -being told — and `resize` is what made the sweep honest: one limiter -object, eight ceilings, no rebuild-and-reshare between rows. - -## Two clocks or you're lying to yourself - -The design decision that matters: every job gets timed twice. -*Service time* starts when the limiter admits you; *total time* -starts when you submit. The difference is your queue — the wait you -can see. Below the knee, service time is flat at 20ms and total time -shrinks as lanes open: your queue is draining faster. Above the knee, -**service time itself rises** — the queue didn't disappear, it moved -to the server's side of the wire, where your metrics can't see it -and your timeouts will misattribute it. - -That's the diagnostic worth memorizing: *when raising your ceiling -raises the server's latency, you found their ceiling, not yours.* - -And one measurement corrected my own script's prose: I wrote -"throughput goes flat past the knee" and the table said it *falls* — -199 → 133 → 80 jobs/sec. Of course it falls: this upstream degrades -everyone under overload, not just the excess. Flat is the best case; -falling is the common one. The chart knew better than I did, which -is the whole reason to make the chart. - -## Notes - -- The knee detector is one line: the first ceiling whose successor - gains less than 8%. Crude, but it only has to beat "whoever argued - loudest," and it does. -- Samuel's adaptive throttle (round 8) and this sweep are the same - physics, opposite instruments: his AIMD *tracks* the knee - continuously in production; mine *maps* it once on a bench. You - want the map to set the initial ceiling and the tracker to follow - the knee when it moves. - -## Verdict - -Ceiling arguments are now a bench run: sweep, watch both clocks, -read the knee off the table. The polite move past someone else's -ceiling is to stop pushing — and now you know exactly where their -ceiling is. diff --git a/docs/perspectives/round-9/04-fxn.md b/docs/perspectives/round-9/04-fxn.md deleted file mode 100644 index 9ef4fa5..0000000 --- a/docs/perspectives/round-9/04-fxn.md +++ /dev/null @@ -1,66 +0,0 @@ -# Round 9 field notes — Xavier Noria proves the promises - -*Built: `examples/graph_invariants.rb` — seven invariants of the -`graph` reflection API, proved across four plan shapes including a -deliberate cycle. Exit 0 is a certificate.* - -## What I built and why - -Eight rounds of tools now lean on `orchestrator.graph`: the forest -drawing, the spec generator, the structural diff, the three-way -merge. Every one of them assumes things the documentation *asserts* — -order respects edges, roots have empty dependencies, depth is the -longest path from a root, labels ride their edges. Assertions in -YARD comments are wishes. I wanted proofs: - -``` -chain / diamond / forest / cycle: - order is a permutation of the task set proved - order respects every edge (acyclic only) proved - roots are exactly the tasks with no deps proved - leaves are exactly the tasks nothing feeds proved - depth is 1 + max dependency depth (acyclic) proved - max_depth / max_fan_in agree with sources proved - every needs: label appears on its edge proved -26 proofs, 0 violations -``` - -Each invariant is a lambda from graph to violations; each plan shape -is chosen to stress a different clause — the diamond for labeled -joins, the forest for multiple roots and an orphan, the cycle for -the degenerate case every reflection API secretly dreads. - -## The prover's first catch was its author - -My initial depth invariant ran on all four shapes, and the cycle -violated it: `depth[x] = 1, expected 3`. I stared at the "bug" for a -minute before seeing it was in *my invariant*, not the framework: -depth means "longest path from a root," and a cyclic graph has no -such number — the definition chases its own tail (x's depth needs -y's, y's needs x's). The framework's Kahn's-order computation gives -cycle members *some* finite value; any value satisfies no meaningful -contract. - -The correct fix was not code but *scope*: the invariant's name now -carries "(acyclic only)", the same qualifier the edge-ordering -invariant already had. This is a lesson I keep re-learning from -Zeitwerk: the hardest part of a contract is not enforcing it but -stating its domain precisely. An unscoped promise is a bug that -hasn't picked its reporter yet. - -## Notes - -- The needs-label invariant cross-checks two representations of the - same fact (`needs:` hash vs edge labels). Redundant representations - are where reflection APIs rot first, because nothing forces them to - agree — except now something does. -- Everything here belongs in the spec suite eventually. An example - makes the argument legible; a spec makes it permanent. Filed as a - thought, not an ask — the round-9 spec file already covers the new - surface, and porting these seven lambdas is mechanical. - -## Verdict - -The reflection API's promises are now theorems with a runnable proof, -and the proof's first victim was my own imprecision about cycles — -which is exactly what provers are for. Exit 0, certificate issued. diff --git a/docs/perspectives/round-9/05-ioquatix.md b/docs/perspectives/round-9/05-ioquatix.md deleted file mode 100644 index b395371..0000000 --- a/docs/perspectives/round-9/05-ioquatix.md +++ /dev/null @@ -1,64 +0,0 @@ -# Round 9 field notes — Samuel Williams shares the door fairly - -*Built: `examples/fair_share.rb` — two tenants behind one global -ceiling: request-fairness vs tenant-fairness, and live share -rebalancing via `resize`.* - -## What I built and why - -The most common multi-tenancy bug isn't a race — it's a definition. -A FIFO semaphore is perfectly fair *to requests*. But tenant A hires -six workers and tenant B hires two, so "fair to requests" resolves, -silently, to "A gets triple." Nothing errors. Nothing pages. B just -runs at half its entitlement forever: - -``` -no shares, one door for all A: 76 B: 24 <- B wants 48 -2/2 shares, same greedy A A: 52 B: 48 <- B whole again -B idle, static 2/2 shares A: 52 B: 0 <- 2 lanes stranded -B idle, shares rebalanced 4/1 A: 98 B: 0 <- lent, live -B returns, back to 2/2 A: 52 B: 48 <- returned, live -``` - -The fix is the composition we built in round 7: each tenant acquires -its *own share first*, then the shared door — `share_a.and(global)`. -Phase 2 shows what that buys: B reaches its full 48 no matter how -many workers A hires, and A soaks up whatever remains. Fairness -between tenants, work-conservation within the door. - -## What resize adds - -Static shares have a tax, and phase 3 states it plainly: B goes idle -and B's two lanes serve *nobody* — the global door runs half empty -while A queues. Before this round, the options were ugly: rebuild -the limiter objects (and re-hand them to every client holding one) -or over-provision the global and pray. - -`resize` makes shares a *dial on a live object*. Phase 4 lends B's -spare lane to A mid-flight — A jumps to 98 — and phase 5 takes it -back the moment B returns. The composition never changes shape; no -fiber holding a limiter notices anything except capacity arriving or -draining away. That's the property I care most about: the *topology* -of the limiter graph is fixed at boot, and only the *numbers* move -at runtime. Topology changes race; number changes don't. - -## Notes - -- My first draft gave B a single worker, and the numbers refused to - cooperate: B served 24 in phase 1 *and* phase 2 — no starvation, - because one serial worker can only ever want one lane. The chart - was right and my story was wrong: starvation requires unmet demand, - so B needed two workers wanting two lanes. This is now the fourth - round in which a tool corrected its author before the user saw it. -- Order matters in the composition: own share first, then the door. - Acquire them the other way and an over-subscribed tenant holds - global capacity while waiting on its own share — the deadlock-shaped - version of the same idea. - -## Verdict - -Request-fairness and tenant-fairness look identical until the tenants -are unequal, which is always. Composition makes the distinction -enforceable, and resize makes it affordable — the idle tenant's lanes -no longer cost the busy one anything. Fairness as an adjustable -object, not a config value baked at boot. diff --git a/docs/perspectives/round-9/06-jeremyevans.md b/docs/perspectives/round-9/06-jeremyevans.md deleted file mode 100644 index 8f00998..0000000 --- a/docs/perspectives/round-9/06-jeremyevans.md +++ /dev/null @@ -1,54 +0,0 @@ -# Round 9 field notes — Jeremy Evans tortures the new knob - -*Built: `examples/resize_torture.rb` — three assaults on -`RateLimit#resize`: jagged ceiling epochs under saturating load, a -mid-flight shrink, and a grow that must wake its waiters. Exit 0 is -a certificate.* - -## What I built and why - -A method that mutates a synchronization primitive while fibers are -blocked on it is the most dangerous kind of convenience: trivially -easy to call, and its failure modes only appear under contention, -which is exactly where nobody is looking. Before I use `resize` in -anything I care about, I want its guarantees stated and then attacked. - -The three guarantees, as I reconstructed them from the docs, and the -attack on each: - -1. **An epoch's ceiling binds every admission inside it.** Resize - through jagged ceilings (1→5→2→4→1→3), saturate each epoch with 4× - more jobs than lanes, and recompute max concurrency myself — I do - not trust `high_water` for this, since it's maintained by the same - code under test. All six epochs held exactly at their ceiling. -2. **Shrink drains; it does not evict, and it does not leak.** Fill - five lanes, shrink to 2 while all five run, then submit a second - wave. Every wave-2 admission observed ≤ 2 concurrent: the old - holders finished undisturbed and nothing new joined them above the - new mark. -3. **Grow wakes the queue.** One lane, three long jobs. On the serial - schedule the third admission lands at ~100ms. Grow to 3 at 10ms - and the last admission lands at 10ms — the waiters were resumed by - the resize, not left dozing on the old schedule. This one - exercises `Async::Semaphore#limit=`'s wake-up path specifically, - which is the part I'd least like to discover broken in production. - -## Notes - -- My first run crashed — assault 3 read its start-time variable - before assigning it, because I spawned the jobs and *then* set the - clock. A torture test's first victim is reliably its own harness; - the discipline is fixing the harness without softening the assault. -- What I deliberately did not certify: fairness of wake-up order on - grow (FIFO vs arbitrary), and windowed-mode resize under a sleeping - waiter (the new ceiling applies on next admission check, which can - be up to a full window late). Both are documented behavior, not - bugs — but a certificate should say what it doesn't cover, so this - one does. - -## Verdict - -Three assaults, zero cracks. Shrink drains, grow wakes, ceilings -bind. `resize` earns its place — and the sentence I'll reuse -elsewhere: a mutation method on a concurrency primitive without a -torture test is a data race with a friendly name. diff --git a/docs/perspectives/round-9/07-solnic.md b/docs/perspectives/round-9/07-solnic.md deleted file mode 100644 index 1af9785..0000000 --- a/docs/perspectives/round-9/07-solnic.md +++ /dev/null @@ -1,63 +0,0 @@ -# Round 9 field notes — Piotr Solnica derives the examples - -*Built: `examples/contract_fixtures.rb` — fixtures generated from -contract declarations (minimal and maximal per capability), proved -against their own validator, with a mutant to prove the validator -still has teeth.* - -## What I built and why - -Every README has an example payload, and every example payload is -lying by the second release. Not maliciously — entropically. The -contract moved and the prose didn't, because nothing *runs* the -prose. - -The fix follows from a principle this framework has been converging -on for six rounds: **anything declared can be derived from.** A -declaration rich enough to *reject* a payload is rich enough to -*construct* one: - -- `enum:` → its first member (`"air"`) -- `min:`/`max:` → the midpoint (`2500` — legal by construction, and - visibly *inside* the range rather than tremblingly on its edge) -- `required:` → membership in the minimal fixture -- everything else → a string that names its own key - -Two fixtures per capability: **minimal** (required keys only — the -smallest legal call, which is what a new integrator actually wants -to see) and **maximal** (every declared key — what a code generator -wants). Then the part that makes it an engineering artifact instead -of a convenience: the referee validates every generated fixture -against the same contract it came from, and then drops a required -key to prove the validator still rejects things. A generator proved -only against an accept-everything validator has proved nothing — -the mutant is what makes the green checkmarks mean something. - -Handwritten examples are promises; derived ones are consequences. - -## The blind spot, on the record - -`rules:` are predicates, and a generator cannot see inside a lambda. -A fixture that is legal per-field can still violate a cross-field -rule — my midpoint weight and first-enum mode could, in some -contract, be exactly the forbidden combination. The example prints -this caveat in its own output because a derivation tool that -overstates its coverage is worse than none. - -This is now the third tool to hit the same wall (Jeremy's prober -works around it dynamically, my semver advisor stated it, now the -generator inherits it), and the shape of the fix is visible: -structured rules already carry `fields:` and `message:`; if the -common cases also carried a machine-readable *relation* (`sum_lte:`, -`requires:`, `mutually_exclusive:`), the generator could satisfy -them and the advisor could diff them. Filing that as the round-10 -ask: **relation-typed structured rules** — keep the lambda escape -hatch, but let the declarable majority be declared. - -## Verdict - -The contract now produces its own documentation examples, and proves -them on every run. Third derivation tool this quarter from the same -metadata (docs, schemas, semver, now fixtures) — when declarations -keep compounding like this, the metadata design has paid for itself -several times over. diff --git a/docs/perspectives/round-9/08-mperham.md b/docs/perspectives/round-9/08-mperham.md deleted file mode 100644 index c4f0951..0000000 --- a/docs/perspectives/round-9/08-mperham.md +++ /dev/null @@ -1,64 +0,0 @@ -# Round 9 field notes — Mike Perham installs the breaker - -*Built: `examples/circuit_breaker.rb` — a closed/open/half-open -breaker in two acts: a 503 outage handled with three strikes, and a -revoked key handled with one.* - -## What I built and why - -Retries handle failures one call at a time. But an outage isn't one -call's problem — it's a *condition*, and treating a condition with -per-call optimism means every request pays the full timeout to -rediscover what the last request just learned. The breaker is the -missing memory: after enough strikes, stop asking. - -``` -act one: 503s, ticks 4-9 act two: 401, tick 2 onward - 4 failed (retryable: true) 2 failed (retryable: false) - 5 failed (retryable: true) - breaker TRIPS - 6 failed - breaker TRIPS 3 SKIPPED - 7 SKIPPED 4 SKIPPED - ... ... - 10 probe SUCCEEDED - closes 6 probe fails - TRIPS again -``` - -Act one is the textbook: three strikes, trip, eat the middle of the -outage as skips (nothing sent, nothing billed, no timeout waited -out), one probe to discover the recovery. Six ticks of outage, three -calls felt it. The gap is the money — and at LLM prices, the gap is -real money. - -## The verdict feeds the trip decision - -Act two is why this example belongs in round 9. Strike counts exist -because a 503 *might* pass — you give it three chances to be -transient. But the 401's journaled verdict says `retryable: false`: -the error itself testified that no retry can ever help. Giving that -error three strikes isn't caution, it's ritual. The breaker trips on -first contact, and each half-open probe re-trips on the same wall -until a human rotates the key. - -The breaker reads the verdict from the journal — recorded at write -time, this round's release — rather than re-deriving it from a -class-name table. Same argument as the dead letter office: the -moment of failure is when retryability is known most precisely; -everything derived later is reconstruction. - -## Notes - -- My first draft read `events.last[:retryable]` and got nil — the - last event after a failed run is `plan_completed`, which has no - verdict. The nil then hit the `retryable ? 1 : TRIP_AFTER` branch - and instantly tripped on a *transient* 503. Two lessons in one bug: - scan for the event you mean, and never let "no opinion" silently - mean "hopeless." A production breaker should treat a nil verdict - as retryable-with-suspicion, not as a death sentence. -- Half-open admits exactly one probe. Letting the whole queue through - on recovery is how you re-kill the thing that just got back up. - -## Verdict - -Retry policies answer "should THIS call try again?"; breakers answer -"should ANYONE?" With retryability journaled at the moment of -failure, both answers now come from the error's own testimony — -counted strikes for the maybes, an instant trip for the nevers. diff --git a/docs/perspectives/round-9/09-sandimetz.md b/docs/perspectives/round-9/09-sandimetz.md deleted file mode 100644 index 201117b..0000000 --- a/docs/perspectives/round-9/09-sandimetz.md +++ /dev/null @@ -1,68 +0,0 @@ -# Round 9 field notes — Sandi Metz counts the ducks - -*Built: `examples/duck_agents.rb` — one plan, five differently-shaped -agents through the single `agent:` seam: lambda, instance, Method -object, curried proc, and a decorator that wraps any of them.* - -## What I built and why - -Eight rounds of examples have passed lambdas to `agent:` so uniformly -that a reader could be forgiven for thinking the seam *requires* -lambdas. It doesn't, and the difference between "we always pass -lambdas" and "you must pass lambdas" is the difference between a -convention and a cage. I built the parade to prove the cage isn't -there: - -``` -fetch lambda -> {records: 12, ...} -dedupe instance with #call -> {unique: 9, pass: 1} -stats Method object -> {mean: 4.2, max: 9} -render curried proc -> {rendered: true, format: "html"} -audit decorator around a lambda -> {audited: true, timed_ms: 0.0} -``` - -The seam asks one question — *can you be called with a task?* — and -never asks anyone's class. So every shape that answers walks in, and -each shape earns its place for a different reason: - -- The **lambda** for logic too small to deserve a name. -- The **instance** when logic deserves a home — my `Deduper` carries - state (`pass: 1`) that a lambda would have to smuggle in a closure. -- The **Method object** when the logic already lives somewhere — - `Stats.method(:summarize)` joins the plan without a wrapper, which - means no wrapper to test. -- The **curried proc** for configuration applied ahead of time: - `render.curry["html"]` is dependency injection wearing a very small - hat. -- The **decorator** is the payoff of all of it: `Timed` consumes the - same one-message contract it provides, so it stacks around *any* of - the other four without knowing or caring which it got. - -## The design lesson - -Depend on messages, not classes, and your plugin API is every object -ever written — including the ones not written yet. The framework got -this right in a way worth naming precisely: `resolve_agent` checks -`respond_to?(:execute)` and otherwise wraps the callable. Two ducks -accepted, both by *capability*, neither by ancestry. There is no -`AgentBase` to inherit, and so there is no inheritance debt to -pre-borrow — the round-1 sin (`FactoryMethods` breaking under -subclassing) has stayed fixed at the seam that matters most. - -The decorator deserves one more sentence. That `Timed` works on all -five shapes isn't a feature anyone built; it's a *consequence* of the -narrow contract. Wide interfaces make decorators expensive (forward -everything!); one-message interfaces make them nine lines. If you -want composition, keep your contracts poor. - -## Notes - -- I resisted adding a sixth duck that type-checks its input, as a - cautionary contrast. The parade argues better without the clown. - -## Verdict - -The seam is honest: it asks for what it needs — one message — and -not for who you are. Five shapes, zero adapters, one nine-line -decorator that fits them all. That's what "design for change" looks -like when it's cheap. diff --git a/docs/perspectives/round-9/10-ankane.md b/docs/perspectives/round-9/10-ankane.md deleted file mode 100644 index 53edf74..0000000 --- a/docs/perspectives/round-9/10-ankane.md +++ /dev/null @@ -1,62 +0,0 @@ -# Round 9 field notes — Andrew Kane runs the shootout - -*Built: `examples/impl_shootout.rb` — two candidate implementations -of one capability, one eval set, and a two-axis verdict: accuracy -AND latency, on the same table.* - -## What I built and why - -Every capability eventually has a challenger: the regex that shipped -in an afternoon versus the subtler thing someone wrote on a weekend. -The upgrade decision then happens in a meeting, powered by whoever -has the freshest anecdote. I wanted the decision to be a table: - -``` -v1 regex accuracy 63% p50 2.1ms -v2 weights accuracy 100% p50 10.1ms -verdict: v2 wins 8/8 to 5/8 - and costs 5x the latency -``` - -The eval set is the referee, and the deciding cases share one shape: -"password reset email shows an error page" has one bug word and five -points of account evidence. v1 answers by *first match* — clause -order, an accident of code layout — and files it under bug. v2 -answers by *total evidence* and files it under account. That -difference is invisible in a demo and everywhere in production, -because production tickets are all like this: multi-topic, casually -worded, keyword-colliding. - -## Both axes or it's marketing - -The scoreboard refuses to report accuracy without latency. "v2 is -better" is not a finding; "v2 buys 3 more correct routes per 8 -tickets at 8ms each" is, because it's *deniable* — a team routing a -million tickets a day at tight budgets can look at the same table -and correctly choose v1. Benchmarks in my gems' READMEs follow the -same rule: publish the numbers that let someone reasonably choose -the competitor, or you've published an ad. - -Two implementation notes that carried weight: - -- v2's first draft matched exact words and *lost* the crash case — - "crashes" isn't "crash." Prefix-stem matching fixed it, which is - the eval set doing its actual job: catching the bug in the - challenger before the meeting where you propose it. -- A perfect 8/8 doesn't mean v2 is done; it means the eval set - **stopped discriminating**. The scoreboard says so in its own - output. Grow the set until your best candidate fails — a suite - your champion aces is a suite that's stopped asking questions. - -## Notes - -- The shootout composes with round 8's scorer seam: swap `correct:` - for a graded scorer and this same harness A/Bs LLM-backed - candidates, where "accuracy" becomes mean score and latency - becomes dollars. Nothing about the table changes shape. - -## Verdict - -Upgrade debates become table reads: two candidates, one eval set, -both axes visible. v2 wins this one on evidence-versus-clause-order, -and the latency price is printed next to the win so the choice stays -a choice. diff --git a/examples/README.md b/examples/README.md index 32ef18d..dcabed5 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,134 +6,8 @@ not this file. | Example | What it shows | |---------|---------------| -| `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... | -| `contract_fuzzer.rb` | The Contract Fuzzer: for every registered capability, generate inputs that SHOULD pass its declared contract and mutatio... | -| `contract_overhead.rb` | The Contract Overhead Bench: "should we validate every call?" is a performance question, so answer it with a measurement... | -| `contract_semver.rb` | The Contract Semver Advisor: two versions of a capability's contract, every change classified as breaking or compatible ... | -| `cost_estimator.rb` | The Cost Estimator: price the plan BEFORE running it - per-task token estimates times a pricing table - gate on budget, ... | -| `coupling_cartographer.rb` | The Coupling Cartographer: which files lean on which? Every file is surveyed for the constants it DEFINES and the consta... | -| `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!,... | -| `error_taxonomy_drill.rb` | The Error Taxonomy Drill: three tasks fail three different ways - a rate limit (retryable, says the error itself), an au... | -| `eval_scorers.rb` | Eval Scorers: the same eval set scored four ways - exact match, keyword containment, numeric tolerance, and a judge rubr... | -| `event_prof.rb` | EventProf for Plans: TestProf taught test suites to answer "where does the time GO?" by group, not by file. Same questio... | -| `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-... | -| `gem_scout.rb` | Gem Scout: describe what you need, get a ranked shortlist of gems. Search and scoring are separate capabilities; the sea... | -| `gentle_deprecations.rb` | Gentle Deprecations: the hard part of maintaining a framework isn't adding the better name - it's the two years of not b... | -| `graph_critic.rb` | The Graph Critic: reviews a plan's dependency structure BEFORE it runs, the way you'd review a class diagram. God tasks,... | -| `graph_invariants.rb` | The Graph Invariants Prover: the reflection API makes promises - order respects edges, roots have no dependencies, depth... | -| `graph_style.rb` | The Graph Style Guide: RuboCop for plans. Cops with thresholds run against any orchestrator's graph - depth, fan-in, orp... | -| `graph_to_specs.rb` | Graph to Specs: the plan's structure dictates its test plan - roots need fixture cases, joins need one case per missing ... | -| `haiku_agent.rb` | The three-line agent. Run me with no API key at all: | -| `hill_chart.rb` | The Hill Chart: Basecamp's answer to "how's it going?" - work climbs the hill while it's still uncertain (queued, waitin... | -| `honest_doubles.rb` | Honest Doubles: every fake LLM in every agent test suite is lying a little - the question is whether anyone checks. The ... | -| `hostile_inputs.rb` | Hostile Inputs: a parser's real spec is what it does with input nobody intended. The journal's replay parses a file that... | -| `impl_shootout.rb` | The Implementation Shootout: two candidates for the same capability, one eval set, and a verdict computed instead of vib... | -| `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... | -| `perf_diff.rb` | The Perf Diff: run the plan before and after a change, diff per-task durations, and flag regressions - with the one qual... | -| `perf_history.rb` | Perf History: last release's run left a journal; this release's run is compared against it. No synthetic baseline, no sa... | -| `performance_detective.rb` | The Performance Detective: one task per Ruby file in lib/, fanned out through the orchestrator, each dissecting a file f... | -| `plan_diagram.rb` | The Plan Diagrammer: any orchestrator's graph, emitted as Mermaid - paste it into a README, GitHub renders it, and the d... | -| `plan_dsl.rb` | The Plan DSL: Sinatra's whole argument was that an API is a user interface, and a user interface should read like what i... | -| `plan_flog.rb` | Plan Flog: flog gives every Ruby method a pain score; this gives every plan one. Fan-in hurts (joins hide coupling), dep... | -| `plan_forest.rb` | The Plan Forest: your graph drawn as a forest - roots at the soil, leaves in the canopy, every task planted at its depth... | -| `plan_fortune.rb` | The Plan Fortune Teller: reads your graph's palm - depth, fan-in, roots, breadth - and tells its fortune. Every fortune ... | -| `plan_gantt.rb` | The Plan Gantt: lifecycle hooks timestamp every task, then the run is rendered as an ASCII timeline - where your wall cl... | -| `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... | -| `relation_diff.rb` | The Relation Diff: round 8's semver advisor classified declaration changes but had to shrug at rules - lambdas can't be ... | -| `relation_prober.rb` | The Relation Prober: relation-typed rules are new, and new predicates deserve hostility. Each relation is probed with ed... | -| `renga_circle.rb` | A renga circle: three poet agents compose a linked-verse poem, each verse responding to the one before it. The dependenc... | -| `require_cost.rb` | The Require Cost Report: `require` is a purchase - memory, objects, and boot time, paid again by every process you fork ... | -| `resize_torture.rb` | The Resize Torture Test: a feature that changes a limiter's ceiling while fibers are waiting on it had better say exactl... | -| `retry_budget.rb` | The Retry Budget: a retry storm is a self-inflicted DDoS - every job politely retrying 3x turns one outage into four. Re... | -| `rule_prober.rb` | The Rule Prober: structured rules declare which fields they read - so now that claim can be AUDITED. For each rule, pert... | -| `rule_shapes.rb` | Rule Shapes: the same policy - "express shipments need a customs code" - written three ways: a lambda, a structured chec... | -| `schema_advisor.rb` | The Schema Advisor: give it a schema and a query log, get back the advisories a careful DBA would write - each rule its ... | -| `self_correcting_output.rb` | Self-Correcting Output: the pattern that makes LLM components shippable. The model's output is validated against the cap... | -| `setup_doctor.rb` | The Setup Doctor: every onboarding wiki page is a bug. This runs the checks a README asks a new hire to do by hand - rub... | -| `shared_rate_limit.rb` | The Shared Rate Limit: two plans run concurrently in one reactor, but the API key they share allows only 3 requests in f... | -| `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... | -| `threads_drill.rb` | The Threads Drill: fibers are polite; threads are not. Everything in this gem that claims to be shared-safe gets hammere... | -| `three_shapes.rb` | Three Shapes: the same six units of work arranged three ways - a chain, a star, and staged joins - then measured and cri... | -| `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/adaptive_throttle.rb b/examples/adaptive_throttle.rb deleted file mode 100644 index 4ea9680..0000000 --- a/examples/adaptive_throttle.rb +++ /dev/null @@ -1,82 +0,0 @@ -# frozen_string_literal: true - -# The Adaptive Throttle: nobody TELLS you an upstream's capacity - you -# discover it. An AIMD controller (TCP's algorithm) probes upward one -# lane at a time and halves on congestion, converging on the capacity -# the provider never documented. Watch the target find the truth. -# -# bundle exec ruby examples/adaptive_throttle.rb -# -# Runs offline; the upstream secretly handles 3 concurrent calls well. - -require_relative "../lib/agentic" -require "async" - -SECRET_CAPACITY = 3 -BASE_LATENCY = 0.02 -BATCHES = 12 -BATCH_SIZE = 6 - -# The upstream: fast until you exceed its capacity, then it degrades -in_flight = 0 -upstream = lambda do - in_flight += 1 - overload = [in_flight - SECRET_CAPACITY, 0].max - sleep(BASE_LATENCY * (1 + overload * 1.5)) - in_flight -= 1 -end - -# AIMD: additive increase, multiplicative decrease - steering ONE live -# limiter via resize, the same object the clients would share -target = 1 -limiter = Agentic::RateLimit.new(target) -history = [] -congestion_threshold = BASE_LATENCY * 1.6 - -puts "ADAPTIVE THROTTLE (upstream capacity: undisclosed; AIMD will find it)" -puts -puts format(" %-7s %-8s %-10s %-24s %s", "batch", "target", "p50", "", "action") - -Sync do - BATCHES.times do |batch| - limiter.resize(target) - latencies = [] - - BATCH_SIZE.times.map { - Async do - limiter.acquire do - started = Process.clock_gettime(Process::CLOCK_MONOTONIC) - upstream.call - latencies << Process.clock_gettime(Process::CLOCK_MONOTONIC) - started - end - end - }.each(&:wait) - - p50 = latencies.sort[latencies.size / 2] - congested = p50 > congestion_threshold - history << target - - action = if congested - new_target = [target / 2, 1].max - verdict = "congested -> halve to #{new_target}" - target = new_target - verdict - else - target += 1 - "healthy -> probe up to #{target}" - end - - puts format(" %-7d %-8d %6.1fms %-24s %s", - batch + 1, history.last, p50 * 1000, "#" * (history.last * 3), action) - end -end - -puts -settled = history.last(6) -puts format(" the controller oscillates around %.1f lanes - the upstream's", settled.sum.to_f / settled.size) -puts " secret capacity is #{SECRET_CAPACITY}. AIMD never saw that constant; it derived" -puts " it from latency alone, and it will re-derive it when the upstream" -puts " changes. static concurrency limits are a guess frozen at deploy" -puts " time; adaptive ones are a measurement that never stops. and the" -puts " controller steers ONE live RateLimit via resize - every client" -puts " sharing that limiter inherits each correction, mid-flight." diff --git a/examples/allocation_audit.rb b/examples/allocation_audit.rb deleted file mode 100644 index 65fb9f1..0000000 --- a/examples/allocation_audit.rb +++ /dev/null @@ -1,96 +0,0 @@ -# frozen_string_literal: true - -# The Allocation Audit: every object is a promissory note the GC -# collects on later. This audit counts exactly what each framework -# operation allocates (GC.stat's total_allocated_objects is an exact -# counter, not a sample) and where the GC actually runs during a -# plan. Latency spikes that "come from nowhere" come from here. -# -# bundle exec ruby examples/allocation_audit.rb -# -# Runs offline; counts are exact for this Ruby version. - -require_relative "../lib/agentic" - -Agentic.logger.level = :fatal - -def allocations(iterations = 100) - yield # warm: first call pays memoization, schema compilation, caches - GC.start - before = GC.stat(:total_allocated_objects) - iterations.times { yield } - (GC.stat(:total_allocated_objects) - before) / iterations -end - -def task_named(name) - Agentic::Task.new(description: name, agent_spec: {"name" => "w", "instructions" => "work"}) -end - -SPEC = Agentic::CapabilitySpecification.new( - name: "audit", description: "x", version: "1.0.0", - inputs: {mode: {type: "string", required: true, enum: %w[a b]}, - weight: {type: "number", required: true, min: 1, max: 100}}, - rules: {fits: {relation: :sum_lte, fields: [:weight], limit: 100}} -) -VALIDATOR = Agentic::CapabilityValidator.new(SPEC) - -def ten_task_orchestrator - orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 4) - previous = nil - 10.times do |i| - task = task_named("t#{i}") - orchestrator.add_task(task, previous ? [previous] : [], agent: ->(_t) { :ok }) - previous = task - end - orchestrator -end - -puts "ALLOCATION AUDIT (objects per operation, exact via GC.stat)" -puts - -rows = { - "Task.new" => -> { task_named("x") }, - "validator: happy path" => -> { VALIDATOR.validate_inputs!(mode: "a", weight: 50) }, - "validator: rejection" => -> { - begin - VALIDATOR.validate_inputs!(mode: "z", weight: 500) - rescue Agentic::Errors::ValidationError - nil - end - }, - "graph snapshot (10 tasks)" => -> { ten_task_orchestrator.graph }, - "to_json_schema" => -> { SPEC.to_json_schema } -} - -counts = rows.transform_values { |op| allocations(&op) } -counts.each do |label, objects| - puts format(" %-28s %6d objects %s", label, objects, "#" * [objects / 50, 40].min) -end - -# The graph row includes building the orchestrator - separate the two -build_only = allocations { ten_task_orchestrator } -graph_only = counts["graph snapshot (10 tasks)"] - build_only -puts format(" %-28s %6d objects (snapshot alone, build subtracted)", " ...graph, isolated", graph_only) - -# --- where the GC actually fires during a plan --------------------------------- -orchestrator = ten_task_orchestrator -GC.start -gc_before = GC.count -allocated_before = GC.stat(:total_allocated_objects) -orchestrator.execute_plan -plan_allocations = GC.stat(:total_allocated_objects) - allocated_before -gc_runs = GC.count - gc_before - -puts -puts format(" a full 10-task plan allocates %d objects and triggered %d GC run(s).", plan_allocations, gc_runs) -puts -per_call = counts["validator: happy path"] -puts " reading the audit like a VM person: the happy-path validation" -puts " (#{per_call} objects) is what you multiply by requests-per-second -" -puts " #{per_call} x 1000 rps is #{per_call * 1000} promissory notes a second, and the GC" -puts " collects on schedule whether you budgeted or not. rejection costs" -puts " #{counts["validator: rejection"] / per_call}x the happy path in objects (exceptions carry backtraces;" -puts " error paths are allocation paths), and the graph snapshot's" -puts " #{graph_only} objects of dup+freeze buy the immutability every round-8" -puts " tool leans on - that's not waste, that's a purchase. allocation" -puts " isn't evil; UNBUDGETED allocation is. now there's a budget." diff --git a/examples/always_on_profiler.rb b/examples/always_on_profiler.rb deleted file mode 100644 index 50623d8..0000000 --- a/examples/always_on_profiler.rb +++ /dev/null @@ -1,95 +0,0 @@ -# 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_reference.rb b/examples/api_reference.rb deleted file mode 100644 index f232d2d..0000000 --- a/examples/api_reference.rb +++ /dev/null @@ -1,111 +0,0 @@ -# frozen_string_literal: true - -# The API Reference Generator: walk the registry, emit reference docs -# for every capability - types, enums, bounds, policies - straight from -# the contracts that VALIDATE the calls. Documentation that enforces -# itself cannot lie about what it accepts. -# -# bundle exec ruby examples/api_reference.rb -# -# Runs offline; prints markdown. - -require_relative "../lib/agentic" - -registry = Agentic::AgentCapabilityRegistry.instance - -# Two capabilities as the "app": a transfer and a payout -transfer = Agentic::CapabilitySpecification.new( - name: "transfer_funds", - description: "Move money between accounts", - version: "1.2.0", - inputs: { - from_account: {type: "string", required: true, non_empty: true, description: "Source account id"}, - to_account: {type: "string", required: true, non_empty: true, description: "Destination account id"}, - amount_cents: {type: "number", required: true, min: 1, max: 10_000_000, description: "Amount in cents"}, - memo: {type: "string", description: "Optional statement memo"} - }, - outputs: { - transfer_id: {type: "string", required: true}, - settled: {type: "boolean", required: true} - }, - rules: { - no_self_transfer: { - message: "source and destination must differ", - fields: [:from_account, :to_account], - check: ->(i) { i[:from_account] != i[:to_account] } - } - } -) - -payout = Agentic::CapabilitySpecification.new( - name: "schedule_payout", - description: "Schedule a payout to a bank account", - version: "2.0.0", - inputs: { - amount_cents: {type: "number", required: true, min: 100}, - speed: {type: "string", required: true, enum: %w[standard instant]}, - currency: {type: "string", required: true, enum: %w[usd eur gbp]} - }, - outputs: {payout_id: {type: "string", required: true}}, - rules: { - instant_is_domestic: { - message: "instant payouts support usd only", - fields: [:speed, :currency], - check: ->(i) { i[:speed] != "instant" || i[:currency] == "usd" } - } - } -) - -[transfer, payout].each do |spec| - Agentic.register_capability(spec, Agentic::CapabilityProvider.new( - capability: spec, implementation: ->(_i) { {} } - )) -end - -# --- the generator: registry in, markdown out -------------------------------- -def constraint_notes(declaration) - notes = [] - notes << "one of: #{declaration[:enum].join(", ")}" if declaration[:enum] - notes << ">= #{declaration[:min]}" if declaration[:min] - notes << "<= #{declaration[:max]}" if declaration[:max] - notes << "non-empty" if declaration[:non_empty] - notes.join("; ") -end - -def field_table(declared) - rows = declared.map { |name, decl| - required = decl[:required] ? "yes" : "no" - format("| `%s` | %s | %s | %s | %s |", - name, decl[:type], required, constraint_notes(decl), decl[:description] || "") - } - ["| Field | Type | Required | Constraints | Description |", - "|-------|------|----------|-------------|-------------|"] + rows -end - -def reference_for(spec) - doc = ["## `#{spec.name}` v#{spec.version}", "", spec.description, ""] - doc << "### Inputs" - doc += field_table(spec.inputs) - unless spec.rules.empty? - doc << "" - doc << "### Policies" - spec.rules.each do |rule_id, rule| - doc << "- **#{rule_id}** (checks #{rule[:fields].map { |f| "`#{f}`" }.join(", ")}): #{rule[:message]}" - end - end - unless spec.outputs.empty? - doc << "" - doc << "### Outputs" - doc += field_table(spec.outputs) - end - doc.join("\n") -end - -puts "# API Reference" -puts -puts "_Generated from the same contracts that validate every call._" -puts -%w[transfer_funds schedule_payout].each do |name| - puts reference_for(registry.get(name)) - puts -end diff --git a/examples/api_riffs.rb b/examples/api_riffs.rb deleted file mode 100644 index 40a32c1..0000000 --- a/examples/api_riffs.rb +++ /dev/null @@ -1,86 +0,0 @@ -# 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/api_surface.rb b/examples/api_surface.rb deleted file mode 100644 index df9e744..0000000 --- a/examples/api_surface.rb +++ /dev/null @@ -1,66 +0,0 @@ -# frozen_string_literal: true - -# The API Surface Census: your public API is not what you documented - -# it's every public method a user CAN call, because that's what semver -# binds you to. This census counts the whole surface, then -# cross-references 100 example programs to split it into the API -# people actually use and the accidental API nobody asked for but -# everyone can break themselves against. -# -# bundle exec ruby examples/api_surface.rb -# -# Runs offline; the examples directory is the usage corpus. - -require_relative "../lib/agentic" - -Agentic.logger.level = :fatal - -# Load everything so the census sees the whole surface -Zeitwerk::Registry.loaders.each(&:eager_load) if defined?(Zeitwerk::Registry) - -CORE = [ - Agentic::PlanOrchestrator, Agentic::Task, Agentic::ExecutionJournal, - Agentic::RateLimit, Agentic::CapabilitySpecification, Agentic::CapabilityValidator, - Agentic::CapabilityProvider, Agentic::TaskFailure, Agentic::TaskResult, - Agentic::PlanExecutionResult, Agentic::RelationRules -].freeze - -corpus = Dir[File.join(__dir__, "*.rb")].reject { |f| f.end_with?("api_surface.rb") } - .map { |f| File.read(f, encoding: "UTF-8") }.join("\n") - -puts "API SURFACE CENSUS (#{CORE.size} core classes vs #{Dir[File.join(__dir__, "*.rb")].size - 1} example programs)" -puts -puts format(" %-26s %-9s %-11s %s", "class", "surface", "exercised", "accidental (public, unused by any example)") - -total_surface = 0 -total_exercised = 0 -accidental_all = [] -CORE.each do |klass| - # Owner-checked: only methods this class itself defines count as ITS - # surface - inherited Object/Psych noise is someone else's ledger - methods = (klass.public_instance_methods(false) + - klass.singleton_class.public_instance_methods(false).select { |m| - klass.singleton_class.instance_method(m).owner == klass.singleton_class - }).uniq.reject { |m| m.to_s.start_with?("_") } - used, unused = methods.partition { |m| corpus.match?(/\.#{Regexp.escape(m.to_s.chomp("?").chomp("!"))}\b/) || corpus.include?(".#{m}") } - total_surface += methods.size - total_exercised += used.size - accidental_all.concat(unused.map { |m| "#{klass.name.split("::").last}##{m}" }) - puts format(" %-26s %-9d %-11d %s", - klass.name.split("::").last, methods.size, used.size, unused.take(3).join(", ")) -end - -puts -puts format(" total public surface: %d methods; %d (%.0f%%) exercised by the corpus.", - total_surface, total_exercised, total_exercised * 100.0 / total_surface) -puts -puts " reading the census like a steward: the exercised set is your REAL" -puts " API - 100 programs voted with their call sites, and every one of" -puts " those methods now carries a semver promise whether the docs say" -puts " so or not. the accidental set (#{accidental_all.size} methods) is surface you're" -puts " paying interest on without collecting rent: each is a thing a" -puts " user could couple to tomorrow, constraining refactors forever." -puts " the move isn't deletion - it's DECLARATION: mark them @api" -puts " private (or make them private) while nobody depends on them," -puts " because the day after somebody does, they're yours for a major" -puts " version. public-by-default is a loan; the census is the bill." diff --git a/examples/attachment_pipeline.rb b/examples/attachment_pipeline.rb deleted file mode 100644 index 645e276..0000000 --- a/examples/attachment_pipeline.rb +++ /dev/null @@ -1,103 +0,0 @@ -# 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/backoff_conformance.rb b/examples/backoff_conformance.rb deleted file mode 100644 index 08de191..0000000 --- a/examples/backoff_conformance.rb +++ /dev/null @@ -1,85 +0,0 @@ -# frozen_string_literal: true - -# Backoff Conformance: every strategy x jitter combination, a thousand -# draws each through an injected seeded RNG, checked against the -# documented bounds. Retry timing is a contract like any other - and -# now that rng: is injectable, the contract is testable without -# stubbing a single method. -# -# bundle exec ruby examples/backoff_conformance.rb [seed] -# -# Runs offline and deterministically. Exit 1 on any bound violation. - -require_relative "../lib/agentic" - -seed = (ARGV.first || 20260707).to_i -DRAWS = 1_000 -BASE = 1.0 -RETRY_COUNT = 3 - -# The documented bounds for each (strategy, jitter) combination -def expected_bounds(strategy, jitter, retry_count) - nominal = case strategy - when :constant then BASE - when :linear then retry_count * BASE - when :exponential then BASE * (2**(retry_count - 1)) - end - - case jitter - when false then [nominal, nominal] - when true then [nominal * 0.75, nominal * 1.25] - when :full then [0.0, nominal] - end -end - -failures = [] -puts "BACKOFF CONFORMANCE (seed #{seed}, #{DRAWS} draws per combination)" -puts -puts format(" %-13s %-8s %-22s %-22s %s", "strategy", "jitter", "expected", "observed", "verdict") - -%i[constant linear exponential].each do |strategy| - [false, true, :full].each do |jitter| - rng = Random.new(seed) - orchestrator = Agentic::PlanOrchestrator.new(retry_policy: { - backoff_strategy: strategy, - backoff_constant: BASE, - backoff_base: BASE, - backoff_jitter: jitter, - rng: rng - }) - - observed = [] - orchestrator.define_singleton_method(:sleep) { |delay| observed << delay } - - task = Agentic::Task.new(description: "t", agent_spec: {"name" => "t", "instructions" => "t"}) - task.retry_count = RETRY_COUNT - DRAWS.times { orchestrator.apply_retry_backoff(task: task) } - - low, high = expected_bounds(strategy, jitter, RETRY_COUNT) - epsilon = 1e-9 - in_bounds = observed.all? { |d| d >= low - epsilon && d <= high + epsilon } - spans = jitter == false || (observed.max - observed.min) > (high - low) * 0.8 - - verdict = if !in_bounds - failures << [strategy, jitter, :bounds] - "OUT OF BOUNDS (#{observed.min.round(3)}..#{observed.max.round(3)})" - elsif !spans - failures << [strategy, jitter, :coverage] - "poor coverage" - else - "conforms" - end - - puts format(" %-13s %-8s [%6.3f, %6.3f] [%6.3f, %6.3f] %s", - strategy, jitter.inspect, low, high, observed.min, observed.max, verdict) - end -end - -puts -if failures.empty? - puts " every combination stayed inside its documented envelope AND" - puts " explored at least 80% of it. the timing contract holds." -else - puts " CONTRACT VIOLATIONS: #{failures.inspect}" - exit 1 -end diff --git a/examples/batch_import.rb b/examples/batch_import.rb deleted file mode 100644 index 1a80702..0000000 --- a/examples/batch_import.rb +++ /dev/null @@ -1,89 +0,0 @@ -# frozen_string_literal: true - -# The Batch Import: 500 rows of the kind of data people actually -# upload - typos, header drift, impossible combinations - run through -# one contract. Good rows proceed; bad rows land in a REJECT FILE -# with the field, the reason, and the rule that caught them. An -# importer that raises on row 37 is a tool for importing 36 rows. -# -# bundle exec ruby examples/batch_import.rb -# -# Runs offline; the dirty data is seeded and repeatable. - -require_relative "../lib/agentic" -require "json" - -CONTRACT = Agentic::CapabilitySpecification.new( - name: "import_shipment", description: "One row of the shipments upload", version: "1.0.0", - inputs: { - mode: {type: "string", required: true, enum: %w[air sea road]}, - weight: {type: "number", required: true, min: 1, max: 5_000}, - volume: {type: "number", min: 0}, - express: {type: "boolean"}, - customs_code: {type: "string"} - }, - rules: { - fits: {relation: :sum_lte, fields: [:weight, :volume], limit: 6_000}, - customs: {relation: :requires, fields: [:express, :customs_code]} - } -) - -# 500 rows, seeded: roughly three-quarters clean, the rest wrong in -# the ways uploads actually are -rng = Random.new(20_260_707) -ROWS = 500.times.map do |i| - row = { - mode: %w[air sea road].sample(random: rng), - weight: rng.rand(1..4_000), - volume: rng.rand(0..1_500) - } - row[:express] = true if rng.rand < 0.25 - row[:customs_code] = "HS-#{rng.rand(100)}" if row[:express] && rng.rand < 0.7 - case rng.rand - when 0..0.03 then row[:mode] = "trian" # typo - when 0.03..0.06 then row[:weight] = 0 # zero weight - when 0.06..0.09 then row[:weight] = rng.rand(5_001..9_000) # too heavy - when 0.09..0.12 then row[:volume] = rng.rand(4_000..8_000) # breaks the sum rule - when 0.12..0.14 then row.delete(:mode) # header drift - end - row -end - -validator = Agentic::CapabilityValidator.new(CONTRACT) -accepted = [] -rejects = [] - -started = Process.clock_gettime(Process::CLOCK_MONOTONIC) -ROWS.each_with_index do |row, index| - validator.validate_inputs!(row) - accepted << row -rescue Agentic::Errors::ValidationError => e - reasons = e.violations.except(:base).map { |field, msgs| "#{field}: #{msgs.first}" } - reasons += e.rule_violations.map { |v| "#{v[:rule]}: #{v[:message]}" } - rejects << {line: index + 2, reasons: reasons} # +2: 1-based plus header row -end -elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started - -puts "BATCH IMPORT (#{ROWS.size} rows through one contract in #{(elapsed * 1000).round}ms)" -puts -puts " accepted: #{accepted.size} rejected: #{rejects.size}" -puts - -by_reason = rejects.flat_map { |r| r[:reasons].map { |reason| reason[/\A[^:]+/] } }.tally -puts " reject file, summarized by cause:" -by_reason.sort_by { |_, count| -count }.each do |cause, count| - puts format(" %-14s %-3d %s", cause, count, "#" * count) -end -puts -puts " first three lines of the reject file (the thing support actually opens):" -rejects.first(3).each do |reject| - puts " line #{reject[:line]}: #{reject[:reasons].join("; ")}" -end -puts -puts " #{ROWS.size} rows cost #{(elapsed * 1000).round}ms of validation - #{format("%.2f", elapsed / ROWS.size * 1_000_000)}us a row - and" -puts " every rejection names its line, its field, and its rule, including" -puts " the cross-field ones (\"fits\", \"customs\") no per-column check" -puts " catches. two design rules for importers: never raise on row 37" -puts " (collect, don't crash), and never write \"invalid row\" (a reject" -puts " file without reasons is a support ticket generator). the contract" -puts " supplied both for free." diff --git a/examples/behavior_spec.rb b/examples/behavior_spec.rb deleted file mode 100644 index ad66a1b..0000000 --- a/examples/behavior_spec.rb +++ /dev/null @@ -1,116 +0,0 @@ -# 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/burst_absorber.rb b/examples/burst_absorber.rb deleted file mode 100644 index f7d98f9..0000000 --- a/examples/burst_absorber.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -# The Burst Absorber: three waves of requests slam a credential with a -# ceiling of 3 (Agentic::RateLimit - this round's release). The ceiling -# holds, the queue absorbs, and the per-request wait times show exactly -# what "absorbed" costs. Capacity planning is reading this table. -# -# bundle exec ruby examples/burst_absorber.rb -# -# Runs offline; the upstream is sleep. - -require_relative "../lib/agentic" -require "async" - -CEILING = 3 -CALL_TIME = 0.05 -WAVES = [6, 2, 9].freeze # requests arriving together, 120ms apart - -limit = Agentic::RateLimit.new(CEILING) -waits = Hash.new { |h, k| h[k] = [] } - -puts "BURST ABSORBER: ceiling #{CEILING}, calls take #{(CALL_TIME * 1000).round}ms" -puts - -Sync do |task| - arrivals = [] - WAVES.each_with_index do |count, wave| - arrivals << task.async do - sleep(wave * 0.12) # the wave arrives - count.times.map { |i| - task.async do - arrived = Process.clock_gettime(Process::CLOCK_MONOTONIC) - limit.acquire do - waits[wave] << Process.clock_gettime(Process::CLOCK_MONOTONIC) - arrived - sleep(CALL_TIME) - end - end - }.each(&:wait) - end - end - arrivals.each(&:wait) -end - -WAVES.each_with_index do |count, wave| - wave_waits = waits[wave].sort - puts format(" wave %d: %d requests wait p50 %3dms worst %3dms", - wave + 1, count, wave_waits[wave_waits.size / 2] * 1000, wave_waits.last * 1000) -end - -puts -puts format(" high-water mark: %d of %d - the ceiling held through every burst", limit.high_water, CEILING) -puts -puts "wave 1 (6 into 3 slots) queues; wave 2 (2 requests) sails through;" -puts "wave 3 (9 at once) pays the real price. the ceiling converts" -puts "provider 429s into local queueing - visible, measurable, bounded." diff --git a/examples/cancel_drill.rb b/examples/cancel_drill.rb deleted file mode 100644 index ef8a051..0000000 --- a/examples/cancel_drill.rb +++ /dev/null @@ -1,114 +0,0 @@ -# frozen_string_literal: true - -# The Cancel Drill: structured concurrency's core promise is that -# cancellation is PROMPT - stop means stop, not "finish everything -# and then agree you'd stopped". Three drills measure what each -# cancel path actually delivers. In round 10 this drill caught -# plan-wide cancel billing for every canceled task; the round-11 -# release fixed it, and this file is the acceptance test that keeps -# it fixed. -# -# bundle exec ruby examples/cancel_drill.rb -# -# Runs offline; every claim below is a measurement. - -require_relative "../lib/agentic" -require "async" - -Agentic.logger.level = :fatal - -JOB = 0.1 - -def build(count, agent_runs) - orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 2) - epoch = Process.clock_gettime(Process::CLOCK_MONOTONIC) - tasks = count.times.map { |i| - Agentic::Task.new(description: "job#{i}", agent_spec: {"name" => "w", "instructions" => "w"}) - } - tasks.each do |task| - orchestrator.add_task(task, agent: ->(_t) { - agent_runs << [task.description, Process.clock_gettime(Process::CLOCK_MONOTONIC) - epoch] - sleep(JOB) - :ok - }) - end - [orchestrator, tasks] -end - -def states(orchestrator) - orchestrator.instance_variable_get(:@execution_state).transform_values(&:size) - .reject { |_, v| v.zero? }.map { |k, v| "#{v} #{k}" }.join(", ") -end - -puts "CANCEL DRILL (6 jobs of #{(JOB * 1000).to_i}ms, 2 lanes; full plan = 300ms)" -puts - -# --- drill 1: surgical cancel of one IN-FLIGHT task --------------------------- -runs = [] -orchestrator, tasks = build(6, runs) -elapsed = nil -Sync do - runner = Async { orchestrator.execute_plan } - Async do - sleep(0.03) # job0 and job1 are mid-sleep - orchestrator.cancel_task(tasks[0].id) - end - started = Process.clock_gettime(Process::CLOCK_MONOTONIC) - runner.wait - elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started -end -third_start = (runs[2][1] * 1000).round -puts " drill 1 - cancel ONE in-flight task (at 30ms):" -puts " #{states(orchestrator)}; plan finished in #{(elapsed * 1000).round}ms" -puts " the proof the lane was freed is WHEN the next job started:" -puts " #{runs[2][0]} began at #{third_start}ms - on the canceled fiber's lane," -puts " immediately - not at 100ms when that job would have finished." -puts - -# --- drill 2: surgical cancel of one PENDING task ------------------------------ -runs2 = [] -orchestrator2, tasks2 = build(6, runs2) -Sync do - runner = Async { orchestrator2.execute_plan } - Async do - sleep(0.03) - orchestrator2.cancel_task(tasks2[5].id) # still queued behind the lanes - end - runner.wait -end -puts " drill 2 - cancel ONE pending task:" -puts " #{states(orchestrator2)}; agents actually ran: #{runs2.size}/6" -puts " the canceled job never started and never billed. queued work" -puts " canceled is money returned." -puts - -# --- drill 3: cancel_plan mid-flight ------------------------------------------- -runs3 = [] -orchestrator3, = build(6, runs3) -flip_ms = wall = nil -Sync do - started = Process.clock_gettime(Process::CLOCK_MONOTONIC) - runner = Async { orchestrator3.execute_plan } - Async do - sleep(0.03) - orchestrator3.cancel_plan - flip_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round - end - runner.wait - wall = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started -end -puts " drill 3 - cancel_plan at 30ms:" -puts " status flipped to :canceled by #{flip_ms}ms, the plan returned in #{(wall * 1000).round}ms," -puts " and only #{runs3.size}/6 agents ever ran (#{states(orchestrator3)}) - the two that" -puts " were mid-flight when the order came. before round 11 this row" -puts " read \"301ms, 6/6 agents executing\": every task reported" -puts " :canceled while every agent ran to completion and billed." -puts -puts " the drill's verdict, updated: all three cancel paths now keep" -puts " the structured-concurrency promise. the round-11 fix stops the" -puts " fibers instead of the reactor handle - and does its bookkeeping" -puts " FIRST, because stopping a fiber frees its slot and synchronously" -puts " admits the next waiter, which must already read as canceled in" -puts " that instant. cancellation is a race against your own scheduler;" -puts " this drill is the finish-line camera, and it stays in the repo" -puts " so the race gets re-run on every change." diff --git a/examples/capability_evals.rb b/examples/capability_evals.rb deleted file mode 100644 index 20f8efb..0000000 --- a/examples/capability_evals.rb +++ /dev/null @@ -1,94 +0,0 @@ -# frozen_string_literal: true - -# Capability Evals: golden test cases run against registered -# capabilities, scored, and gated. When you swap a lambda for an LLM -# (or one model for another), the eval suite is what tells you whether -# quality moved - contracts check shape, evals check SUBSTANCE. -# -# bundle exec ruby examples/capability_evals.rb -# -# Runs offline; one capability has a bug the evals catch. - -require_relative "../lib/agentic" - -def capability(name, inputs:, outputs:, &impl) - spec = Agentic::CapabilitySpecification.new( - name: name, description: name, version: "1.0.0", inputs: inputs, outputs: outputs - ) - Agentic.register_capability( - spec, Agentic::CapabilityProvider.new(capability: spec, implementation: impl) - ) -end - -capability("classify_sentiment", - inputs: {text: {type: "string", required: true}}, - outputs: {label: {type: "string", required: true, enum: %w[positive negative neutral]}}) do |i| - text = i[:text].downcase - label = if text.match?(/love|great|excellent|happy/) - "positive" - elsif text.match?(/hate|terrible|awful|angry/) - "negative" - else - "neutral" - end - {label: label} -end - -capability("extract_amount", - inputs: {text: {type: "string", required: true}}, - outputs: {cents: {type: "number", required: true}}) do |i| - # The bug: parses dollars but drops the cents - dollars = i[:text][/\$(\d+)/, 1].to_i - {cents: dollars * 100} -end - -EVALS = { - "classify_sentiment" => [ - {input: {text: "I love this gem"}, expect: {label: "positive"}}, - {input: {text: "This is terrible"}, expect: {label: "negative"}}, - {input: {text: "It runs on Ruby 3.3"}, expect: {label: "neutral"}}, - {input: {text: "absolutely EXCELLENT work"}, expect: {label: "positive"}} - ], - "extract_amount" => [ - {input: {text: "invoice total $45"}, expect: {cents: 4500}}, - {input: {text: "you owe $12.50 by friday"}, expect: {cents: 1250}}, - {input: {text: "refund of $0.99 issued"}, expect: {cents: 99}} - ] -}.freeze - -registry = Agentic::AgentCapabilityRegistry.instance -THRESHOLD = 0.9 - -puts "CAPABILITY EVALS (pass threshold #{(THRESHOLD * 100).round}%)" -puts - -overall = [] -EVALS.each do |name, cases| - provider = registry.get_provider(name) - results = cases.map do |eval_case| - actual = provider.execute(eval_case[:input]) - passed = eval_case[:expect].all? { |key, expected| actual[key] == expected } - [eval_case, actual, passed] - end - - passed = results.count { |_, _, ok| ok } - rate = passed.to_f / cases.size - overall << rate - puts format(" %-20s %d/%d (%3.0f%%) %s", name, passed, cases.size, rate * 100, - (rate >= THRESHOLD) ? "" : "BELOW THRESHOLD") - - results.reject { |_, _, ok| ok }.each do |eval_case, actual, _| - puts " FAIL #{eval_case[:input][:text].inspect}" - puts " expected #{eval_case[:expect].inspect}, got #{actual.inspect}" - end -end - -puts -score = overall.sum / overall.size -puts format(" suite score: %.0f%%", score * 100) -if score < THRESHOLD - puts " the gate holds: extract_amount drops cents - a shape-valid," - puts " substance-wrong answer that no contract could catch. contracts" - puts " check types; evals check truth. you need both." - exit 1 -end diff --git a/examples/capability_resolver.rb b/examples/capability_resolver.rb deleted file mode 100644 index 9589c1f..0000000 --- a/examples/capability_resolver.rb +++ /dev/null @@ -1,108 +0,0 @@ -# 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/capacity_planner.rb b/examples/capacity_planner.rb deleted file mode 100644 index 39023b5..0000000 --- a/examples/capacity_planner.rb +++ /dev/null @@ -1,83 +0,0 @@ -# frozen_string_literal: true - -# The Capacity Planner: "how many workers do we need?" is not a -# feeling, it's Little's Law - L = lambda x W. The journal already -# holds W (task durations, as percentiles across runs); give the -# planner your target arrival rate and it computes the lanes, then -# checks the answer against every limit you've configured - because -# the binding constraint is usually not the one in the meeting. -# -# bundle exec ruby examples/capacity_planner.rb -# -# Runs offline; history is seeded into a journal first. - -require_relative "../lib/agentic" -require "tmpdir" - -Agentic.logger.level = :fatal - -# --- build history: 30 journaled runs of the pipeline --------------------------- -JOURNAL = File.join(Dir.tmpdir, "agentic_capacity.journal.jsonl") -File.delete(JOURNAL) if File.exist?(JOURNAL) -journal = Agentic::ExecutionJournal.new(path: JOURNAL) -rng = Random.new(1123) - -30.times do - {"fetch:ticket" => 0.08, "classify" => 0.35, "draft:reply" => 0.9}.each do |name, base| - # log-normal-ish: mostly base, occasionally 2-3x - like real latency - duration = base * (0.8 + rng.rand(0.4)) * ((rng.rand < 0.12) ? (2 + rng.rand) : 1) - journal.record(:task_succeeded, task_id: name, description: name, duration: duration.round(4), output: nil) - end -end - -state = Agentic::ExecutionJournal.replay(path: JOURNAL) - -TARGET_PER_MINUTE = 120 # tickets per minute at peak -CONFIGURED = { - "orchestrator concurrency_limit" => 8, - "provider quota (windowed)" => "90/min", - "connection pool ceiling" => 12 -}.freeze - -puts "CAPACITY PLANNER (target: #{TARGET_PER_MINUTE} tickets/min at peak)" -puts -puts format(" %-16s %-10s %-10s %-22s %s", "task", "p50", "p95", "lanes needed (p50/p95)", "") - -lambda_per_sec = TARGET_PER_MINUTE / 60.0 -total_p95_lanes = 0 -state.duration_samples.keys.each do |task| - p50 = state.duration_percentile(task, 50) - p95 = state.duration_percentile(task, 95) - # Little's Law: concurrent-in-service L = arrival rate x service time - lanes_p50 = (lambda_per_sec * p50).ceil - lanes_p95 = (lambda_per_sec * p95).ceil - total_p95_lanes += lanes_p95 - puts format(" %-16s %6.0fms %6.0fms %2d / %-2d %s", - task, p50 * 1000, p95 * 1000, lanes_p50, lanes_p95, "#" * lanes_p95) -end - -puts -puts " plan for p95, not p50: capacity sized to the median is capacity" -puts " that queues every time latency has a bad day, and latency has a" -puts " bad day 1 run in 8 in this journal. total lanes at p95: #{total_p95_lanes}." -puts - -# --- check the plan against every configured limit ------------------------------- -puts " the plan vs. what's actually configured:" -puts format(" %-36s %-8s %s", "limit", "have", "verdict at #{TARGET_PER_MINUTE}/min") -verdicts = { - "orchestrator concurrency_limit" => (total_p95_lanes <= 8) ? "holds" : "BINDS FIRST - raise to #{total_p95_lanes}", - "provider quota (windowed)" => (TARGET_PER_MINUTE <= 90) ? "holds" : "BINDS - 90/min < #{TARGET_PER_MINUTE}/min arrivals, queues grow without bound", - "connection pool ceiling" => (total_p95_lanes <= 12) ? "holds" : "BINDS - #{total_p95_lanes} lanes want connections" -} -CONFIGURED.each do |name, have| - puts format(" %-36s %-8s %s", name, have, verdicts[name]) -end - -puts -puts " the meeting was about to argue concurrency_limit; the math says" -puts " the provider QUOTA binds first - 90/min against 120/min arrivals" -puts " isn't a slowdown, it's an unbounded queue (utilization > 1 has" -puts " no steady state). fix the quota; the #{total_p95_lanes} lanes and the pool" -puts " already hold. Little's Law plus a journal is a capacity plan;" -puts " a dashboard plus a feeling is a postmortem." diff --git a/examples/changelog_scout.rb b/examples/changelog_scout.rb deleted file mode 100644 index afd2f4a..0000000 --- a/examples/changelog_scout.rb +++ /dev/null @@ -1,82 +0,0 @@ -# frozen_string_literal: true - -# The Changelog Scout: reads real git history, classifies every commit -# through a contract-checked capability, and drafts the release notes - -# features first, fixes second, docs summarized in one line. -# -# bundle exec ruby examples/changelog_scout.rb [commit_count] -# -# Runs offline against the current repo. Swap the classifier lambda for -# an LLM client when you want prose instead of parsing - the contract -# stays identical. - -require_relative "../lib/agentic" - -ROOT = File.expand_path("..", __dir__) -count = (ARGV.first || 40).to_i - -# --- the classifier: one commit in, one classified entry out --------------- -spec = Agentic::CapabilitySpecification.new( - name: "classify_commit", - description: "Classify one commit subject for release notes", - version: "1.0.0", - inputs: {subject: {type: "string", required: true}}, - outputs: { - kind: {type: "string", required: true}, - note: {type: "string", required: true}, - breaking: {type: "boolean", required: true} - } -) -Agentic.register_capability(spec, Agentic::CapabilityProvider.new( - capability: spec, - implementation: ->(inputs) { - subject = inputs[:subject] - kind = subject[/\A(feat|fix|docs|refactor|test|chore)/, 1] || "other" - note = subject.sub(/\A\w+(\([^)]*\))?!?:\s*/, "").sub(/\A(.)/) { $1.upcase } - {kind: kind, note: note, breaking: subject.include?("!:")} - } -)) - -scribe = Agentic::Agent.build { |a| a.name = "Scribe" } -scribe.add_capability("classify_commit") - -# --- the plan: classify commits in parallel, then one writer fans in -------- -subjects = `git -C #{ROOT} log -#{count} --pretty=format:%s`.lines.map(&:strip) - -orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 8) -classifications = subjects.map.with_index do |subject, i| - task = Agentic::Task.new( - description: "commit #{i + 1}", - agent_spec: {"name" => "Scribe", "instructions" => "classify"}, - payload: subject - ) - orchestrator.add_task(task, agent: ->(t) { - scribe.execute_capability("classify_commit", {subject: t.payload}) - }) - task -end - -notes = Agentic::Task.new( - description: "release notes", - agent_spec: {"name" => "Editor", "instructions" => "draft the notes"} -) -orchestrator.add_task(notes, classifications, agent: ->(t) { - entries = classifications.map { |c| t.output_of(c) } - grouped = entries.group_by { |e| e[:kind] } - - sections = [] - sections << "## Breaking\n" + entries.select { |e| e[:breaking] }.map { |e| "- #{e[:note]}" }.join("\n") if entries.any? { |e| e[:breaking] } - {"feat" => "## Features", "fix" => "## Fixes", "refactor" => "## Internals"}.each do |kind, heading| - items = grouped[kind] or next - sections << "#{heading}\n#{items.map { |e| "- #{e[:note]}" }.join("\n")}" - end - quiet = grouped.slice("docs", "test", "chore", "other").values.flatten.size - sections << "_...plus #{quiet} documentation, test, and housekeeping commits._" if quiet.positive? - sections.join("\n\n") -}) - -result = orchestrator.execute_plan - -puts "RELEASE NOTES (last #{subjects.size} commits, drafted in #{(result.execution_time * 1000).round}ms)" -puts "=" * 60 -puts result.results[notes.id].output diff --git a/examples/circuit_breaker.rb b/examples/circuit_breaker.rb deleted file mode 100644 index 67e4a9d..0000000 --- a/examples/circuit_breaker.rb +++ /dev/null @@ -1,137 +0,0 @@ -# frozen_string_literal: true - -# The Circuit Breaker: when an upstream is down, the cheapest request -# is the one you don't send. The breaker trips after 3 consecutive -# retryable failures (or ONE non-retryable - no auth error deserves a -# second strike), fast-fails while open, and probes with a single -# request before closing again. Every skipped call is money. -# -# bundle exec ruby examples/circuit_breaker.rb -# -# Runs offline; act one is a 503 outage, act two a revoked key. - -require_relative "../lib/agentic" -require "tmpdir" - -Agentic.logger.level = :fatal - -# A breaker is a tiny state machine: closed -> open -> half_open -> closed -class Breaker - TRIP_AFTER = 3 - COOLDOWN_TICKS = 4 - - attr_reader :state, :skipped - - def initialize - @state = :closed - @strikes = 0 - @cooldown = 0 - @skipped = 0 - end - - def allow? - case @state - when :closed then true - when :open - @cooldown -= 1 - if @cooldown <= 0 - @state = :half_open - true # the probe - else - @skipped += 1 - false - end - when :half_open then true - end - end - - def record_success - @state = :closed - @strikes = 0 - end - - # The framework's nil convention (TaskFailure#hopeless?): only an - # EXPLICIT false verdict trips instantly. An error that expressed no - # opinion gets a strike - suspicion, not a death sentence. - def record_failure(verdict) - hopeless = verdict == false - @strikes += hopeless ? TRIP_AFTER : 1 - return unless @strikes >= TRIP_AFTER || @state == :half_open - - @state = :open - @strikes = 0 - @cooldown = COOLDOWN_TICKS - end -end - -def run_scenario(name, ticks, journal_path, &upstream) - File.delete(journal_path) if File.exist?(journal_path) - journal = Agentic::ExecutionJournal.new(path: journal_path) - breaker = Breaker.new - - puts " #{name}" - puts format(" %-6s %-11s %s", "tick", "breaker", "what happened") - - ticks.times do |tick| - unless breaker.allow? - puts format(" %-6d %-11s call SKIPPED - not sent, not billed, not waited on", tick, breaker.state) - next - end - - probe = breaker.state == :half_open - orchestrator = Agentic::PlanOrchestrator.new( - lifecycle_hooks: journal.lifecycle_hooks, - retry_policy: {max_retries: 0, retryable_errors: []} - ) - orchestrator.add_task(Agentic::Task.new( - description: "call:#{tick}", agent_spec: {"name" => "caller", "instructions" => "call"} - ), agent: ->(_t) { upstream.call(tick) }) - result = orchestrator.execute_plan - - if result.successful? - breaker.record_success - puts format(" %-6d %-11s %s", tick, breaker.state, probe ? "probe SUCCEEDED - breaker closes" : "ok") - else - # The journal's write-time verdict feeds the breaker - the error - # already testified whether retrying could ever help - verdict = Agentic::ExecutionJournal.replay(path: journal_path).events - .reverse.find { |e| e[:event] == "task_failed" }[:retryable] - breaker.record_failure(verdict) - puts format(" %-6d %-11s failed (retryable: %s)%s", tick, breaker.state, verdict, - (breaker.state == :open) ? " - breaker TRIPS" : "") - end - end - puts - [Agentic::ExecutionJournal.replay(path: journal_path), breaker] -end - -puts "CIRCUIT BREAKER (trip after #{Breaker::TRIP_AFTER} strikes, cooldown #{Breaker::COOLDOWN_TICKS} ticks)" -puts - -# Act one: a transient outage - three strikes, trip, skip, probe, recover -OUTAGE = (4..9) -state, breaker = run_scenario("act one: 503s from tick 4 to 9", 16, - File.join(Dir.tmpdir, "agentic_breaker_1.jsonl")) do |tick| - raise Agentic::Errors::LlmServerError, "503 (tick #{tick})" if OUTAGE.cover?(tick) - - "ok" -end - -# Act two: a revoked key - ONE strike, because the error testified -# that no retry can ever help -state2, breaker2 = run_scenario("act two: key revoked at tick 2", 8, - File.join(Dir.tmpdir, "agentic_breaker_2.jsonl")) do |tick| - raise Agentic::Errors::LlmAuthenticationError, "401 key revoked" if tick >= 2 - - "ok" -end - -felt = state.events.count { |e| e[:event] == "task_failed" } -puts " act one's outage lasted #{OUTAGE.size} ticks but only #{felt} calls felt it: the" -puts " breaker ate the middle as #{breaker.skipped} skips (nothing sent, nothing billed," -puts " no timeout waited out) and spent one probe discovering the recovery." -puts " act two never reached three strikes - the 401's journaled verdict" -puts " (retryable: false) tripped the breaker on FIRST contact, and the" -puts " probe kept finding the same wall (#{state2.events.count { |e| e[:event] == "task_failed" }} real calls, #{breaker2.skipped} skipped)." -puts " strike counts are for errors that might pass; testimony that the" -puts " error can never pass deserves an instant trip." diff --git a/examples/cli_contract.rb b/examples/cli_contract.rb deleted file mode 100644 index 2334e76..0000000 --- a/examples/cli_contract.rb +++ /dev/null @@ -1,107 +0,0 @@ -# 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/collaboration_tracer.rb b/examples/collaboration_tracer.rb deleted file mode 100644 index f35c8e4..0000000 --- a/examples/collaboration_tracer.rb +++ /dev/null @@ -1,89 +0,0 @@ -# frozen_string_literal: true - -# The Collaboration Tracer: lifecycle hooks record every message the -# orchestrator sends and every reply that comes back, then the run is -# drawn as a sequence diagram. Object-oriented programs are -# conversations; this makes the conversation visible. -# -# bundle exec ruby examples/collaboration_tracer.rb -# -# Runs offline: a three-agent editorial pipeline, traced. - -require_relative "../lib/agentic" - -PIPELINE = { - "Researcher" => {work: ->(_prev) { "3 facts about fibers" }}, - "Writer" => {work: ->(prev) { "draft built on: #{prev}" }}, - "Editor" => {work: ->(prev) { "tightened: #{prev.split(":").first}" }} -}.freeze - -trace = [] -hooks = { - before_task_execution: ->(task_id:, task:) { - trace << {from: "Orchestrator", to: task.description, label: "perform(#{task.description.downcase})"} - unless task.dependency_outputs.empty? - task.dependency_outputs.each_value do |output| - trace << {from: "Orchestrator", to: task.description, label: "here's \"#{output.to_s[0, 18]}...\""} - end - end - }, - after_task_success: ->(task_id:, task:, result:, duration:) { - trace << {from: task.description, to: "Orchestrator", label: "done: \"#{result.output.to_s[0, 18]}...\""} - } -} - -orchestrator = Agentic::PlanOrchestrator.new(lifecycle_hooks: hooks) -previous = nil -PIPELINE.each do |role, spec| - task = Agentic::Task.new( - description: role, - agent_spec: {"name" => role, "instructions" => "collaborate"}, - payload: spec[:work] - ) - orchestrator.add_task(task, previous ? [previous] : [], agent: ->(t) { - t.payload.call(t.dependency_outputs.values.first) - }) - previous = task -end - -orchestrator.execute_plan - -# --- render the conversation as a sequence diagram -------------------------- -actors = ["Orchestrator"] + PIPELINE.keys -width = 16 -positions = actors.each_with_index.to_h { |actor, i| [actor, i * width + width / 2] } -line_width = actors.size * width - -puts "COLLABORATION TRACE (#{trace.size} messages)" -puts -puts actors.map { |a| a.center(width) }.join -puts positions.values.each_with_object(" " * line_width) { |pos, line| - line[pos] = "|" - } - -trace.each do |message| - from_pos = positions[message[:from]] - to_pos = positions[message[:to]] - left, right = [from_pos, to_pos].minmax - - # the arrow line, with lifelines drawn through - line = " " * line_width - positions.each_value { |pos| line[pos] = "|" } - (left + 1...right).each { |i| line[i] = "-" } - line[(from_pos < to_pos) ? right - 1 : left + 1] = (from_pos < to_pos) ? ">" : "<" - puts line - - # the label line - label = " " * line_width - positions.each_value { |pos| label[pos] = "|" } - text = message[:label][0, right - left - 3] - label[left + 2, text.length] = text - puts label -end - -puts positions.values.each_with_object(" " * line_width) { |pos, line| - line[pos] = "|" - } -puts -puts "read it like a conversation: every arrow is a message, every" -puts "reply flows back before the next collaborator is addressed." diff --git a/examples/command_bus.rb b/examples/command_bus.rb deleted file mode 100644 index 6c061e9..0000000 --- a/examples/command_bus.rb +++ /dev/null @@ -1,114 +0,0 @@ -# frozen_string_literal: true - -# The Command Bus: every command is a composed capability with its OWN -# declared contract (new in this round - compositions used to be -# contract-less). The bus is just the registry: dispatching a command -# validates it at the boundary, routes it through its handler pipeline, -# and validates what comes back. -# -# bundle exec ruby examples/command_bus.rb -# -# Runs offline. Watch PlaceOrder(quantity: "many") bounce off the -# boundary with the violation named. - -require_relative "../lib/agentic" - -registry = Agentic::AgentCapabilityRegistry.instance - -# --- primitive capabilities: small, reusable handler steps ----------------- -def capability(name, inputs:, outputs:, &impl) - spec = Agentic::CapabilitySpecification.new( - name: name, description: name, version: "1.0.0", inputs: inputs, outputs: outputs - ) - Agentic.register_capability( - spec, Agentic::CapabilityProvider.new(capability: spec, implementation: impl) - ) -end - -STOCK = Hash.new(10) -LEDGER = [] - -capability("reserve_stock", - inputs: {sku: {type: "string", required: true}, quantity: {type: "number", required: true}}, - outputs: {reserved: {type: "boolean", required: true}, remaining: {type: "number", required: true}}) do |input| - available = STOCK[input[:sku]] - reserved = available >= input[:quantity] - STOCK[input[:sku]] -= input[:quantity] if reserved - {reserved: reserved, remaining: STOCK[input[:sku]]} -end - -capability("record_entry", - inputs: {entry: {type: "string", required: true}}, - outputs: {position: {type: "number", required: true}}) do |input| - LEDGER << input[:entry] - {position: LEDGER.size} -end - -# --- commands: compositions with their own contracts ------------------------ -registry.compose( - "PlaceOrder", "Place an order for a SKU", "1.0.0", - [{name: "reserve_stock", version: "1.0.0"}, {name: "record_entry", version: "1.0.0"}], - lambda do |(reserve, record), command| - reservation = reserve.execute(sku: command[:sku], quantity: command[:quantity]) - unless reservation[:reserved] - next {accepted: false, events: ["OrderRejected: insufficient stock for #{command[:sku]}"]} - end - - entry = record.execute(entry: "order #{command[:sku]} x#{command[:quantity]}") - {accepted: true, events: ["StockReserved(#{reservation[:remaining]} left)", "OrderPlaced(##{entry[:position]})"]} - end, - inputs: { - sku: {type: "string", required: true}, - quantity: {type: "number", required: true} - }, - outputs: { - accepted: {type: "boolean", required: true}, - events: {type: "array", required: true} - } -) - -registry.compose( - "RestockShelf", "Add stock for a SKU", "1.0.0", - [{name: "record_entry", version: "1.0.0"}], - lambda do |(record), command| - STOCK[command[:sku]] += command[:quantity] - entry = record.execute(entry: "restock #{command[:sku]} +#{command[:quantity]}") - {accepted: true, events: ["ShelfRestocked(##{entry[:position]})"]} - end, - inputs: {sku: {type: "string", required: true}, quantity: {type: "number", required: true}}, - outputs: {accepted: {type: "boolean", required: true}, events: {type: "array", required: true}} -) - -# --- the bus: dispatch is validation + routing, nothing else ---------------- -def dispatch(registry, command_name, payload) - provider = registry.get_provider(command_name) or - return {accepted: false, events: ["UnknownCommand: #{command_name}"]} - provider.execute(payload) -rescue Agentic::Errors::ValidationError => e - {accepted: false, - events: e.violations.map { |key, msgs| "CommandRejected: #{key} #{Array(msgs).join(", ")}" }} -end - -COMMANDS = [ - ["PlaceOrder", {sku: "widget", quantity: 3}], - ["PlaceOrder", {sku: "widget", quantity: "many"}], # violates the contract - ["RestockShelf", {sku: "widget", quantity: 5}], - ["PlaceOrder", {sku: "widget", quantity: 13}], # violates the business rule - ["ShipRocket", {to: "the moon"}] # nobody handles this -].freeze - -puts "COMMAND BUS" -puts -COMMANDS.each do |name, payload| - result = dispatch(registry, name, payload) - status = result[:accepted] ? "ACCEPTED" : "REJECTED" - puts format(" %-8s %s(%s)", status, name, payload.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")) - result[:events].each { |event| puts " -> #{event}" } -end - -puts -puts "ledger: #{LEDGER.size} entries | widget stock: #{STOCK["widget"]}" -puts -puts "note the two different REJECTED shapes: the contract rejected" -puts "'many' before any handler ran; the business rule rejected 13 after" -puts "checking the shelf. types stop nonsense, domains stop mistakes." diff --git a/examples/composed_limits.rb b/examples/composed_limits.rb deleted file mode 100644 index 49fc348..0000000 --- a/examples/composed_limits.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -# Composed Limits: a real provider enforces BOTH a billed quota and a -# connection ceiling. quota.and(pool) - new this round - holds the two -# laws in one limiter, and the run proves each law bound the traffic -# in its own dimension: admissions per window AND concurrent in-flight. -# -# bundle exec ruby examples/composed_limits.rb -# -# Runs offline; 12 slow calls against quota 6/200ms and pool of 2. - -require_relative "../lib/agentic" -require "async" - -REQUESTS = 12 -CALL_TIME = 0.05 - -quota = Agentic::RateLimit.new(6, per: 0.2) # billing law: 6 per 200ms -pool = Agentic::RateLimit.new(2) # socket law: 2 in flight -limit = quota.and(pool) # both, in that order - -admissions = [] -started = Process.clock_gettime(Process::CLOCK_MONOTONIC) - -Sync do - REQUESTS.times.map { - Async do - limit.acquire do - admissions << Process.clock_gettime(Process::CLOCK_MONOTONIC) - started - sleep(CALL_TIME) - end - end - }.each(&:wait) -end - -puts "COMPOSED LIMITS: #{REQUESTS} calls x #{(CALL_TIME * 1000).round}ms, " \ - "quota 6/200ms AND pool of 2" -puts - -buckets = admissions.group_by { |t| (t / 0.2).floor } -buckets.sort.each do |window, hits| - puts format(" window %d (%3d-%3dms): %-8s %d admitted (quota allows 6)", - window + 1, window * 200, (window + 1) * 200, "#" * hits.size, hits.size) -end - -puts -puts format(" pool high-water: %d of %d - the socket law held", pool.high_water, pool.ceiling) -puts format(" quota high-water: %d concurrent (window law measures admissions, not flight)", quota.high_water) -puts -puts "which law binds? the pool could clear ~8 per window (2 lanes x 4" -puts "service times) but the quota admits only 6 - so the QUOTA is the" -puts "binding constraint, and the chart shows it: exactly 6 per window." -puts "raise the quota and the pool becomes the wall. composition enforces" -puts "both laws and the windows tell you which one is throttling you." -puts -puts "ordering note: quota.and(pool) spends quota BEFORE waiting for a" -puts "socket - correct, because sockets are scarce and quota refills on a" -puts "clock. the reverse order would hold a connection hostage while" -puts "waiting for the meter." diff --git a/examples/concurrency_key.rb b/examples/concurrency_key.rb deleted file mode 100644 index fbff202..0000000 --- a/examples/concurrency_key.rb +++ /dev/null @@ -1,108 +0,0 @@ -# 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/confident_pipeline.rb b/examples/confident_pipeline.rb deleted file mode 100644 index 5e9b76b..0000000 --- a/examples/confident_pipeline.rb +++ /dev/null @@ -1,104 +0,0 @@ -# frozen_string_literal: true - -# The Confident Pipeline: timid code checks nil at every step because -# it trusts nothing, including itself. Confident code validates once, -# at the barricade, and then speaks in declarative sentences. Same -# pipeline, written both ways - and then both are made to face the -# same malformed input, so the difference is behavior, not taste. -# -# bundle exec ruby examples/confident_pipeline.rb -# -# Runs offline; the conditional count is computed from this file. - -require_relative "../lib/agentic" - -Agentic.logger.level = :fatal - -# --- the timid version ---------------------------------------------------------- -# Every method distrusts its caller, so every method re-litigates -# reality. Read it aloud: it's all subordinate clauses. -module Timid - def self.process(order) - return nil if order.nil? - - items = order[:items] - return nil if items.nil? || !items.is_a?(Array) || items.empty? - - total = 0 - items.each do |item| - next if item.nil? - - price = item[:price_cents] - qty = item[:qty] || 1 - total += price * qty if !price.nil? && price.is_a?(Numeric) && price >= 0 - end - - email = order[:email] - receipt = if email && !email.empty? - "receipt to #{email}" - end - - {total_cents: total, delivery: receipt || "no receipt"} - end -end - -# --- the confident version ------------------------------------------------------- -# One barricade at the boundary. Inside it, every sentence is -# indicative mood: the data IS shaped; the contract said so. -ORDER_CONTRACT = Agentic::CapabilitySpecification.new( - name: "process_order", description: "Price an order", version: "1.0.0", - inputs: { - items: {type: "array", required: true, non_empty: true}, - email: {type: "string", required: true, non_empty: true} - }, - outputs: {total_cents: {type: "number", required: true}, delivery: {type: "string", required: true}} -) -BARRICADE = Agentic::CapabilityValidator.new(ORDER_CONTRACT) - -module Confident - def self.process(order) - BARRICADE.validate_inputs!(order) - total = order[:items].sum { |item| item.fetch(:price_cents) * item.fetch(:qty, 1) } - output = {total_cents: total, delivery: "receipt to #{order[:email]}"} - BARRICADE.validate_outputs!(output) - output - end -end - -GOOD = {items: [{price_cents: 1200, qty: 2}, {price_cents: 350}], email: "a@b.co"}.freeze -BAD = {items: [{price_cents: nil, qty: 3}], email: ""}.freeze - -puts "THE CONFIDENT PIPELINE (same job, two postures)" -puts - -source = File.read(__FILE__, encoding: "UTF-8") -timid_src = source[/module Timid.*?\n end\nend/m] -confident_src = source[/module Confident.*?\n end\nend/m] -count = ->(src) { src.scan(/\b(?:if|unless|return nil|next if|\|\|)\s/).size + src.scan("&&").size } - -puts format(" %-12s %2d conditionals, %2d lines", "timid:", count.call(timid_src), timid_src.lines.size) -puts format(" %-12s %2d conditionals, %2d lines", "confident:", count.call(confident_src), confident_src.lines.size) -puts - -puts " good input:" -puts " timid: #{Timid.process(GOOD)}" -puts " confident: #{Confident.process(GOOD)}" -puts -puts " malformed input (an item with a nil price, email: \"\"):" -puts " timid: #{Timid.process(BAD).inspect}" -begin - Confident.process(BAD) -rescue Agentic::Errors::ValidationError => e - puts " confident: raises ValidationError - #{e.violations.keys.join(", ")} rejected AT THE DOOR" -end -puts -puts " look at what the timid version returned for garbage: a polite," -puts " well-formed, WRONG answer - zero dollars, \"no receipt\", no error." -puts " that nil-tolerance didn't handle the bad input, it LAUNDERED it;" -puts " some downstream ledger now owes a customer an explanation. the" -puts " confident version has one conditional posture: a barricade at" -puts " each door (inputs validated once, outputs too - honesty is also" -puts " a promise about what you return). inside, every line is a" -puts " declarative sentence about data that is KNOWN to be shaped." -puts " confidence isn't optimism - it's pushing all the doubt to the" -puts " boundary, where it can say no out loud." diff --git a/examples/contract_cop.rb b/examples/contract_cop.rb deleted file mode 100644 index d97c578..0000000 --- a/examples/contract_cop.rb +++ /dev/null @@ -1,101 +0,0 @@ -# frozen_string_literal: true - -# The Contract Cop: RuboCop for capability specs. Contracts are the -# most-read documents in this framework - six tools consume them - -# so they deserve a style guide with teeth: named cops, an offense -# report, and autocorrection for everything mechanical. Style is not -# vanity; it's the cost of reading, paid down in advance. -# -# bundle exec ruby examples/contract_cop.rb -# -# Runs offline; a messy contract walks in, autocorrect walks it out. - -require_relative "../lib/agentic" - -# The defendant: a contract written at 6pm on a Friday -MESSY = { - name: "QuoteShipping", description: "", version: "1.0.0", - inputs: { - Mode: {type: "string", required: true, enum: %w[sea air road]}, - weightKg: {type: "number", required: true, min: 0, max: 5_000}, - customs_code: {type: "string"}, - ref: {}, - a: {type: "string", required: true}, - b: {type: "string", required: true}, - c: {type: "string", required: true}, - d: {type: "string", required: true}, - e: {type: "string", required: true} - }, - rules: { - check1: {fields: [:weightKg], check: ->(i) { i[:weightKg] < 5_000 }} - } -}.freeze - -# Each cop: name, check (spec-hash in, offenses out), correctable? -COPS = { - "Naming/SnakeCaseName" => { - check: ->(s) { (s[:name] =~ /\A[a-z][a-z0-9_]*\z/) ? [] : ["capability name '#{s[:name]}' is not snake_case"] }, - correct: ->(s) { s[:name] = s[:name].gsub(/([a-z])([A-Z])/, '\1_\2').downcase } - }, - "Naming/SnakeCaseFields" => { - check: ->(s) { s[:inputs].keys.reject { |k| k =~ /\A[a-z][a-z0-9_]*\z/ }.map { |k| "input :#{k} is not snake_case" } }, - correct: ->(s) { s[:inputs].transform_keys! { |k| k.to_s.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym } } - }, - "Documentation/Description" => { - check: ->(s) { s[:description].to_s.empty? ? ["capability has no description"] : [] }, - correct: nil # a human must actually say what it does - }, - "Style/EnumOrder" => { - check: ->(s) { s[:inputs].select { |_, d| d[:enum] && d[:enum] != d[:enum].sort }.map { |k, _| "input :#{k} enum is not sorted" } }, - correct: ->(s) { s[:inputs].each_value { |d| d[:enum] = d[:enum].sort if d[:enum] } } - }, - "Lint/UntypedField" => { - check: ->(s) { s[:inputs].select { |_, d| d[:type].nil? }.map { |k, _| "input :#{k} has no type (and won't project into schemas)" } }, - correct: nil # guessing a type is how bugs get typed - }, - "Lint/OpaqueRuleWithoutMessage" => { - check: ->(s) { - s[:rules].select { |_, d| d.respond_to?(:call) || (d[:check] && !d[:message]) } - .map { |k, _| "rule :#{k} is opaque AND messageless - violations will say nothing" } - }, - correct: nil # the message is the author's testimony; can't forge it - }, - "Metrics/RequiredInputCount" => { - check: ->(s) { - required = s[:inputs].count { |_, d| d[:required] } - (required > 5) ? ["#{required} required inputs (max 5) - is this one capability or three?"] : [] - }, - correct: nil - } -}.freeze - -def inspect_spec(spec_hash) - COPS.flat_map { |cop, definition| definition[:check].call(spec_hash).map { |offense| [cop, offense] } } -end - -puts "CONTRACT COP (#{COPS.size} cops on the beat)" -puts -offenses = inspect_spec(MESSY) -puts " inspecting quote_shipping... #{offenses.size} offenses:" -offenses.each { |cop, offense| puts format(" %-33s %s", cop, offense) } -puts - -# --- autocorrect what's mechanical ---------------------------------------------- -corrected = {name: MESSY[:name].dup, description: MESSY[:description].dup, version: MESSY[:version], - inputs: MESSY[:inputs].transform_values(&:dup).dup, rules: MESSY[:rules].dup} -corrected[:inputs].each_value { |d| d[:enum] = d[:enum].dup if d[:enum] } -COPS.each_value { |definition| definition[:correct]&.call(corrected) } - -remaining = inspect_spec(corrected) -puts " after autocorrect (#{offenses.size - remaining.size} offenses fixed mechanically):" -remaining.each { |cop, offense| puts format(" %-33s %s", cop, offense) } -puts -puts " what autocorrect fixed, it fixed safely: names snake_cased," -puts " enums sorted - transformations with exactly one right answer." -puts " what remains is the honest residue: a missing description" -puts " (only the author knows what it does), an untyped field" -puts " (guessing types is how bugs get typed), an opaque messageless" -puts " rule, and seven required inputs' worth of scope creep. a linter's" -puts " job splits exactly there - automate the mechanical, and make" -puts " the judgment calls impossible to not-see. style is applied" -puts " empathy for the next reader, and contracts have six readers." diff --git a/examples/contract_fixtures.rb b/examples/contract_fixtures.rb deleted file mode 100644 index 3bfc902..0000000 --- a/examples/contract_fixtures.rb +++ /dev/null @@ -1,146 +0,0 @@ -# frozen_string_literal: true - -# Contract Fixtures: example payloads in docs rot the day the contract -# changes. So don't write them - DERIVE them. This generator reads a -# capability's declarations and produces a minimal fixture (required -# keys only) and a maximal one (everything), then a referee proves -# every generated fixture passes its own contract, and that a mutated -# fixture still fails. Docs that compile, effectively. -# -# bundle exec ruby examples/contract_fixtures.rb -# -# Runs offline; exits 1 if the generator and validator disagree. - -require_relative "../lib/agentic" -require "json" - -SPECS = [ - Agentic::CapabilitySpecification.new( - name: "quote_shipping", description: "Quote a shipment", version: "2.0.0", - inputs: { - mode: {type: "string", required: true, enum: %w[air sea road]}, - weight: {type: "number", required: true, min: 1, max: 5_000}, - volume: {type: "number", min: 0, max: 5_000}, - customs_code: {type: "string"}, - express: {type: "boolean"}, - api_key: {type: "string"}, - oauth_token: {type: "string"} - }, - outputs: {price_cents: {type: "number", required: true}}, - rules: { - fits: {relation: :sum_lte, fields: [:weight, :volume], limit: 4_000}, - customs: {relation: :requires, fields: [:express, :customs_code]}, - one_auth: {relation: :mutually_exclusive, fields: [:api_key, :oauth_token]} - } - ), - Agentic::CapabilitySpecification.new( - name: "classify_ticket", description: "Route a support ticket", version: "1.1.0", - inputs: { - text: {type: "string", required: true, non_empty: true}, - urgency: {type: "number", min: 0, max: 10} - }, - outputs: {queue: {type: "string", required: true, enum: %w[billing tech general]}} - ) -].freeze - -# One value per declaration, derived - never invented -def value_for(key, decl) - return decl[:enum].first if decl[:enum] - - case decl[:type] - when "number" - low = decl[:min] || 0 - high = decl[:max] || 100 - low + (high - low) / 2 - when "boolean" then true - else "example-#{key}" - end -end - -# Relation-typed rules are data, so the generator can SATISFY them -# instead of hoping: scale sums under their limit, add what a present -# trigger requires, keep only the first of an exclusive group -def satisfy_relations(fixture, spec) - spec.rules.each_value do |definition| - next if definition.respond_to?(:call) || !definition[:relation] - - fields = definition[:fields] - case definition[:relation] - when :sum_lte - given = fields.select { |f| fixture[f].is_a?(Numeric) } - total = given.sum { |f| fixture[f] } - if total > definition[:limit] - given.each { |f| fixture[f] = definition[:limit] / given.size } - end - when :requires - trigger, *needed = fields - if !fixture[trigger].nil? - needed.each { |f| fixture[f] ||= value_for(f, spec.inputs[f]) } - end - when :mutually_exclusive - fields.select { |f| fixture.key?(f) }.drop(1).each { |f| fixture.delete(f) } - end - end - fixture -end - -def fixtures_for(spec) - required = spec.inputs.select { |_, decl| decl[:required] } - { - "minimal" => satisfy_relations(required.to_h { |key, decl| [key, value_for(key, decl)] }, spec), - "maximal" => satisfy_relations(spec.inputs.to_h { |key, decl| [key, value_for(key, decl)] }, spec) - } -end - -failures = 0 -puts "CONTRACT FIXTURES (derived from declarations, then proved)" - -SPECS.each do |spec| - validator = Agentic::CapabilityValidator.new(spec) - puts - puts " #{spec.name} v#{spec.version}:" - - fixtures_for(spec).each do |flavor, fixture| - verdict = begin - validator.validate_inputs!(fixture) - "valid" - rescue Agentic::Errors::ValidationError => e - failures += 1 - "REJECTED BY OWN CONTRACT: #{e.message}" - end - puts format(" %-8s %-60s %s", flavor, JSON.generate(fixture), verdict) - end - - # The referee's teeth: a mutant fixture (first required key removed) - # must still FAIL - a validator that accepts everything would make - # the proofs above worthless - mutant = fixtures_for(spec)["minimal"].dup - removed = mutant.keys.first - mutant.delete(removed) - begin - validator.validate_inputs!(mutant) - failures += 1 - puts format(" %-8s dropped :%-20s ACCEPTED - validator has no teeth", "mutant", removed) - rescue Agentic::Errors::ValidationError - puts format(" %-8s dropped :%-20s rejected, as it must be", "mutant", removed) - end -end - -puts -if failures.zero? - puts " every derived fixture passed its own contract, and every mutant" - puts " failed. paste these into your README - when the contract changes," - puts " rerun and they change with it. handwritten examples are promises;" - puts " derived ones are consequences." - puts - puts " and the round-9 blind spot has closed for the declarable" - puts " majority: relation-typed rules (sum_lte, requires," - puts " mutually_exclusive) are data, so the generator SATISFIED them -" - puts " scaled the weights under the limit, added what express required," - puts " kept one credential of the exclusive pair - and the validator," - puts " which now enforces relations too, countersigned the result." - puts " lambdas remain for the exotic tail, and remain opaque." -else - puts " #{failures} DISAGREEMENT(S) between generator and validator." -end -exit(failures.zero? ? 0 : 1) diff --git a/examples/contract_fuzzer.rb b/examples/contract_fuzzer.rb deleted file mode 100644 index a6f224e..0000000 --- a/examples/contract_fuzzer.rb +++ /dev/null @@ -1,103 +0,0 @@ -# frozen_string_literal: true - -# The Contract Fuzzer: for every registered capability, generate inputs -# that SHOULD pass its declared contract and mutations that SHOULD fail -# it, then check the validator agrees. A contract that accepts garbage -# or rejects conforming data is a bug in the boundary - the worst place -# to have one. -# -# bundle exec ruby examples/contract_fuzzer.rb [seed] -# -# Runs offline and deterministically: same seed, same verdicts. - -require_relative "../lib/agentic" - -seed = (ARGV.first || 20260706).to_i -rng = Random.new(seed) - -Agentic::Capabilities.register_standard_capabilities -registry = Agentic::AgentCapabilityRegistry.instance - -# Generates a value conforming to a declared type -def conforming_value(type, rng) - case type - when "string" then %w[alpha beta gamma delta].sample(random: rng) - when "number", "integer" then rng.rand(1..100) - when "boolean" then [true, false].sample(random: rng) - when "array" then Array.new(rng.rand(1..3)) { rng.rand(10) } - when "object", "hash" then {key: rng.rand(10)} - else "anything" - end -end - -# Generates a value that VIOLATES a declared type -def violating_value(type, rng) - case type - when "string" then rng.rand(1..100) - when "number", "integer" then "not a number" - when "boolean" then "yes" - when "array" then "not an array" - when "object", "hash" then 42 - end -end - -def conforming_inputs(spec, rng) - spec.inputs.to_h { |name, decl| [name, conforming_value(decl[:type], rng)] } -end - -verdicts = [] -trials = 0 - -registry.list.each_key do |name| - spec = registry.get(name) - validator = Agentic::CapabilityValidator.new(spec) - next if spec.inputs.empty? - - # Trial 1: conforming inputs must pass - trials += 1 - begin - validator.validate_inputs!(conforming_inputs(spec, rng)) - rescue Agentic::Errors::ValidationError => e - verdicts << "#{name}: REJECTED conforming inputs (#{e.message})" - end - - # Trial 2: each required key, dropped, must fail - spec.inputs.select { |_, decl| decl[:required] }.each_key do |required| - trials += 1 - inputs = conforming_inputs(spec, rng) - inputs.delete(required) - begin - validator.validate_inputs!(inputs) - verdicts << "#{name}: ACCEPTED inputs missing required :#{required}" - rescue Agentic::Errors::ValidationError - # correct rejection - end - end - - # Trial 3: each typed key, corrupted, must fail - spec.inputs.each do |key, decl| - corrupted = violating_value(decl[:type], rng) - next if corrupted.nil? - - trials += 1 - inputs = conforming_inputs(spec, rng).merge(key => corrupted) - begin - validator.validate_inputs!(inputs) - verdicts << "#{name}: ACCEPTED #{decl[:type]} key :#{key} holding #{corrupted.inspect}" - rescue Agentic::Errors::ValidationError - # correct rejection - end - end -end - -puts "CONTRACT FUZZ (seed #{seed})" -puts " #{registry.list.size} capabilities, #{trials} trials" -puts -if verdicts.empty? - puts " every contract accepted what it promised and rejected what it should." - puts " the boundary holds." -else - puts " BOUNDARY DEFECTS (#{verdicts.size}):" - verdicts.each { |v| puts " - #{v}" } - exit 1 -end diff --git a/examples/contract_overhead.rb b/examples/contract_overhead.rb deleted file mode 100644 index 419b6bc..0000000 --- a/examples/contract_overhead.rb +++ /dev/null @@ -1,77 +0,0 @@ -# frozen_string_literal: true - -# The Contract Overhead Bench: "should we validate every call?" is a -# performance question, so answer it with a measurement instead of a -# vibe. Benchmarks the validator across contract sizes and rule -# counts, then prices it against the thing it protects - because -# overhead is a fraction, and everyone keeps quoting the numerator. -# -# bundle exec ruby examples/contract_overhead.rb -# -# Runs offline; times are real, the LLM latency is the industry's. - -require_relative "../lib/agentic" - -ITERATIONS = 2_000 -LLM_CALL_MS = 800.0 # a conservative round-trip for a real model call - -def bench(iterations) - # Warm up, then measure - the first call pays dry-schema compilation - yield - started = Process.clock_gettime(Process::CLOCK_MONOTONIC) - iterations.times { yield } - (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) / iterations * 1000 -end - -def spec_with(keys:, relations:) - inputs = keys.times.to_h { |i| [:"field_#{i}", {type: "number", required: true, min: 0, max: 1_000}] } - rules = relations.times.to_h { |i| - [:"rule_#{i}", {relation: :sum_lte, fields: [:"field_#{i}", :"field_#{(i + 1) % keys}"], limit: 2_000}] - } - Agentic::CapabilitySpecification.new( - name: "bench", description: "bench", version: "1.0.0", inputs: inputs, rules: rules - ) -end - -puts "CONTRACT OVERHEAD BENCH (#{ITERATIONS} validations per row)" -puts -puts format(" %-26s %-12s %s", "contract", "per call", "share of an #{LLM_CALL_MS.to_i}ms LLM call") - -rows = [ - ["3 keys, no rules", spec_with(keys: 3, relations: 0), {field_0: 1, field_1: 2, field_2: 3}], - ["10 keys, no rules", spec_with(keys: 10, relations: 0), 10.times.to_h { |i| [:"field_#{i}", i] }], - ["10 keys, 5 relations", spec_with(keys: 10, relations: 5), 10.times.to_h { |i| [:"field_#{i}", i] }], - ["30 keys, 15 relations", spec_with(keys: 30, relations: 15), 30.times.to_h { |i| [:"field_#{i}", i] }] -] - -results = rows.map do |label, spec, payload| - validator = Agentic::CapabilityValidator.new(spec) - ms = bench(ITERATIONS) { validator.validate_inputs!(payload) } - share = ms / LLM_CALL_MS * 100 - puts format(" %-26s %8.4fms %.4f%% %s", label, ms, share, "#" * [(share * 2000).round, 40].min) - [label, ms] -end - -# The failure path costs too - measure a rejection (exception + message building) -reject_spec = spec_with(keys: 10, relations: 5) -reject_validator = Agentic::CapabilityValidator.new(reject_spec) -bad_payload = 10.times.to_h { |i| [:"field_#{i}", 1_900] } # breaks every sum_lte -reject_ms = bench(500) { - begin - reject_validator.validate_inputs!(bad_payload) - rescue Agentic::Errors::ValidationError - nil - end -} -puts format(" %-26s %8.4fms (the expensive path: exception + %d rule reports)", "rejection, 5 rules broken", reject_ms, 5) - -puts -fastest, cheapest = results.first -biggest, priciest = results.last -puts " the whole table rounds to zero: the biggest contract here" -puts format(" (%s) costs %.4fms per call - %.5f%% of the LLM", biggest.downcase, priciest, priciest / LLM_CALL_MS * 100) -puts format(" round-trip it guards. even rejection, the slow path, is %.2fms.", reject_ms) -puts " \"we skip validation for performance\" saves the price of a" -puts format(" rounding error (%s: %.4fms) to risk shipping a malformed", fastest.downcase, cheapest) -puts " prompt to an #{LLM_CALL_MS.to_i}ms call that BILLS you for the mistake." -puts " validate both doors. the meter says you can afford it." diff --git a/examples/contract_semver.rb b/examples/contract_semver.rb deleted file mode 100644 index 94a73ca..0000000 --- a/examples/contract_semver.rb +++ /dev/null @@ -1,94 +0,0 @@ -# frozen_string_literal: true - -# The Contract Semver Advisor: two versions of a capability's contract, -# every change classified as breaking or compatible - FROM THE CALLER'S -# and THE CONSUMER'S seats, which disagree about what "breaking" means. -# The verdict is the version bump you owe your users. -# -# bundle exec ruby examples/contract_semver.rb -# -# Runs offline; v2 contains one of every interesting change. - -require_relative "../lib/agentic" - -V1 = Agentic::CapabilitySpecification.new( - name: "quote_shipping", description: "Quote a shipment", version: "1.4.0", - inputs: { - mode: {type: "string", required: true, enum: %w[air sea]}, - weight: {type: "number", required: true, min: 1, max: 10_000}, - notes: {type: "string"} - }, - outputs: { - price_cents: {type: "number", required: true}, - carrier: {type: "string", required: true} - } -) - -V2 = Agentic::CapabilitySpecification.new( - name: "quote_shipping", description: "Quote a shipment", version: "?", - inputs: { - mode: {type: "string", required: true, enum: %w[air sea road]}, # enum widened - weight: {type: "number", required: true, min: 1, max: 5_000}, # max tightened - customs_code: {type: "string", required: true}, # new required input - notes: {type: "string"} - }, - outputs: { - price_cents: {type: "number", required: true}, - eta_days: {type: "number", required: true} # new output - # carrier: removed - } -) - -def classify_inputs(v1, v2) - changes = [] - v2.inputs.each do |key, decl| - old = v1.inputs[key] - if old.nil? - changes << [decl[:required] ? :breaking : :compatible, - "input :#{key} added#{decl[:required] ? " as REQUIRED - existing callers don't send it" : " (optional)"}"] - next - end - changes << [:breaking, "input :#{key} type changed #{old[:type]} -> #{decl[:type]}"] if old[:type] != decl[:type] - changes << [:breaking, "input :#{key} became required"] if decl[:required] && !old[:required] - if old[:enum] && decl[:enum] - changes << [:compatible, "input :#{key} enum widened (#{(decl[:enum] - old[:enum]).join(", ")})"] if (old[:enum] - decl[:enum]).empty? && decl[:enum] != old[:enum] - changes << [:breaking, "input :#{key} enum narrowed (removed #{(old[:enum] - decl[:enum]).join(", ")})"] unless (old[:enum] - decl[:enum]).empty? - end - changes << [:breaking, "input :#{key} max tightened #{old[:max]} -> #{decl[:max]} - previously legal calls now rejected"] if old[:max] && decl[:max] && decl[:max] < old[:max] - changes << [:breaking, "input :#{key} min tightened #{old[:min]} -> #{decl[:min]}"] if old[:min] && decl[:min] && decl[:min] > old[:min] - end - (v1.inputs.keys - v2.inputs.keys).each do |key| - changes << [:compatible, "input :#{key} no longer declared (extra keys were always permitted)"] - end - changes -end - -def classify_outputs(v1, v2) - changes = [] - (v1.outputs.keys - v2.outputs.keys).each do |key| - changes << [:breaking, "output :#{key} removed - consumers reading it get nil"] - end - (v2.outputs.keys - v1.outputs.keys).each do |key| - changes << [:compatible, "output :#{key} added (consumers ignore unknown keys)"] - end - changes -end - -changes = classify_inputs(V1, V2) + classify_outputs(V1, V2) -breaking = changes.count { |kind, _| kind == :breaking } - -puts "CONTRACT SEMVER ADVISOR: #{V1.name} v#{V1.version} -> v?" -puts -changes.sort_by { |kind, _| (kind == :breaking) ? 0 : 1 }.each do |kind, message| - puts format(" %-10s %s", kind.to_s.upcase, message) -end - -major, minor, = V1.version.split(".").map(&:to_i) -suggested = breaking.positive? ? "#{major + 1}.0.0" : "#{major}.#{minor + 1}.0" -puts -puts " verdict: #{breaking} breaking change(s) -> ship as v#{suggested}" -puts -puts " note the asymmetry: INPUTS break when tightened (callers rejected)," -puts " OUTPUTS break when narrowed (consumers starved). the same edit is" -puts " breaking on one side and compatible on the other - semver for" -puts " contracts is a two-seat calculation, and both seats are customers." diff --git a/examples/cost_estimator.rb b/examples/cost_estimator.rb deleted file mode 100644 index 402629f..0000000 --- a/examples/cost_estimator.rb +++ /dev/null @@ -1,88 +0,0 @@ -# frozen_string_literal: true - -# The Cost Estimator: price the plan BEFORE running it - per-task token -# estimates times a pricing table - gate on budget, then reconcile -# estimate against actuals afterward. LLM plans spend real money; -# nobody should learn the bill from the invoice. -# -# bundle exec ruby examples/cost_estimator.rb [budget_cents] -# -# Runs offline; actual token usage is seeded simulation. - -require_relative "../lib/agentic" - -# $/1M tokens, input+output blended for the demo -PRICING = { - "small" => 0.40, - "large" => 6.00 -}.freeze - -JOBS = { - "classify tickets" => {model: "small", est_tokens: 40_000}, - "summarize threads" => {model: "small", est_tokens: 120_000}, - "draft responses" => {model: "large", est_tokens: 60_000}, - "review drafts" => {model: "large", est_tokens: 25_000} -}.freeze - -def cents(model, tokens) - (PRICING.fetch(model) * tokens / 1_000_000 * 100) -end - -budget_cents = (ARGV.first || 60).to_i -rng = Random.new(20260707) - -orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 2) -tasks = JOBS.map do |name, job| - task = Agentic::Task.new( - description: name, - agent_spec: {"name" => name, "instructions" => "spend wisely"}, - payload: job - ) - orchestrator.add_task(task, agent: ->(t) { - sleep(0.01) - # Actuals drift from estimates, as actuals do (+/- up to 40%) - drift = 0.8 + rng.rand * 0.6 - {tokens: (t.payload[:est_tokens] * drift).round} - }) - task -end - -# --- pre-flight: price the graph before any token is spent ------------------- -estimate = orchestrator.graph[:tasks].values.sum { |t| cents(t.payload[:model], t.payload[:est_tokens]) } - -puts "COST ESTIMATOR (budget: #{budget_cents}c)" -puts -puts " pre-flight estimate:" -JOBS.each do |name, job| - puts format(" %-20s %-6s ~%6d tokens ~%5.1fc", name, job[:model], job[:est_tokens], - cents(job[:model], job[:est_tokens])) -end -puts format(" %-20s %28s %5.1fc", "TOTAL", "", estimate) -puts - -if estimate > budget_cents - puts " GATE: estimate exceeds budget - plan refused before spending a cent." - puts " (raise the budget or downgrade 'draft responses' to the small model)" - exit 1 -end -puts " GATE: under budget, proceeding." -puts - -# --- the run, then the reconciliation ---------------------------------------- -result = orchestrator.execute_plan - -puts " reconciliation (estimate vs actual):" -total_actual = 0.0 -tasks.each do |task| - job = task.payload - actual_tokens = result.results[task.id].output[:tokens] - actual = cents(job[:model], actual_tokens) - total_actual += actual - est = cents(job[:model], job[:est_tokens]) - drift_pct = 100.0 * (actual - est) / est - puts format(" %-20s est %5.1fc actual %5.1fc (%+.0f%%)", task.description, est, actual, drift_pct) -end -puts format(" %-20s est %5.1fc actual %5.1fc", "TOTAL", estimate, total_actual) -puts -puts " feed actuals back into est_tokens and next month's estimates" -puts " stop being folklore. budgets want feedback loops, not faith." diff --git a/examples/coupling_cartographer.rb b/examples/coupling_cartographer.rb deleted file mode 100644 index 78e300b..0000000 --- a/examples/coupling_cartographer.rb +++ /dev/null @@ -1,110 +0,0 @@ -# frozen_string_literal: true - -# The Coupling Cartographer: which files lean on which? Every file is -# surveyed for the constants it DEFINES and the constants it REFERENCES; -# a fan-in task joins the two into a dependency graph and reports the -# load-bearing walls and the heaviest leaners. -# -# bundle exec ruby examples/coupling_cartographer.rb [lib_dir] -# -# Runs offline; Prism supplies both sides of every edge. - -require_relative "../lib/agentic" -require "prism" - -LIB = File.expand_path(ARGV.first || "#{__dir__}/../lib") - -# Collects constants defined and referenced in one parse tree -def survey_constants(node, namespace, defined, referenced) - return unless node - - case node - when Prism::ModuleNode, Prism::ClassNode - name = node.constant_path.slice - full = [namespace, name].reject(&:empty?).join("::") - defined << full - node.child_nodes.each { |child| survey_constants(child, full, defined, referenced) } - return - when Prism::ConstantReadNode - referenced << node.name.to_s - when Prism::ConstantPathNode - referenced << node.slice - end - - node.child_nodes.each { |child| survey_constants(child, namespace, defined, referenced) } -end - -orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 8) -files = Dir[File.join(LIB, "**", "*.rb")].sort - -surveys = files.map do |path| - task = Agentic::Task.new( - description: path.delete_prefix("#{LIB}/"), - agent_spec: {"name" => "Surveyor", "instructions" => "chart the constants"}, - payload: path - ) - orchestrator.add_task(task, agent: ->(t) { - defined = [] - referenced = [] - survey_constants(Prism.parse_file(t.payload).value, "", defined, referenced) - {defined: defined, referenced: referenced.uniq - defined} - }) - task -end - -atlas = Agentic::Task.new( - description: "the atlas", - agent_spec: {"name" => "Cartographer", "instructions" => "join the maps"} -) -orchestrator.add_task(atlas, surveys, agent: ->(t) { - charts = surveys.to_h { |s| [s.description, t.output_of(s)] } - - # Who owns each constant (by trailing segment, since references are - # often relative: LlmClient rather than Agentic::LlmClient) - owners = {} - charts.each do |file, chart| - chart[:defined].each { |const| owners[const.split("::").last] = file } - end - - edges = Hash.new { |h, k| h[k] = [] } - charts.each do |file, chart| - chart[:referenced].each do |ref| - owner = owners[ref.split("::").last] - edges[file] << owner if owner && owner != file - end - end - # Copy without the default proc: a Hash.new {} that leaks to readers - # invents keys on every miss - including during iteration - edges = edges.transform_values(&:uniq) - - inbound = Hash.new(0) - edges.each_value { |targets| targets.each { |target| inbound[target] += 1 } } - - {edges: edges, inbound: inbound} -}) - -result = orchestrator.execute_plan -atlas_data = result.results[atlas.id].output - -puts "COUPLING ATLAS of #{LIB} (#{files.size} files)" -puts -puts "load-bearing walls (most depended-upon):" -atlas_data[:inbound].sort_by { |_, count| -count }.first(6).each do |file, count| - puts format(" %2d files lean on %s", count, file) -end -puts -puts "heaviest leaners (most dependencies out):" -atlas_data[:edges].sort_by { |_, targets| -targets.size }.first(6).each do |file, targets| - puts format(" %-40s leans on %2d files", file, targets.size) -end - -mutual = atlas_data[:edges].flat_map { |file, targets| - targets.filter_map { |target| [file, target].sort if atlas_data[:edges][target]&.include?(file) } -}.uniq -puts -if mutual.empty? - puts "no mutual dependencies - every edge points one way. rare, and good." -else - puts "mutual dependencies (each file references the other):" - mutual.each { |a, b| puts " #{a} <-> #{b}" } -end diff --git a/examples/critical_path.rb b/examples/critical_path.rb deleted file mode 100644 index 8d04ca8..0000000 --- a/examples/critical_path.rb +++ /dev/null @@ -1,86 +0,0 @@ -# frozen_string_literal: true - -# The Critical Path: after a run, combine the graph topology with -# measured durations to find the chain of tasks that determined the -# wall clock. Optimizing anything OFF that path is charity work - -# and this proves it by making a non-critical task instant and -# re-running. -# -# bundle exec ruby examples/critical_path.rb -# -# Runs offline; durations are simulated IO. - -require_relative "../lib/agentic" - -WORK = { - "pull:catalog" => {sleep: 0.05, deps: []}, - "pull:orders" => {sleep: 0.18, deps: []}, - "pull:reviews" => {sleep: 0.06, deps: []}, - "score:products" => {sleep: 0.09, deps: ["pull:catalog", "pull:reviews"]}, - "invoice:month" => {sleep: 0.12, deps: ["pull:orders"]}, - "report:board" => {sleep: 0.04, deps: ["score:products", "invoice:month"]} -}.freeze - -def run(work, concurrency: 6) - durations = {} - hooks = { - after_task_success: ->(task_id:, task:, result:, duration:) { durations[task_id] = duration } - } - orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: concurrency, lifecycle_hooks: hooks) - tasks = {} - work.each do |name, spec| - tasks[name] = Agentic::Task.new( - description: name, - agent_spec: {"name" => name, "instructions" => "simulate"}, - payload: spec[:sleep] - ) - orchestrator.add_task(tasks[name], spec[:deps].map { |d| tasks.fetch(d) }, - agent: ->(t) { sleep(t.payload) || :ok }) - end - result = orchestrator.execute_plan - [orchestrator.graph, durations, result.execution_time] -end - -# Longest-duration path to each node, computed over the real topology -def critical_path(graph, durations) - names = graph[:tasks].transform_values(&:description) - memo = {} - walk = lambda do |task_id| - memo[task_id] ||= begin - deps = graph[:dependencies][task_id] - best = deps.map { |dep| walk.call(dep) }.max_by { |p| p[:cost] } || {cost: 0.0, path: []} - {cost: best[:cost] + durations[task_id], path: best[:path] + [names[task_id]]} - end - end - graph[:dependencies].keys.map { |id| walk.call(id) }.max_by { |p| p[:cost] } -end - -graph, durations, wall = run(WORK) -path = critical_path(graph, durations) - -puts "CRITICAL PATH ANALYSIS" -puts -puts format(" wall clock: %dms", wall * 1000) -puts format(" critical path: %dms = %s", path[:cost] * 1000, path[:path].join(" -> ")) -puts format(" (path explains %.0f%% of the wall clock - the rest is scheduling noise)", - 100 * path[:cost] / wall) -puts - -# The proof: optimize a NON-critical task to zero... nothing happens -off_path = WORK.keys.find { |name| !path[:path].include?(name) } -faster = WORK.merge(off_path => WORK[off_path].merge(sleep: 0.0)) -_, _, wall_after_wrong = run(faster) - -# Now halve the SLOWEST task on the path... everything happens -bottleneck = path[:path].max_by { |name| WORK[name][:sleep] } -righter = WORK.merge(bottleneck => WORK[bottleneck].merge(sleep: WORK[bottleneck][:sleep] / 2)) -_, _, wall_after_right = run(righter) - -puts "the experiment:" -puts format(" make '%s' (off-path) instant: %3dms -> %3dms (nothing. told you.)", - off_path, wall * 1000, wall_after_wrong * 1000) -puts format(" halve '%s' (on-path): %3dms -> %3dms (there it is)", - bottleneck, wall * 1000, wall_after_right * 1000) -puts -puts "profile the path, not the plan. optimizing off the critical path" -puts "is how teams burn a sprint making the fast part faster." diff --git a/examples/dead_letter_office.rb b/examples/dead_letter_office.rb deleted file mode 100644 index bfdfc12..0000000 --- a/examples/dead_letter_office.rb +++ /dev/null @@ -1,93 +0,0 @@ -# frozen_string_literal: true - -# The Dead Letter Office: three days of journaled runs, every failure -# collected and triaged by what the errors said about themselves - -# retryable failures go on the requeue manifest, non-retryable ones -# get parked with a reason. Nobody re-sends a letter addressed to a -# revoked mailbox. -# -# bundle exec ruby examples/dead_letter_office.rb -# -# Runs offline; failures are scripted across runs. - -require_relative "../lib/agentic" -require "tmpdir" - -Agentic.logger.level = :fatal - -JOURNAL = File.join(Dir.tmpdir, "agentic_dlo.journal.jsonl") -File.delete(JOURNAL) if File.exist?(JOURNAL) -journal = Agentic::ExecutionJournal.new(path: JOURNAL) - -RUNS = [ - {"sync:crm" => Agentic::Errors::LlmTimeoutError.new("read timeout"), - "sync:billing" => Agentic::Errors::LlmRateLimitError.new("429, slow down"), - "sync:tickets" => nil}, - {"sync:crm" => nil, # recovers on its own - "sync:billing" => Agentic::Errors::LlmRateLimitError.new("429, still angry"), - "sync:warehouse" => Agentic::Errors::LlmAuthenticationError.new("401 key revoked")}, - {"sync:tickets" => Agentic::Errors::LlmServerError.new("502 from upstream"), - "sync:warehouse" => Agentic::Errors::LlmAuthenticationError.new("401 key revoked")} -].freeze - -RUNS.each do |jobs| - orchestrator = Agentic::PlanOrchestrator.new( - concurrency_limit: 2, lifecycle_hooks: journal.lifecycle_hooks, - retry_policy: {max_retries: 0, retryable_errors: []} - ) - jobs.each do |name, error| - orchestrator.add_task(Agentic::Task.new( - description: name, agent_spec: {"name" => name, "instructions" => "sync"}, - payload: error - ), agent: ->(t) { - sleep(0.005) - raise t.payload if t.payload - - :ok - }) - end - orchestrator.execute_plan -end - -# --- the office: triage from the journal -------------------------------------- -state = Agentic::ExecutionJournal.replay(path: JOURNAL) - -# A letter is DEAD only if its most recent attempt failed -latest = {} -state.events.each do |event| - next unless %w[task_succeeded task_failed].include?(event[:event]) - - latest[event[:description]] = event -end -dead = latest.values.select { |e| e[:event] == "task_failed" } - -# Each failure's retryability was journaled AT THE MOMENT it happened -# (from the error's own retryable? verdict) - no read-time table to -# drift out of date when the taxonomy renames -requeue, parked = dead.partition { |e| e[:retryable] } -attempts = state.events.select { |e| e[:event] == "task_failed" }.group_by { |e| e[:description] } - -puts "DEAD LETTER OFFICE (#{RUNS.size} journaled runs)" -puts -puts " REQUEUE MANIFEST (transient failures - safe to retry):" -requeue.each do |letter| - puts format(" %-16s %-42s %d failed attempt(s) on record", - letter[:description], "#{letter[:error_type].split("::").last}: #{letter[:error]}", - attempts[letter[:description]].size) -end -puts -puts " PARKED (retrying will not help - a human must act):" -parked.each do |letter| - puts format(" %-16s %s", letter[:description], "#{letter[:error_type].split("::").last}: #{letter[:error]}") -end -puts -recovered = latest.values.select { |e| e[:event] == "task_succeeded" && attempts.key?(e[:description]) } -puts " recovered on their own (failed once, succeeded later - NOT dead):" -recovered.each { |e| puts " #{e[:description]}" } -puts -puts " the office triages by MOST RECENT attempt: sync:crm's old timeout" -puts " doesn't page anyone, and sync:tickets' early success doesn't" -puts " excuse its newer 502. a dead-letter queue that forgets recoveries" -puts " pages people for ghosts; one that forgets relapses buries real mail." -puts " and each verdict above came from the journal itself - retryability" -puts " was recorded when the error was fresh, not reconstructed later." diff --git a/examples/deploy_train.rb b/examples/deploy_train.rb deleted file mode 100644 index d238cb7..0000000 --- a/examples/deploy_train.rb +++ /dev/null @@ -1,82 +0,0 @@ -# frozen_string_literal: true - -# The Deploy Train: lint -> test -> build -> canary -> ship, where a -# red gate stops the train and everything behind it reports CANCELED, -# not skipped-and-shrugged. Run it twice: healthy train, then a canary -# failure. The second run is why deploy pipelines exist. -# -# bundle exec ruby examples/deploy_train.rb -# -# Runs offline; the canary's health is scripted. - -require_relative "../lib/agentic" - -Agentic.logger.level = :fatal # the failure is the demo, not news - -STATIONS = %w[lint test build canary ship announce].freeze - -def run_train(canary_healthy:) - orchestrator = nil - hooks = { - after_task_failure: ->(task_id:, task:, failure:, duration:) { - # A red gate stops the whole train - no half-shipped releases - orchestrator.cancel_plan - } - } - orchestrator = Agentic::PlanOrchestrator.new( - concurrency_limit: 1, - lifecycle_hooks: hooks, - retry_policy: {max_retries: 0, retryable_errors: []} - ) - - previous = nil - tasks = STATIONS.to_h do |station| - task = Agentic::Task.new( - description: station, - agent_spec: {"name" => station, "instructions" => "run the gate"} - ) - orchestrator.add_task(task, previous ? [previous] : [], agent: ->(t) { - sleep(0.01) - if t.description == "canary" && !canary_healthy - raise "error rate 4.2% exceeds 1% threshold" - end - - :green - }) - previous = task - [station, task] - end - - result = orchestrator.execute_plan - [result, tasks] -end - -def print_train(label, result, tasks) - puts label - tasks.each do |station, task| - task_result = result.results[task.id] - status = if task_result.nil? - "CANCELED (never left the yard)" - elsif task_result.successful? - "green" - elsif task_result.canceled? - "CANCELED" - else - "RED - #{task_result.failure.message}" - end - puts format(" %-9s %s", station, status) - end - puts format(" train status: %s", result.status) - puts -end - -healthy, healthy_tasks = run_train(canary_healthy: true) -print_train("monday's deploy:", healthy, healthy_tasks) - -broken, broken_tasks = run_train(canary_healthy: false) -print_train("friday's deploy:", broken, broken_tasks) - -puts "friday's verdict is precise: the train is :partial_failure (a gate" -puts "went RED), and the cars behind it are CANCELED, not silently" -puts "skipped. failure outranks cancellation in the status - the headline" -puts "is WHY the train stopped, the manifest shows what never shipped." diff --git a/examples/doc_coverage.rb b/examples/doc_coverage.rb deleted file mode 100644 index f37b87b..0000000 --- a/examples/doc_coverage.rb +++ /dev/null @@ -1,100 +0,0 @@ -# frozen_string_literal: true - -# The Documentation Surveyor: measures YARD comment coverage for every -# public method in a lib/ tree. One survey task per file fans out; a -# single report task fans all the surveys in through the dependency -# pipe and renders the coverage table. -# -# bundle exec ruby examples/doc_coverage.rb [lib_dir] -# -# Runs offline; Prism reads the definitions, the comments speak for -# themselves. - -require_relative "../lib/agentic" -require "prism" - -LIB = File.expand_path(ARGV.first || "#{__dir__}/../lib") - -# Walks a parse tree counting public defs and whether a comment -# immediately precedes each one -def survey(parsed) - comment_lines = parsed.comments.map { |c| c.location.start_line }.to_set - stats = {documented: 0, undocumented: [], private_from: nil} - - # Track `private` markers statement-by-statement within class bodies - walk = lambda do |node, private_scope| - return unless node - - if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode) - inner = false - node.child_nodes.each { |child| inner = walk.call(child, inner) || inner } - private_scope - elsif node.is_a?(Prism::StatementsNode) - scope = private_scope - node.child_nodes.each { |child| scope = walk.call(child, scope) || scope } - scope - elsif node.is_a?(Prism::CallNode) && node.name == :private && node.receiver.nil? && node.arguments.nil? - true - elsif node.is_a?(Prism::DefNode) - unless private_scope - if comment_lines.include?(node.location.start_line - 1) - stats[:documented] += 1 - else - stats[:undocumented] << {name: node.name.to_s, line: node.location.start_line} - end - end - false - else - node.child_nodes.each { |child| walk.call(child, private_scope) } - false - end - end - - walk.call(parsed.value, false) - stats -end - -orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 8) -files = Dir[File.join(LIB, "**", "*.rb")].sort - -surveys = files.map do |path| - task = Agentic::Task.new( - description: path.delete_prefix("#{LIB}/"), - agent_spec: {"name" => "Surveyor", "instructions" => "Survey documentation"}, - payload: path - ) - orchestrator.add_task(task, agent: ->(t) { survey(Prism.parse_file(t.payload)) }) - task -end - -report = Agentic::Task.new( - description: "coverage report", - agent_spec: {"name" => "Reporter", "instructions" => "Aggregate"} -) -orchestrator.add_task(report, surveys, agent: ->(t) { - rows = surveys.map { |s| - stats = t.output_of(s) - total = stats[:documented] + stats[:undocumented].size - {file: s.description, documented: stats[:documented], total: total, - missing: stats[:undocumented]} - } - covered = rows.sum { |r| r[:documented] } - total = rows.sum { |r| r[:total] } - {rows: rows, covered: covered, total: total} -}) - -result = orchestrator.execute_plan -data = result.results[report.id].output - -puts "DOCUMENTATION SURVEY of #{LIB}" -puts format(" %d/%d public methods documented (%.1f%%)", - data[:covered], data[:total], 100.0 * data[:covered] / data[:total]) -puts -worst = data[:rows].select { |r| r[:total] > 0 } - .sort_by { |r| [Float(r[:documented]) / r[:total], -r[:total]] }.first(5) -puts "least documented files:" -worst.each do |row| - puts format(" %5.1f%% %-46s (%d/%d)", - 100.0 * row[:documented] / row[:total], row[:file], row[:documented], row[:total]) - row[:missing].first(2).each { |m| puts " missing: ##{m[:name]} (line #{m[:line]})" } -end diff --git a/examples/duck_agents.rb b/examples/duck_agents.rb deleted file mode 100644 index c3e4b42..0000000 --- a/examples/duck_agents.rb +++ /dev/null @@ -1,100 +0,0 @@ -# frozen_string_literal: true - -# Duck Agents: the agent: seam asks one question - "can you be called -# with a task?" - and five differently-shaped objects all answer yes: -# a lambda, an instance, a Method, a curried proc, and a decorator -# wrapping any of the others. Nobody was asked what class they are. -# Ask for what you need, not for who someone is. -# -# bundle exec ruby examples/duck_agents.rb -# -# Runs offline; one plan, five ducks, one timing decorator. - -require_relative "../lib/agentic" - -Agentic.logger.level = :fatal - -# Duck 1: a lambda - the shape you reach for first -fetch = ->(task) { {records: 12, source: task.description} } - -# Duck 2: a plain object with #call - state and a real home for logic -class Deduper - def initialize - @seen = 0 - end - - def call(_task) - @seen += 1 - {unique: 9, dropped: 3, pass: @seen} - end -end - -# Duck 3: a Method object - module functions join the plan unwrapped -module Stats - def self.summarize(_task) - {mean: 4.2, max: 9} - end -end - -# Duck 4: a curried proc - configuration applied ahead of time, -# leaving exactly the one-argument shape the seam asks for -render = lambda { |format, _task| {rendered: true, format: format} } -render_html = render.curry["html"] - -# Duck 5: a decorator - wraps ANY of the above, because it only -# relies on the same one-message contract it provides -class Timed - def initialize(inner) - @inner = inner - end - - def call(task) - started = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @inner.call(task).merge( - timed_ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(2) - ) - end -end - -def task(name) - Agentic::Task.new(description: name, agent_spec: {"name" => name, "instructions" => "work"}) -end - -orchestrator = Agentic::PlanOrchestrator.new -fetch_task = task("fetch") -dedupe_task = task("dedupe") -stats_task = task("stats") -render_task = task("render") -audit_task = task("audit") - -orchestrator.add_task(fetch_task, agent: fetch) -orchestrator.add_task(dedupe_task, [fetch_task], agent: Deduper.new) -orchestrator.add_task(stats_task, [dedupe_task], agent: Stats.method(:summarize)) -orchestrator.add_task(render_task, [stats_task], agent: render_html) -orchestrator.add_task(audit_task, [render_task], agent: Timed.new(->(_t) { {audited: true} })) - -result = orchestrator.execute_plan - -DUCKS = { - "fetch" => "lambda", - "dedupe" => "instance with #call", - "stats" => "Method object", - "render" => "curried proc", - "audit" => "decorator around a lambda" -}.freeze - -puts "DUCK AGENTS (one seam, five shapes)" -puts -[fetch_task, dedupe_task, stats_task, render_task, audit_task].each do |t| - output = result.task_result(t.id).output - puts format(" %-8s %-26s -> %s", t.description, DUCKS[t.description], output) -end - -puts -puts " the seam asked one question: respond to call (or execute) with a" -puts " task. it never asked anyone's class, so anything shaped right" -puts " walks in: closures for the quick cases, instances when logic" -puts " deserves a home, Method objects when it already has one, curry" -puts " for pre-applied config, and decorators that stack because they" -puts " honor the same contract they consume. depend on messages and" -puts " every object ever written - and not yet written - is a plugin." diff --git a/examples/dungeon_crawl.rb b/examples/dungeon_crawl.rb deleted file mode 100644 index 0b04456..0000000 --- a/examples/dungeon_crawl.rb +++ /dev/null @@ -1,75 +0,0 @@ -# frozen_string_literal: true - -# The Dungeon Crawl: a quest is a plan, rooms are tasks, and doors are -# dependencies. The map is drawn from the orchestrator's own graph -# BEFORE the run - then the party delves, and the treasure fans in. -# -# bundle exec ruby examples/dungeon_crawl.rb [seed] -# -# Runs offline. The dungeon is the dependency graph; there is no -# second map to fall out of date. - -require_relative "../lib/agentic" - -seed = (ARGV.first || 4).to_i -rng = Random.new(seed) - -LOOT = { - "Entrance Hall" => ["a rusty key", "a torch stub", "an ominous note"], - "Spider Nest" => ["silk rope", "a shed fang", "someone's boot"], - "Flooded Crypt" => ["a waterlogged tome", "a silver coin", "an eyeless fish"], - "Treasury" => ["the Amulet of Yendor", "a chest of coppers", "a suspicious goose"] -}.freeze - -orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 2) - -rooms = {} -delve = ->(t) { - sleep(0.02) # every room takes delving - LOOT.fetch(t.description).sample(random: rng) -} - -def room(name) - Agentic::Task.new(description: name, agent_spec: {"name" => name, "instructions" => "delve"}) -end - -rooms["Entrance Hall"] = room("Entrance Hall") -rooms["Spider Nest"] = room("Spider Nest") -rooms["Flooded Crypt"] = room("Flooded Crypt") -rooms["Treasury"] = room("Treasury") - -orchestrator.add_task(rooms["Entrance Hall"], agent: delve) -orchestrator.add_task(rooms["Spider Nest"], [rooms["Entrance Hall"]], agent: delve) -orchestrator.add_task(rooms["Flooded Crypt"], [rooms["Entrance Hall"]], agent: delve) -orchestrator.add_task(rooms["Treasury"], - needs: {web: rooms["Spider Nest"], depths: rooms["Flooded Crypt"]}, - agent: ->(t) { - "#{LOOT.fetch(t.description).sample(random: rng)} (unlocked with #{t.needs.web} and #{t.needs.depths})" - }) - -# --- the map, drawn from the plan itself ------------------------------------ -graph = orchestrator.graph -names = graph[:tasks].transform_values(&:description) - -puts "THE MAP (drawn from orchestrator.graph, in delving order)" -puts -graph[:order].each do |room_id| - door_ids = graph[:dependencies][room_id] - if door_ids.empty? - puts " [#{names[room_id]}] <- you are here" - else - puts " [#{names[room_id]}] doors from: #{door_ids.map { |d| names[d] }.join(", ")}" - end -end - -# --- the delve --------------------------------------------------------------- -result = orchestrator.execute_plan - -puts -puts "THE DELVE (seed #{seed})" -rooms.each do |name, task| - puts " #{name}: found #{result.results[task.id].output}" -end -puts -puts "(#{result.status} in #{(result.execution_time * 1000).round}ms - the nest and" -puts " the crypt were delved in parallel; the treasury needed both keys)" diff --git a/examples/durable_batch.rb b/examples/durable_batch.rb deleted file mode 100644 index 5ceb8eb..0000000 --- a/examples/durable_batch.rb +++ /dev/null @@ -1,104 +0,0 @@ -# frozen_string_literal: true - -# The Durable Batch: six billable "LLM calls" run under an -# ExecutionJournal. Mid-batch, the process dies for real - exit!, no -# cleanup, the honest kill -9. Then a second process replays the -# journal and finishes the batch WITHOUT re-paying for completed work. -# -# bundle exec ruby examples/durable_batch.rb -# -# Runs offline; the "API" is sleep plus an invoice counter. The number -# to watch is the last one: total paid should equal the batch size, -# crash or no crash. - -require_relative "../lib/agentic" -require "tmpdir" - -# exit! discards buffered IO - the child's narration would die with it -$stdout.sync = true - -JOURNAL = File.join(Dir.tmpdir, "agentic_durable_batch.journal.jsonl") -File.delete(JOURNAL) if File.exist?(JOURNAL) - -INVOICES = %w[invoice-1 invoice-2 invoice-3 invoice-4 invoice-5 invoice-6].freeze -COST_PER_CALL = 0.25 # dollars of imaginary tokens - -# Bills the imaginary API, then optionally dies like a deploy -BillableAgent = Struct.new(:crash_on, :billed) do - def execute(prompt) - invoice = prompt[/invoice-\d+/] - sleep(0.05) # the API call we pay for - billed << invoice - - if invoice == crash_on - puts " !! power cut during #{invoice} - process dying with exit!(97)" - Process.exit!(97) # no ensure blocks, no at_exit - a real crash - end - - {"invoice" => invoice, "status" => "paid"} - end -end - -Desk = Struct.new(:agent) do - def get_agent_for_task(_task) - agent - end -end - -def run_batch(label, skip: [], crash_on: nil) - journal = Agentic::ExecutionJournal.new(path: JOURNAL) - billed = [] - orchestrator = Agentic::PlanOrchestrator.new( - concurrency_limit: 1, # deterministic order, one call in flight - lifecycle_hooks: journal.lifecycle_hooks - ) - - todo = INVOICES - skip - todo.each do |invoice| - orchestrator.add_task(Agentic::Task.new( - description: invoice, - agent_spec: {"name" => "Biller", "instructions" => "Process #{invoice}"}, - input: {} - )) - end - - puts "#{label}: processing #{todo.size} invoice(s): #{todo.join(", ")}" - orchestrator.execute_plan(Desk.new(BillableAgent.new(crash_on, billed))) - billed -end - -# Maps journal task ids back to invoice names via task_started events -def completed_invoices(state) - names = state.events.each_with_object({}) do |event, map| - map[event[:task_id]] = event[:description] if event[:event] == "task_started" - end - state.completed_task_ids.map { |id| names[id] } -end - -# --- Run 1: a child process that will not survive invoice-4 --------------- -child = fork do - run_batch("run 1", crash_on: "invoice-4") -end -_, status = Process.wait2(child) -puts " child exited with status #{status.exitstatus} (journal survived on disk)" -puts - -# --- The journal knows exactly what was paid for -------------------------- -state = Agentic::ExecutionJournal.replay(path: JOURNAL) -paid = completed_invoices(state) -puts "journal replay: #{paid.size} invoice(s) already paid: #{paid.join(", ")}" -puts " #{INVOICES.size - paid.size} remain (including the one mid-flight when we died)" -puts - -# --- Run 2: same batch, skip what the journal proves is done -------------- -billed_in_run2 = run_batch("run 2", skip: paid) -puts - -total_calls = paid.size + 1 + billed_in_run2.size # +1: paid for, then died during -naive_calls = paid.size + 1 + INVOICES.size # rerunning the whole batch -puts "RECEIPT" -puts " run 1 paid: #{paid.size + 1} calls (#{paid.size} journaled + 1 lost to the crash)" -puts " run 2 paid: #{billed_in_run2.size} calls" -puts format(" total spend: $%.2f for %d invoices (naive rerun-everything: $%.2f)", - total_calls * COST_PER_CALL, INVOICES.size, naive_calls * COST_PER_CALL) -puts " journal: #{JOURNAL}" diff --git a/examples/error_taxonomy_drill.rb b/examples/error_taxonomy_drill.rb deleted file mode 100644 index 1d4a400..0000000 --- a/examples/error_taxonomy_drill.rb +++ /dev/null @@ -1,79 +0,0 @@ -# frozen_string_literal: true - -# The Error Taxonomy Drill: three tasks fail three different ways - -# a rate limit (retryable, says the error itself), an auth failure -# (not retryable, says the error itself), and a mystery error (no -# opinion, so the policy's type list decides). One retry policy, -# three correct outcomes, because errors now testify. -# -# bundle exec ruby examples/error_taxonomy_drill.rb -# -# Runs offline and deterministically. - -require_relative "../lib/agentic" - -attempts = Hash.new(0) - -drills = { - "rate-limited sync" => lambda { |task| - attempts[task.description] += 1 - if attempts[task.description] < 3 - raise Agentic::Errors::LlmRateLimitError.new("429 slow down", retry_after: 1) - end - "synced on attempt #{attempts[task.description]}" - }, - "bad-credentials sync" => lambda { |task| - attempts[task.description] += 1 - raise Agentic::Errors::LlmAuthenticationError.new("401 key revoked") - }, - "mystery-error sync" => lambda { |task| - attempts[task.description] += 1 - raise "something vague" if attempts[task.description] < 2 - "recovered on attempt #{attempts[task.description]}" - } -} - -orchestrator = Agentic::PlanOrchestrator.new( - concurrency_limit: 3, - retry_policy: { - max_retries: 3, - backoff_strategy: :constant, - backoff_constant: 0.02, - # The type list is the fallback for errors with no opinion. - # RuntimeError is listed; the auth error's own verdict will overrule - # any list. That's the point. - retryable_errors: ["RuntimeError", "Agentic::Errors::LlmAuthenticationError"] - } -) - -tasks = drills.map do |name, drill| - task = Agentic::Task.new( - description: name, - agent_spec: {"name" => name, "instructions" => "call the API"}, - payload: drill - ) - orchestrator.add_task(task, agent: ->(t) { t.payload.call(t) }) - task -end - -result = orchestrator.execute_plan - -puts "ERROR TAXONOMY DRILL (max 3 retries for everyone)" -puts -tasks.each do |task| - task_result = result.results[task.id] - outcome = task_result.successful? ? task_result.output : "gave up: #{task_result.failure.message}" - verdict = task_result.successful? ? "OK " : "DEAD" - puts format(" %s %-22s %d attempt(s) %s", verdict, task.description, attempts[task.description], outcome) -end - -puts -puts "plan: #{result.status}" -puts -puts "why each outcome is right:" -puts " - the rate limit said retryable? -> true: retried until it cleared" -puts " - the auth error said retryable? -> false: ONE attempt, even though" -puts " someone unwisely put it in the retryable_errors list. a revoked" -puts " key does not improve with persistence; the error knew that" -puts " - the mystery RuntimeError had no opinion: the policy's type list" -puts " decided, and it earned its second chance" diff --git a/examples/eval_scorers.rb b/examples/eval_scorers.rb deleted file mode 100644 index 62e8f27..0000000 --- a/examples/eval_scorers.rb +++ /dev/null @@ -1,110 +0,0 @@ -# frozen_string_literal: true - -# Eval Scorers: the same eval set scored four ways - exact match, -# keyword containment, numeric tolerance, and a judge rubric. Exact -# scoring drowns one real failure in wording noise; the right scorer -# per field reports exactly the failure that matters. The harness -# shape never changes - only the scorer column does. -# -# bundle exec ruby examples/eval_scorers.rb -# -# Runs offline; exits 1 because one capability blind spot is real. - -require_relative "../lib/agentic" - -SEVERITY = { - "damaged item" => 0.9, - "refund requested" => 0.8, - "account email update" => 0.2 -}.freeze - -spec = Agentic::CapabilitySpecification.new( - name: "summarize_ticket", description: "Summarize a support ticket", version: "1.0.0", - inputs: {text: {type: "string", required: true}}, - outputs: { - summary: {type: "string", required: true}, - priority: {type: "number", required: true, min: 0, max: 1} - } -) -Agentic.register_capability(spec, Agentic::CapabilityProvider.new(capability: spec, implementation: ->(i) { - text = i[:text].downcase - fragments = [] - fragments << "damaged item" if text.match?(/broken|damaged/) - fragments << "refund requested" if text.match?(/refund|money back/) - fragments << "account email update" if text.match?(/email/) - # the blind spot: no rule for crashes - those tickets read as general inquiries - summary = fragments.empty? ? "general inquiry" : "customer reports #{fragments.join(", ")}" - {summary: summary, priority: fragments.map { |f| SEVERITY[f] }.max || 0.3} -})) - -# --- the scorer seam: (expected, actual) -> score in 0.0..1.0 ------------------ -SCORERS = { - exact: ->(expected, actual) { (expected == actual) ? 1.0 : 0.0 }, - contains: ->(keywords, actual) { keywords.count { |k| actual.to_s.downcase.include?(k) }.fdiv(keywords.size) }, - tolerance: ->(spec, actual) { ((spec[:value] - actual).abs <= spec[:within]) ? 1.0 : 0.0 }, - judge: ->(rubric, actual) { rubric.call(actual) } -}.freeze -PASS_AT = 0.99 # judge scorers may grade partially; everything else is 0-or-1 - -NAMES_A_PROBLEM = ->(summary) { (summary.include?("general inquiry") ? 0.0 : 0.6) } -NAMES_AN_ACTION = ->(summary) { summary.match?(/request|update/) ? 0.4 : 0.0 } -RUBRIC = ->(summary) { NAMES_A_PROBLEM.call(summary) + NAMES_AN_ACTION.call(summary) } - -CASES = [ - {ticket: "My package arrived broken and I want my money back", - checks: [ - {field: :summary, scorer: :exact, expected: "Damaged item; refund requested"}, - {field: :summary, scorer: :contains, expected: %w[damaged refund]}, - {field: :priority, scorer: :tolerance, expected: {value: 0.9, within: 0.15}}, - {field: :summary, scorer: :judge, expected: RUBRIC} - ]}, - {ticket: "How do I change my email address?", - checks: [ - {field: :summary, scorer: :exact, expected: "customer reports account email update"}, - {field: :summary, scorer: :contains, expected: %w[email]}, - {field: :priority, scorer: :tolerance, expected: {value: 0.2, within: 0.1}} - ]}, - {ticket: "The app crashes every time I open settings and I lost work", - checks: [ - {field: :summary, scorer: :exact, expected: "Crash in settings; data loss"}, - {field: :summary, scorer: :contains, expected: %w[crash settings]}, - {field: :priority, scorer: :tolerance, expected: {value: 0.95, within: 0.1}}, - {field: :summary, scorer: :judge, expected: RUBRIC} - ]} -].freeze - -provider = Agentic::AgentCapabilityRegistry.instance.get_provider("summarize_ticket") -results = CASES.flat_map.with_index(1) do |kase, number| - output = provider.execute(text: kase[:ticket]) - kase[:checks].map do |check| - score = SCORERS.fetch(check[:scorer]).call(check[:expected], output[check[:field]]) - {case: number, scorer: check[:scorer], field: check[:field], score: score, pass: score >= PASS_AT} - end -end - -puts "EVAL SCORERS: one eval set, four ways to say \"good enough\"" -puts -CASES.each_with_index do |kase, index| - puts " case #{index + 1}: #{kase[:ticket].inspect}" - results.select { |r| r[:case] == index + 1 }.each do |r| - puts format(" %-10s on %-9s %s (%.2f)", r[:scorer], r[:field], r[:pass] ? "PASS" : "FAIL", r[:score]) - end - puts -end - -by_scorer = results.group_by { |r| r[:scorer] } -puts " scoreboard:" -by_scorer.each do |scorer, rows| - puts format(" %-10s %d/%d pass", scorer, rows.count { |r| r[:pass] }, rows.size) -end - -exact_fails = by_scorer[:exact].count { |r| !r[:pass] } -real_fails = results.reject { |r| r[:scorer] == :exact }.reject { |r| r[:pass] }.map { |r| r[:case] }.uniq -puts -puts " exact flagged #{exact_fails}/3 cases, but most of that is wording noise." -puts " the field-appropriate scorers flagged only case #{real_fails.join(", ")} - the crash" -puts " ticket - and that failure is REAL: the capability has no rule for" -puts " crashes, so a data-loss ticket scores priority 0.3. same harness," -puts " same cases; the scorer column is what makes a failure mean something." - -exit(real_fails.any? ? 1 : 0) diff --git a/examples/event_prof.rb b/examples/event_prof.rb deleted file mode 100644 index 367687f..0000000 --- a/examples/event_prof.rb +++ /dev/null @@ -1,72 +0,0 @@ -# frozen_string_literal: true - -# EventProf for Plans: TestProf taught test suites to answer "where -# does the time GO?" by group, not by file. Same question for plans: -# tag every task by its kind (llm:, db:, render:), collect durations -# from the lifecycle hooks, and report task-seconds by tag - plus the -# number nobody computes: how much of that time ran in parallel, and -# how much of the wall clock one tag owns hostage. -# -# bundle exec ruby examples/event_prof.rb -# -# Runs offline; durations are scripted, accounting is real. - -require_relative "../lib/agentic" - -Agentic.logger.level = :fatal - -WORK = { - "db:fetch_users" => 0.03, "db:fetch_orders" => 0.04, "db:fetch_stock" => 0.03, - "llm:summarize" => 0.22, "llm:classify" => 0.18, "llm:draft" => 0.25, - "render:header" => 0.01, "render:body" => 0.02, "render:pdf" => 0.05 -}.freeze - -samples = [] -hooks = { - after_task_success: ->(task_id:, task:, result:, duration:) { - samples << [task.description, duration] - } -} - -orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 3, lifecycle_hooks: hooks) -tasks = WORK.to_h { |name, cost| [name, Agentic::Task.new(description: name, agent_spec: {"name" => name, "instructions" => "w"})] } - -# db feeds llm feeds render - three stages, three lanes -db, llm, render = %w[db llm render].map { |prefix| tasks.select { |n, _| n.start_with?(prefix) }.values } -db.each { |t| orchestrator.add_task(t, agent: ->(task) { sleep(WORK[task.description]) }) } -llm.each { |t| orchestrator.add_task(t, db, agent: ->(task) { sleep(WORK[task.description]) }) } -render.each { |t| orchestrator.add_task(t, llm, agent: ->(task) { sleep(WORK[task.description]) }) } - -wall_start = Process.clock_gettime(Process::CLOCK_MONOTONIC) -orchestrator.execute_plan -wall = Process.clock_gettime(Process::CLOCK_MONOTONIC) - wall_start - -# --- the profile ---------------------------------------------------------------- -by_tag = samples.group_by { |name, _| name[/\A\w+/] } - .transform_values { |rows| {seconds: rows.sum { |_, d| d }, count: rows.size, worst: rows.max_by { |_, d| d }} } -task_seconds = samples.sum { |_, d| d } - -puts "EVENT PROF (task-seconds by tag; wall clock #{(wall * 1000).round}ms, 3 lanes)" -puts -puts format(" %-8s %-10s %-8s %-8s %s", "tag", "seconds", "share", "tasks", "worst offender") -by_tag.sort_by { |_, v| -v[:seconds] }.each do |tag, stats| - share = stats[:seconds] / task_seconds * 100 - puts format(" %-8s %6.0fms %5.1f%% %-8d %s (%.0fms) %s", - tag, stats[:seconds] * 1000, share, stats[:count], - stats[:worst][0], stats[:worst][1] * 1000, "#" * (share / 3).round) -end - -parallelism = task_seconds / wall -puts -puts format(" task-seconds: %.0fms across %.0fms of wall = %.1fx effective parallelism", task_seconds * 1000, wall * 1000, parallelism) -puts -llm_share = by_tag["llm"][:seconds] / task_seconds * 100 -puts " the TestProf move is reading the SHARE column before touching any" -puts format(" code: llm owns %.0f%% of all task-seconds, so a 20%% win there is", llm_share) -puts " worth more than deleting the entire render stage - optimizing" -puts " db: or render: is polishing doorknobs on a burning building." -puts format(" and the parallelism line is the second lesson: %.1fx on 3 lanes", parallelism) -puts " means the stage barriers are eating part of the overlap -" -puts " llm tasks can't start until ALL db tasks finish. profile by" -puts " group, fix the biggest group, re-profile. boring, effective," -puts " and the hooks made it fifteen lines." diff --git a/examples/exquisite_corpse.rb b/examples/exquisite_corpse.rb deleted file mode 100644 index a223226..0000000 --- a/examples/exquisite_corpse.rb +++ /dev/null @@ -1,62 +0,0 @@ -# frozen_string_literal: true - -# The Exquisite Corpse: three artists each draw one part of a creature -# without seeing the others' work; the assembler receives all three -# parts BY NAME and stacks them. The surrealists played this on folded -# paper; we play it on a dependency graph. -# -# bundle exec ruby examples/exquisite_corpse.rb [seed] -# -# Runs offline. Every seed is a different creature. - -require_relative "../lib/agentic" - -seed = (ARGV.first || rand(1000)).to_i -rng = Random.new(seed) - -PARTS = { - head: [ - [" /\\_/\\ ", " ( o.o ) ", " > ^ < "], - [" .---. ", " ( @ @ ) ", " \\_-_/ "], - [" ,***, ", " { > < } ", " \"---\" "] - ], - torso: [ - [" /|___|\\ ", " | (===) | ", " \\|___|/ "], - [" <#####> ", " |#####| ", " <#####> "], - [" )~~~( ", " ( ~~~ ) ", " )~~~( "] - ], - legs: [ - [" | | ", " | | ", " _| |_ "], - [" d b ", " | | ", " =$ $= "], - [" \\ / ", " \\ / ", " _/^\\_ "] - ] -}.freeze - -orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 3) - -artists = PARTS.to_h do |part, options| - task = Agentic::Task.new( - description: "draw the #{part}", - agent_spec: {"name" => "Artist of the #{part}", "instructions" => "draw without peeking"}, - payload: options - ) - orchestrator.add_task(task, agent: ->(t) { t.payload.sample(random: rng) }) - [part, task] -end - -reveal = Agentic::Task.new( - description: "unfold the paper", - agent_spec: {"name" => "Assembler", "instructions" => "stack the parts"} -) -orchestrator.add_task(reveal, needs: artists, agent: ->(t) { - t.needs.head + t.needs.torso + t.needs.legs -}) - -result = orchestrator.execute_plan - -puts "EXQUISITE CORPSE (seed #{seed})" -puts -result.results[reveal.id].output.each { |line| puts " #{line}" } -puts -puts "three artists, no peeking - the assembler read the parts by name:" -puts " t.needs.head, t.needs.torso, t.needs.legs" diff --git a/examples/failure_weather.rb b/examples/failure_weather.rb deleted file mode 100644 index 2a7f4ba..0000000 --- a/examples/failure_weather.rb +++ /dev/null @@ -1,104 +0,0 @@ -# frozen_string_literal: true - -# The Failure Weather Report: a journal of three days, read as a -# forecast. Retryable failures are WEATHER - showers that pass on -# their own or with an umbrella. Non-retryable failures are CLIMATE - -# no amount of waiting fixes a drought; someone must dig a well. -# The journal now records which is which at the moment it rains. -# -# bundle exec ruby examples/failure_weather.rb -# -# Runs offline; three scripted days of mixed conditions. - -require_relative "../lib/agentic" -require "tmpdir" - -Agentic.logger.level = :fatal - -JOURNAL = File.join(Dir.tmpdir, "agentic_weather.journal.jsonl") -File.delete(JOURNAL) if File.exist?(JOURNAL) -journal = Agentic::ExecutionJournal.new(path: JOURNAL) - -DAYS = [ - {name: "Monday", - jobs: {"digest" => Agentic::Errors::LlmRateLimitError.new("429"), - "backup" => Agentic::Errors::LlmTimeoutError.new("slow disk"), - "invoice" => Agentic::Errors::LlmAuthenticationError.new("401 key expired"), - "greet" => nil}}, - {name: "Tuesday", - jobs: {"digest" => nil, # the shower passed - "backup" => Agentic::Errors::LlmTimeoutError.new("slow disk again"), - "invoice" => Agentic::Errors::LlmAuthenticationError.new("401 key expired"), - "greet" => nil}}, - {name: "Wednesday", - jobs: {"digest" => nil, - "backup" => nil, # cleared overnight - "invoice" => Agentic::Errors::LlmAuthenticationError.new("401 key expired"), - "greet" => nil}} -].freeze - -DAYS.each do |day| - orchestrator = Agentic::PlanOrchestrator.new( - lifecycle_hooks: journal.lifecycle_hooks, - retry_policy: {max_retries: 0, retryable_errors: []} - ) - day[:jobs].each do |name, error| - orchestrator.add_task(Agentic::Task.new( - description: name, agent_spec: {"name" => name, "instructions" => "run"}, - payload: error - ), agent: ->(t) { - raise t.payload if t.payload - - :ok - }) - end - orchestrator.execute_plan -end - -# --- the forecast desk --------------------------------------------------------- -state = Agentic::ExecutionJournal.replay(path: JOURNAL) -day_events = state.events.slice_when { |a, b| - a[:event] == "plan_completed" && b[:event] != "plan_completed" -}.to_a - -def sky(failed) - weather = failed.count { |e| e[:retryable] } - climate = failed.count { |e| e[:retryable] == false } - return "clear skies" if failed.empty? - return "storm damage (#{climate} structural)" if climate.positive? && weather.positive? - return "drought continues" if climate.positive? - - "passing showers (#{weather})" -end - -puts "FAILURE WEATHER REPORT (#{DAYS.size} journaled days)" -puts -day_events.each_with_index do |events, index| - failed = events.select { |e| e[:event] == "task_failed" } - puts format(" %-10s %-28s %s", DAYS[index][:name], sky(failed), - failed.map { |e| e[:description] }.join(", ")) -end -puts - -# Weather clears; climate persists. The distinction IS the journal's -# retryable verdict, recorded when each drop fell. -latest = {} -state.events.each do |e| - latest[e[:description]] = e if %w[task_failed task_succeeded].include?(e[:event]) -end -weather_jobs = latest.values.select { |e| e[:event] == "task_failed" && e[:retryable] } -climate_jobs = latest.values.select { |e| e[:event] == "task_failed" && e[:retryable] == false } -cleared = state.events.select { |e| e[:event] == "task_failed" }.map { |e| e[:description] }.uniq - .select { |d| latest[d][:event] == "task_succeeded" } - -puts " extended forecast:" -cleared.each { |d| puts " #{d}: rained earlier this week, clear now - weather does that" } -weather_jobs.each { |e| puts " #{e[:description]}: still raining, but it is rain - bring retries" } -climate_jobs.each { |e| puts " #{e[:description]}: this is not weather, it is climate - #{e[:error]}" } -puts -rain = state.events.select { |e| e[:event] == "task_failed" } -puts " #{rain.size} rainy events this week: #{rain.count { |e| e[:retryable] }} were weather (they passed, or will)," -puts " and #{rain.count { |e| e[:retryable] == false }} were the same drought, reported daily." -puts " no forecast fixes a drought: invoice's 401 has held for three days" -puts " and will hold forever, because keys do not expire back. the journal" -puts " told us which failures to wait out and which to dig a well for." diff --git a/examples/fair_share.rb b/examples/fair_share.rb deleted file mode 100644 index e5d6ab3..0000000 --- a/examples/fair_share.rb +++ /dev/null @@ -1,98 +0,0 @@ -# frozen_string_literal: true - -# Fair Share: two tenants, one upstream. The global ceiling is fair to -# REQUESTS - first come, first served - but tenant A brings 6 workers -# and tenant B brings 2, so "fair to requests" quietly means "A gets -# triple". Per-tenant ceilings under the global door restore fairness -# to TENANTS; resize keeps the idle tenant's share from stranding. -# -# bundle exec ruby examples/fair_share.rb -# -# Runs offline; watch B's number - it tells the whole story. - -require_relative "../lib/agentic" -require "async" - -GLOBAL = 4 -JOB = 0.01 -PHASE = 0.24 -WORKERS = {a: 6, b: 2}.freeze # A is greedy; B just wants its two lanes - -global = Agentic::RateLimit.new(GLOBAL) -share_a = Agentic::RateLimit.new(2) -share_b = Agentic::RateLimit.new(2) -tenant_a = share_a.and(global) # own share first, then the shared door -tenant_b = share_b.and(global) - -served = Hash.new(0) - -# A tenant is N worker fibers, each pushing as hard as its limiter allows -def run_tenants(served, plan) - Sync do - plan.flat_map { |key, (limit, workers)| - workers.times.map { - Async do - deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + PHASE - while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline - limit.acquire { - sleep(JOB) - served[key] += 1 - } - end - end - } - }.each(&:wait) - end -end - -def phase(title, served, shares) - before = served.dup - yield - puts format(" %-36s A: %-4d B: %-4d (shares %s)", - title, served[:a] - before[:a], served[:b] - before[:b], shares) -end - -puts "FAIR SHARE (global ceiling #{GLOBAL}; A brings #{WORKERS[:a]} workers, B brings #{WORKERS[:b]})" -puts - -# Phase 1 - no shares: the door is fair to requests, so the tenant -# with more workers takes proportionally more. B wants 2 lanes' worth -# and gets half of it. -phase("no shares, one door for all", served, "-/-") do - run_tenants(served, {a: [global, WORKERS[:a]], b: [global, WORKERS[:b]]}) -end - -# Phase 2 - 2/2 shares under the door: B reaches its full demand no -# matter how many workers A hires -phase("2/2 shares, same greedy A", served, "2/2") do - run_tenants(served, {a: [tenant_a, WORKERS[:a]], b: [tenant_b, WORKERS[:b]]}) -end - -# Phase 3 - B goes idle; static shares strand B's lanes -phase("B idle, static 2/2 shares", served, "2/2") do - run_tenants(served, {a: [tenant_a, WORKERS[:a]], b: [tenant_b, 0]}) -end - -# Phase 4 - same idle B, but the spare share is lent to A, live -share_a.resize(4) -share_b.resize(1) -phase("B idle, shares rebalanced 4/1", served, "4/1") do - run_tenants(served, {a: [tenant_a, WORKERS[:a]], b: [tenant_b, 0]}) -end - -# Phase 5 - B returns; the share comes back, live -share_a.resize(2) -share_b.resize(2) -phase("B returns, shares back to 2/2", served, "2/2") do - run_tenants(served, {a: [tenant_a, WORKERS[:a]], b: [tenant_b, WORKERS[:b]]}) -end - -puts -puts " phase 1 is the quiet outage: nothing errored, nothing paged - B" -puts " simply got half its lanes because the door counts requests, not" -puts " tenants. phase 2 buys tenant-fairness by composition: own share" -puts " first, then the door. phase 3 is the tax static shares charge -" -puts " B's idle lanes served nobody - and phases 4-5 are the round-9" -puts " payoff: resize lends the idle share and takes it back, live," -puts " while the composition never changes shape. fairness is a policy;" -puts " make it an object and it becomes an adjustable one." diff --git a/examples/feature_flags.rb b/examples/feature_flags.rb deleted file mode 100644 index 20b0da1..0000000 --- a/examples/feature_flags.rb +++ /dev/null @@ -1,96 +0,0 @@ -# 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/flaky_api_drill.rb b/examples/flaky_api_drill.rb deleted file mode 100644 index 5b1bd81..0000000 --- a/examples/flaky_api_drill.rb +++ /dev/null @@ -1,84 +0,0 @@ -# frozen_string_literal: true - -# The Flaky API Drill: a task that times out twice before succeeding, -# run under a retry policy with exponential backoff and a journal. -# The timeline shows every attempt, every backoff gap, and the journal -# proves the whole ordeal - failures included - survived to disk. -# -# bundle exec ruby examples/flaky_api_drill.rb -# -# Runs offline; the flakiness is scripted so the drill is repeatable. - -require_relative "../lib/agentic" -require "tmpdir" - -# The error class name must match the retry policy's retryable_errors -class TimeoutError < StandardError; end - -JOURNAL = File.join(Dir.tmpdir, "agentic_flaky_drill.journal.jsonl") -File.delete(JOURNAL) if File.exist?(JOURNAL) - -journal = Agentic::ExecutionJournal.new(path: JOURNAL) -started = Process.clock_gettime(Process::CLOCK_MONOTONIC) -stamp = -> { format("%5dms", (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000) } - -timeline_hooks = journal.lifecycle_hooks( - after_task_failure: ->(task_id:, task:, failure:, duration:) { - puts "#{stamp.call} x attempt failed: #{failure.type} (#{failure.message})" - }, - after_task_success: ->(task_id:, task:, result:, duration:) { - puts "#{stamp.call} + #{task.description} succeeded: #{result.output.inspect}" - } -) - -orchestrator = Agentic::PlanOrchestrator.new( - concurrency_limit: 1, - lifecycle_hooks: timeline_hooks, - retry_policy: { - max_retries: 3, - retryable_errors: ["TimeoutError"], - backoff_strategy: :exponential, - backoff_base: 0.1 - } -) - -# Fails twice with a retryable timeout, then delivers -attempts = 0 -sync = Agentic::Task.new( - description: "sync:accounts", - agent_spec: {"name" => "AccountSync", "instructions" => "sync"}, - payload: nil -) -orchestrator.add_task(sync, agent: ->(_t) { - attempts += 1 - puts "#{stamp.call} > attempt #{attempts} calling the flaky API..." - raise TimeoutError, "upstream took too long" if attempts < 3 - - {"synced" => 42} -}) - -# An innocent bystander task, to show the plan keeps moving -audit = Agentic::Task.new( - description: "audit:trail", - agent_spec: {"name" => "Auditor", "instructions" => "audit"} -) -orchestrator.add_task(audit, [sync], agent: ->(t) { - "audited #{t.output_of(sync)["synced"]} accounts" -}) - -puts "FLAKY API DRILL (max 3 retries, exponential backoff from 100ms)" -puts -result = orchestrator.execute_plan -puts -puts "plan: #{result.status} in #{(result.execution_time * 1000).round}ms, " \ - "#{attempts} attempts for one success" - -state = Agentic::ExecutionJournal.replay(path: JOURNAL) -failures = state.events.count { |e| e[:event] == "task_failed" } -successes = state.events.count { |e| e[:event] == "task_succeeded" } -puts -puts "the journal remembers the whole ordeal:" -puts " #{failures} failed attempts and #{successes} successes on disk" -puts " completed?(\"sync:accounts\") => #{state.completed?("sync:accounts")} (by name - " \ - "a rerun tomorrow gets new task ids and still knows)" -puts " completed?(\"audit:trail\") => #{state.completed?("audit:trail")}" diff --git a/examples/form_errors.rb b/examples/form_errors.rb deleted file mode 100644 index 472edc7..0000000 --- a/examples/form_errors.rb +++ /dev/null @@ -1,85 +0,0 @@ -# frozen_string_literal: true - -# The 422 Generator: turn a ValidationError into the API error document -# your frontend actually wants - message, allowed values, bounds - using -# ONLY what the exception carries (new this round: #expectations). The -# renderer has zero knowledge of the contract; the exception brings the -# contract with it. -# -# bundle exec ruby examples/form_errors.rb -# -# Runs offline; prints the JSON your form would receive. - -require_relative "../lib/agentic" -require "json" - -spec = Agentic::CapabilitySpecification.new( - name: "checkout", - description: "Process a checkout form", - version: "1.0.0", - inputs: { - email: {type: "string", required: true, non_empty: true}, - plan: {type: "string", required: true, enum: %w[starter team enterprise]}, - seats: {type: "number", required: true, min: 1, max: 500}, - coupon: {type: "string"} - }, - rules: { - starter_seat_limit: { - message: "starter plan is limited to 5 seats", - fields: [:plan, :seats], - check: ->(i) { i[:plan] != "starter" || i[:seats] <= 5 } - } - } -) -Agentic.register_capability(spec, Agentic::CapabilityProvider.new( - capability: spec, implementation: ->(i) { {order_id: "ord-#{i[:plan]}-#{i[:seats]}"} } -)) - -# The renderer: exception in, error document out. Note what it does NOT -# have: any reference to the checkout contract. -def error_document(error) - field_errors = error.violations.filter_map do |field, messages| - next if field == :base - - declared = error.expectations[field] || {} - detail = {field: field, messages: Array(messages)} - detail[:allowed] = declared[:enum] if declared[:enum] - detail[:minimum] = declared[:min] if declared[:min] - detail[:maximum] = declared[:max] if declared[:max] - detail[:type] = declared[:type] if declared[:type] - detail - end - - { - status: 422, - capability: error.capability, - errors: field_errors, - # Structured rule violations point at the widgets they involve - policy_violations: error.rule_violations.map { |v| - {rule: v[:rule], message: v[:message], highlight_fields: v[:fields]} - } - } -end - -checkout = Agentic::AgentCapabilityRegistry.instance.get_provider("checkout") - -SUBMISSIONS = [ - {email: "ada@example.com", plan: "team", seats: 12}, - {email: "", plan: "premium", seats: 0}, - {email: "joan@example.com", plan: "starter", seats: 9} -].freeze - -SUBMISSIONS.each_with_index do |form, index| - puts "submission ##{index + 1}: #{form.inspect}" - begin - result = checkout.execute(form) - puts " 201 CREATED #{result[:order_id]}" - rescue Agentic::Errors::ValidationError => e - puts JSON.pretty_generate(error_document(e)).gsub(/^/, " ") - end - puts -end - -puts "the renderer never saw the checkout contract - 'allowed', 'minimum'," -puts "and 'maximum' all traveled inside the exception. one renderer serves" -puts "every capability in the app, current and future." diff --git a/examples/freight_rules.rb b/examples/freight_rules.rb deleted file mode 100644 index 4fa115a..0000000 --- a/examples/freight_rules.rb +++ /dev/null @@ -1,87 +0,0 @@ -# frozen_string_literal: true - -# The Freight Desk: a quoting capability whose tariff book is written -# as cross-field contract rules (new this round). Per-key checks catch -# nonsense; rules: catch the LEGAL-LOOKING orders that violate policy - -# and every broken rule is reported at once, because a shipper fixing -# their manifest deserves the whole list, not a scavenger hunt. -# -# bundle exec ruby examples/freight_rules.rb -# -# Runs offline and deterministically. - -require_relative "../lib/agentic" - -spec = Agentic::CapabilitySpecification.new( - name: "quote_freight", - description: "Quote a freight shipment", - version: "1.0.0", - inputs: { - mode: {type: "string", required: true, enum: %w[air sea road]}, - weight_kg: {type: "number", required: true, min: 1, max: 30_000}, - hazardous: {type: "boolean", required: true}, - insured_value: {type: "number", required: true, min: 0}, - destination: {type: "string", required: true, non_empty: true} - }, - rules: { - air_weight_limit: { - message: "air freight is limited to 500kg", - fields: [:mode, :weight_kg], - check: ->(i) { i[:mode] != "air" || i[:weight_kg] <= 500 } - }, - no_hazardous_air: { - message: "hazardous cargo may not fly", - fields: [:mode, :hazardous], - check: ->(i) { !(i[:mode] == "air" && i[:hazardous]) } - }, - high_value_by_sea: { - message: "insured value over 100k requires sea mode", - fields: [:mode, :insured_value], - check: ->(i) { i[:insured_value] <= 100_000 || i[:mode] == "sea" } - }, - road_is_domestic: { - message: "road freight only reaches domestic destinations", - fields: [:mode, :destination], - check: ->(i) { i[:mode] != "road" || i[:destination].start_with?("domestic:") } - } - } -) - -RATES = {"air" => 4.20, "sea" => 0.30, "road" => 1.10}.freeze -Agentic.register_capability(spec, Agentic::CapabilityProvider.new( - capability: spec, - implementation: ->(i) { {quote: (i[:weight_kg] * RATES.fetch(i[:mode])).round(2)} } -)) - -desk = Agentic::AgentCapabilityRegistry.instance.get_provider("quote_freight") - -MANIFESTS = [ - {mode: "sea", weight_kg: 12_000, hazardous: true, insured_value: 250_000, destination: "port of rotterdam"}, - {mode: "air", weight_kg: 480, hazardous: false, insured_value: 20_000, destination: "berlin"}, - {mode: "air", weight_kg: 900, hazardous: true, insured_value: 150_000, destination: "tokyo"}, - {mode: "road", weight_kg: 2_000, hazardous: false, insured_value: 5_000, destination: "domestic:austin"}, - {mode: "teleport", weight_kg: -5, hazardous: false, insured_value: 10, destination: ""} -].freeze - -puts "THE FREIGHT DESK (#{spec.rules.size} tariff rules on the contract)" -puts -MANIFESTS.each_with_index do |manifest, index| - quote = desk.execute(manifest) - puts format(" #%d QUOTED $%.2f (%s, %dkg)", index + 1, quote[:quote], manifest[:mode], manifest[:weight_kg]) -rescue Agentic::Errors::ValidationError => e - if e.rule_violations.any? - puts " ##{index + 1} REFUSED - #{e.rule_violations.size} rule(s) broken:" - e.rule_violations.each do |violation| - puts " - [#{violation[:rule]}] #{violation[:message]} " \ - "(fields: #{violation[:fields].join(", ")})" - end - else - puts " ##{index + 1} MALFORMED - #{e.violations.keys.join(", ")} invalid " \ - "(never reached the tariff book)" - end -end - -puts -puts "manifest #3 broke THREE rules and heard about all three at once." -puts "manifest #5 never reached the tariff book: per-key validation" -puts "rejects nonsense before cross-field rules spend time on it." diff --git a/examples/gem_scout.rb b/examples/gem_scout.rb deleted file mode 100644 index 14dc029..0000000 --- a/examples/gem_scout.rb +++ /dev/null @@ -1,107 +0,0 @@ -# frozen_string_literal: true - -# Gem Scout: describe what you need, get a ranked shortlist of gems. -# Search and scoring are separate capabilities; the search backend is -# the pluggable seam - offline it's a bundled index, online swap in -# the real WebSearch DuckDuckGo backend with one assignment. -# -# bundle exec ruby examples/gem_scout.rb "background jobs" -# bundle exec ruby examples/gem_scout.rb "vector search" -# -# Runs offline by default. - -require_relative "../lib/agentic" - -# A small index standing in for the network - same shape a live search -# backend returns, so the rest of the program can't tell the difference -CATALOG = [ - {name: "sidekiq", summary: "background jobs backed by Redis, threads not forks", - topics: %w[background jobs queue async workers], downloads_m: 950, last_release_days: 20}, - {name: "solid_queue", summary: "database-backed background jobs for Active Job", - topics: %w[background jobs queue rails database], downloads_m: 15, last_release_days: 30}, - {name: "good_job", summary: "Postgres-based Active Job backend with dashboard", - topics: %w[background jobs queue rails postgres], downloads_m: 40, last_release_days: 14}, - {name: "neighbor", summary: "nearest neighbor vector search for Rails and Postgres", - topics: %w[vector search embeddings pgvector similarity], downloads_m: 8, last_release_days: 45}, - {name: "pgvector", summary: "pgvector support for Ruby", - topics: %w[vector search embeddings postgres], downloads_m: 12, last_release_days: 60}, - {name: "searchkick", summary: "intelligent search made easy with Elasticsearch/OpenSearch", - topics: %w[search elasticsearch full-text ranking], downloads_m: 130, last_release_days: 90}, - {name: "pagy", summary: "the fastest pagination gem", - topics: %w[pagination performance views], downloads_m: 85, last_release_days: 10}, - {name: "strong_migrations", summary: "catch unsafe migrations in development", - topics: %w[migrations database safety postgres], downloads_m: 70, last_release_days: 25} -].freeze - -# Offline backend for the WebSearch seam built in round 1 -Agentic::Capabilities::WebSearch.backend = lambda do |query:, num_results:| - terms = query.downcase.split - hits = CATALOG.map { |gem| - haystack = "#{gem[:name]} #{gem[:summary]} #{gem[:topics].join(" ")}" - score = terms.count { |t| haystack.include?(t) } - [gem, score] - }.select { |_, s| s.positive? }.sort_by { |g, s| [-s, -g[:downloads_m]] }.first(num_results) - - { - results: hits.map { |gem, _| "#{gem[:name]}: #{gem[:summary]}" }, - sources: hits.map { |gem, _| "https://rubygems.org/gems/#{gem[:name]}" } - } -end - -# Scoring is its own capability: search finds candidates, this ranks -# them on the things that matter when you have to live with a gem -spec = Agentic::CapabilitySpecification.new( - name: "score_gem", - description: "Score a gem on adoption and maintenance", - version: "1.0.0", - inputs: {name: {type: "string", required: true}}, - outputs: {score: {type: "number", required: true}, notes: {type: "array", required: true}} -) -Agentic.register_capability(spec, Agentic::CapabilityProvider.new( - capability: spec, - implementation: ->(inputs) { - gem = CATALOG.find { |g| g[:name] == inputs[:name] } or - next({score: 0.0, notes: ["unknown gem"]}) - - adoption = Math.log10([gem[:downloads_m], 1].max) / 3.0 # 0..1 for 1M..1B - freshness = [1.0 - gem[:last_release_days] / 365.0, 0].max - notes = [] - notes << "widely adopted (#{gem[:downloads_m]}M downloads)" if gem[:downloads_m] > 50 - notes << "recently released (#{gem[:last_release_days]}d ago)" if gem[:last_release_days] < 31 - notes << "check maintenance cadence" if gem[:last_release_days] > 80 - - {score: ((adoption * 0.6 + freshness * 0.4) * 100).round(1), notes: notes} - } -)) - -scout = Agentic::Agent.build { |a| a.name = "GemScout" } -Agentic::Capabilities.register_standard_capabilities -scout.add_capability("web_search") -scout.add_capability("score_gem") - -need = ARGV.join(" ") -need = "background jobs" if need.empty? - -found = scout.execute_capability("web_search", {query: need, num_results: 4}) -candidates = found[:results].map { |line| line.split(":").first } - -ranked = candidates.map { |name| - verdict = scout.execute_capability("score_gem", {name: name}) - {name: name, score: verdict[:score], notes: verdict[:notes]} -}.sort_by { |c| -c[:score] } - -puts "GEM SCOUT: \"#{need}\"" -puts -if ranked.empty? - puts " no candidates found - try different words, or plug in the live backend:" - puts " Agentic::Capabilities::WebSearch.backend = Agentic::Capabilities::WebSearch::DuckDuckGo.new" -else - ranked.each_with_index do |c, i| - marker = (i == 0) ? "->" : " " - puts format("%s %-18s %5.1f %s", marker, c[:name], c[:score], c[:notes].join("; ")) - end - puts - winner = ranked.first - puts "recommendation: start with #{winner[:name]} - " \ - "#{CATALOG.find { |g| g[:name] == winner[:name] }[:summary]}" -end diff --git a/examples/gentle_deprecations.rb b/examples/gentle_deprecations.rb deleted file mode 100644 index 1e32daf..0000000 --- a/examples/gentle_deprecations.rb +++ /dev/null @@ -1,108 +0,0 @@ -# frozen_string_literal: true - -# Gentle Deprecations: the hard part of maintaining a framework isn't -# adding the better name - it's the two years of not breaking anyone -# who used the old one. This shims a renamed contract field through -# three release phases: translate-and-warn (once per call site, with -# the caller named), count everything for the migration report, and -# a strict mode that turns warnings into CI failures ON YOUR schedule, -# not the users'. -# -# bundle exec ruby examples/gentle_deprecations.rb -# -# Runs offline; three "apps" call the API from three code sites. - -require_relative "../lib/agentic" - -Agentic.logger.level = :fatal - -# v2 renamed weight: -> weight_kg:. The contract only knows the new world. -CONTRACT = Agentic::CapabilitySpecification.new( - name: "quote_shipping", description: "Quote a shipment", version: "2.0.0", - inputs: { - mode: {type: "string", required: true, enum: %w[air sea]}, - weight_kg: {type: "number", required: true, min: 1} - }, - outputs: {price_cents: {type: "number", required: true}} -) - -# The shim: old names translated at the door, warned once per call -# site, tallied for the report. Deprecation is DATA about your users. -class DeprecationShim - RENAMES = {weight: :weight_kg}.freeze - - attr_reader :hits - - def initialize(strict: false) - @strict = strict - @warned = {} - @hits = Hash.new(0) - end - - def translate(inputs) - RENAMES.each do |old_name, new_name| - next unless inputs.key?(old_name) - - # The interesting frame is the USER's: skip the shim and the API - # boundary, blame the first frame that belongs to neither - site_location = caller_locations.find { |l| !l.label.include?("translate") && !%w[each quote].include?(l.label) } - site = "#{site_location.label} (#{site_location.to_s[/[^\/]+:\d+/]})" - @hits["#{old_name} at #{site}"] += 1 - if @strict - raise ArgumentError, "DEPRECATED input :#{old_name} (use :#{new_name}) - strict mode refuses it" - end - unless @warned["#{old_name}-#{site}"] - @warned["#{old_name}-#{site}"] = true - warn " DEPRECATION: :#{old_name} is now :#{new_name} (called from #{site}; this warning shows once per site)" - end - inputs = inputs.dup - inputs[new_name] = inputs.delete(old_name) - end - inputs - end -end - -SHIM = DeprecationShim.new -VALIDATOR = Agentic::CapabilityValidator.new(CONTRACT) - -def quote(inputs) - inputs = SHIM.translate(inputs) - VALIDATOR.validate_inputs!(inputs) - {price_cents: (inputs[:weight_kg] * ((inputs[:mode] == "air") ? 9 : 2) * 100).round} -end - -# Three call sites: one migrated, two still on the old name -def legacy_billing_job = quote(mode: "air", weight: 12) - -def legacy_admin_panel = quote(mode: "sea", weight: 400) - -def migrated_checkout = quote(mode: "air", weight_kg: 3) - -puts "GENTLE DEPRECATIONS (rename shipped; nobody broken; everybody counted)" -puts -3.times { legacy_billing_job } -2.times { legacy_admin_panel } -4.times { migrated_checkout } - -puts -puts " the migration report (deprecation is data about your users):" -SHIM.hits.each { |site, count| puts format(" %-46s %d call(s)", site, count) } -puts " migrated call sites warn nothing and appear nowhere." -puts - -# Release N+2: strict mode - the same shim becomes the enforcement -strict = DeprecationShim.new(strict: true) -begin - strict.translate(mode: "air", weight: 12) -rescue ArgumentError => e - puts " strict mode (release N+2, or CI today): #{e.message}" -end -puts -puts " the choreography, straight from the Rails playbook: release N" -puts " adds the new name and the shim - old code runs, warns once per" -puts " call site (per-site, or your logs become the outage), and the" -puts " tally tells you exactly who still needs a PR. release N+1 you" -puts " chase the tally to zero. release N+2 flips strict and deletes" -puts " the shim on YOUR schedule - because the deadline was enforced" -puts " by CI failures in the laggards' builds, not by breaking their" -puts " production. renames are cheap; broken trust compounds." diff --git a/examples/graph_critic.rb b/examples/graph_critic.rb deleted file mode 100644 index 77cf577..0000000 --- a/examples/graph_critic.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true - -# The Graph Critic: reviews a plan's dependency structure BEFORE it -# runs, the way you'd review a class diagram. God tasks, deep chains, -# and orphans are design smells in a graph exactly as they are in -# objects - and they're cheaper to fix before execution than after. -# -# bundle exec ruby examples/graph_critic.rb -# -# Runs offline; no task is executed. The review IS the program. - -require_relative "../lib/agentic" - -def task_named(name) - Agentic::Task.new( - description: name, - agent_spec: {"name" => name, "instructions" => "work"} - ) -end - -# A plan with three deliberate smells -orchestrator = Agentic::PlanOrchestrator.new -tasks = {} -%w[ingest_a ingest_b ingest_c ingest_d ingest_e clean join report publish lonely].each do |name| - tasks[name] = task_named(name) -end - -orchestrator.add_task(tasks["ingest_a"]) -orchestrator.add_task(tasks["ingest_b"]) -orchestrator.add_task(tasks["ingest_c"]) -orchestrator.add_task(tasks["ingest_d"]) -orchestrator.add_task(tasks["ingest_e"]) -# the god task: everything funnels through join -orchestrator.add_task(tasks["join"], %w[ingest_a ingest_b ingest_c ingest_d ingest_e].map { |n| tasks[n] }) -# a chain hanging off it -orchestrator.add_task(tasks["clean"], [tasks["join"]]) -orchestrator.add_task(tasks["report"], [tasks["clean"]]) -orchestrator.add_task(tasks["publish"], [tasks["report"]]) -# and a task nobody references -orchestrator.add_task(tasks["lonely"]) - -# --- the critique ---------------------------------------------------------- -# This example's original feature request, granted: a read-only view of -# the plan's topology. No more crowbar. -graph = orchestrator.graph -dependencies = graph[:dependencies] -names = graph[:tasks].transform_values(&:description) - -dependents = Hash.new { |h, k| h[k] = [] } -dependencies.each { |task_id, deps| deps.each { |dep| dependents[dep] << task_id } } - -# Depth now ships precomputed in the snapshot -depth_of = ->(task_id) { graph[:stats][:depth][task_id] } - -findings = [] - -dependencies.each do |task_id, deps| - if deps.size >= 4 - findings << {smell: "god task", task: names[task_id], - note: "gathers #{deps.size} dependencies - does it join, or does it do everything? " \ - "consider staged joins so each has one reason to wait"} - end -end - -deepest = dependencies.keys.max_by { |task_id| depth_of.call(task_id) } -if depth_of.call(deepest) >= 4 - findings << {smell: "deep chain", task: names[deepest], - note: "sits #{depth_of.call(deepest)} levels down - every level is latency and a failure " \ - "domain; could any middle link merge with a neighbor?"} -end - -dependencies.each do |task_id, deps| - if deps.empty? && dependents[task_id].empty? && dependencies.size > 1 - findings << {smell: "orphan", task: names[task_id], - note: "no dependencies, no dependents - is it in the wrong plan, or is the " \ - "connection that justifies it missing?"} - end -end - -puts "GRAPH CRITIC: #{dependencies.size} tasks reviewed before execution" -puts -findings.each do |finding| - puts " [#{finding[:smell]}] #{finding[:task]}" - puts " #{finding[:note]}" - puts -end - -puts "prescription: fix ONE - start with the god task. five ingests" -puts "joining at once usually means 'join' hides a pipeline: stage the" -puts "joins (a+b, c+d+e, then both) and each join gets one reason to" -puts "change. rerun the critic; the chain may resolve itself." diff --git a/examples/graph_invariants.rb b/examples/graph_invariants.rb deleted file mode 100644 index c0eefe1..0000000 --- a/examples/graph_invariants.rb +++ /dev/null @@ -1,137 +0,0 @@ -# frozen_string_literal: true - -# The Graph Invariants Prover: the reflection API makes promises - -# order respects edges, roots have no dependencies, depth is the -# longest path, leaves feed nothing. Documentation asserts these; -# this referee PROVES them, across four plan shapes including a -# deliberate cycle. Exit 0 is a certificate, not a shrug. -# -# bundle exec ruby examples/graph_invariants.rb -# -# Runs offline; exits 1 if any invariant is violated. - -require_relative "../lib/agentic" - -def task(name) - Agentic::Task.new(description: name, agent_spec: {"name" => name, "instructions" => "work"}) -end - -def chain_plan - orchestrator = Agentic::PlanOrchestrator.new - a, b, c, d = %w[a b c d].map { |n| task(n) } - orchestrator.add_task(a) - orchestrator.add_task(b, [a]) - orchestrator.add_task(c, [b]) - orchestrator.add_task(d, [c]) - orchestrator -end - -def diamond_plan - orchestrator = Agentic::PlanOrchestrator.new - top, left, right, bottom = %w[top left right bottom].map { |n| task(n) } - orchestrator.add_task(top) - orchestrator.add_task(left, [top]) - orchestrator.add_task(right, [top]) - orchestrator.add_task(bottom, needs: {l: left, r: right}) - orchestrator -end - -def forest_plan - orchestrator = Agentic::PlanOrchestrator.new - trees = %w[oak elm ash].map { |n| task(n) } - trees.each { |t| orchestrator.add_task(t) } - crown = task("crown") - orchestrator.add_task(crown, trees) - lone = task("lone") - orchestrator.add_task(lone) - orchestrator -end - -def cyclic_plan - orchestrator = Agentic::PlanOrchestrator.new - x, y = %w[x y].map { |n| task(n) } - orchestrator.add_task(x, [y.id]) - orchestrator.add_task(y, [x]) - orchestrator -end - -# Each invariant is a lambda: graph in, list of violations out -INVARIANTS = { - "order is a permutation of the task set" => lambda { |g| - (g[:order].sort == g[:tasks].keys.sort) ? [] : ["order #{g[:order].size} ids, tasks #{g[:tasks].size}"] - }, - "order respects every edge (acyclic only)" => lambda { |g| - position = g[:order].each_with_index.to_h - g[:edges].reject { |e| position[e[:from]] < position[e[:to]] } - .map { |e| "edge #{e[:from]}->#{e[:to]} out of order" } - }, - "roots are exactly the tasks with no dependencies" => lambda { |g| - expected = g[:dependencies].select { |_, deps| deps.empty? }.keys - (g[:stats][:roots].sort == expected.sort) ? [] : ["roots mismatch"] - }, - "leaves are exactly the tasks nothing depends on" => lambda { |g| - fed = g[:dependencies].values.flatten - expected = g[:tasks].keys - fed - (g[:stats][:leaves].sort == expected.sort) ? [] : ["leaves mismatch"] - }, - "depth is 1 + max dependency depth (acyclic only)" => lambda { |g| - g[:tasks].keys.filter_map { |id| - deps = g[:dependencies][id] - expected = deps.empty? ? 1 : 1 + deps.map { |d| g[:stats][:depth][d] || 0 }.max - "depth[#{id}] = #{g[:stats][:depth][id]}, expected #{expected}" if g[:stats][:depth][id] != expected - } - }, - "max_depth and max_fan_in agree with their sources" => lambda { |g| - violations = [] - violations << "max_depth" if g[:stats][:max_depth] != (g[:stats][:depth].values.max || 0) - violations << "max_fan_in" if g[:stats][:max_fan_in] != (g[:dependencies].values.map(&:size).max || 0) - violations - }, - "every needs: label appears on its edge" => lambda { |g| - g[:needs].flat_map { |task_id, named| - named.filter_map { |label, dep_id| - edge = g[:edges].find { |e| e[:from] == dep_id && e[:to] == task_id } - "label #{label} missing on #{dep_id}->#{task_id}" if edge.nil? || edge[:label] != label - } - } - } -}.freeze - -PLANS = { - "chain (a->b->c->d)" => chain_plan, - "diamond (labeled join)" => diamond_plan, - "forest (3 trees + orphan)" => forest_plan, - "cycle (x<->y)" => cyclic_plan -}.freeze - -puts "GRAPH INVARIANTS PROVER (#{INVARIANTS.size} invariants x #{PLANS.size} plan shapes)" -puts -failures = 0 -PLANS.each do |plan_name, orchestrator| - graph = orchestrator.graph - cyclic = plan_name.include?("cycle") - puts " #{plan_name}:" - INVARIANTS.each do |invariant_name, check| - next if cyclic && invariant_name.include?("acyclic only") - - violations = check.call(graph) - failures += violations.size - status = violations.empty? ? "proved" : "VIOLATED: #{violations.join("; ")}" - puts format(" %-52s %s", invariant_name, status) - end - puts -end - -if failures.zero? - puts " #{INVARIANTS.size * PLANS.size - 2} proofs, 0 violations. two invariants excuse themselves" - puts " on the cycle - and finding THAT was the prover's first catch: depth" - puts " means \"longest path from a root\", and cyclic graphs have no such" - puts " number, so the promise is scoped, not broken. these are the promises" - puts " every graph tool built in rounds 5-8 leans on - the forest drawing, the" - puts " spec generator, the merge, the diff. a reflection API that ships" - puts " without its invariants proved is asking consumers to prove them" - puts " one production incident at a time." -else - puts " #{failures} VIOLATION(S) - the reflection API broke a promise." -end -exit(failures.zero? ? 0 : 1) diff --git a/examples/graph_style.rb b/examples/graph_style.rb deleted file mode 100644 index e484c62..0000000 --- a/examples/graph_style.rb +++ /dev/null @@ -1,95 +0,0 @@ -# frozen_string_literal: true - -# The Graph Style Guide: RuboCop for plans. Cops with thresholds run -# against any orchestrator's graph - depth, fan-in, orphans, and the -# style rule I care most about: fan-ins of two or more should NAME -# their dependencies, because a join you can't name is a join you -# don't understand. -# -# bundle exec ruby examples/graph_style.rb -# -# Runs offline; lints a tidy plan and a messy one. - -require_relative "../lib/agentic" - -STYLE = { - "Graph/MaxDepth" => {limit: 4, why: "every level is latency and a failure domain"}, - "Graph/MaxFanIn" => {limit: 3, why: "wide joins own too many failure modes"}, - "Graph/NoOrphans" => {why: "unconnected tasks are in the wrong plan or missing an edge"}, - "Graph/NamedFanIns" => {min_to_name: 2, why: "a join you can't name is a join you don't understand"} -}.freeze - -def lint(graph, style) - names = graph[:tasks].transform_values(&:description) - stats = graph[:stats] - offenses = [] - - if stats[:max_depth] > style["Graph/MaxDepth"][:limit] - deepest = stats[:depth].max_by { |_, d| d }.first - offenses << ["Graph/MaxDepth", "#{names[deepest]} sits #{stats[:max_depth]} deep (limit #{style["Graph/MaxDepth"][:limit]})"] - end - - graph[:dependencies].each do |id, deps| - if deps.size > style["Graph/MaxFanIn"][:limit] - offenses << ["Graph/MaxFanIn", "#{names[id]} joins #{deps.size} (limit #{style["Graph/MaxFanIn"][:limit]})"] - end - end - - graph[:dependencies].each do |id, deps| - if deps.empty? && graph[:edges].none? { |e| e[:from] == id } && graph[:tasks].size > 1 - offenses << ["Graph/NoOrphans", "#{names[id]} touches nothing and is touched by nothing"] - end - end - - graph[:dependencies].each do |id, deps| - next if deps.size < style["Graph/NamedFanIns"][:min_to_name] - - unnamed = graph[:edges].count { |e| e[:to] == id && e[:label].nil? } - if unnamed.positive? - offenses << ["Graph/NamedFanIns", "#{names[id]} joins #{deps.size} but #{unnamed} edge(s) are unnamed - use needs:"] - end - end - - offenses -end - -def step(name) - Agentic::Task.new(description: name, agent_spec: {"name" => name, "instructions" => "work"}) -end - -# --- a tidy plan ------------------------------------------------------------ -tidy = Agentic::PlanOrchestrator.new -a = step("fetch users") -b = step("fetch orders") -c = step("merge report") -tidy.add_task(a) -tidy.add_task(b) -tidy.add_task(c, needs: {users: a, orders: b}) - -# --- a messy one -------------------------------------------------------------- -messy = Agentic::PlanOrchestrator.new -sources = 4.times.map { |i| step("source-#{i}") } -funnel = step("funnel") -steps = %w[polish buff shine present].map { |n| step(n) } -stray = step("stray") -sources.each { |t| messy.add_task(t) } -messy.add_task(funnel, sources) -previous = funnel -steps.each { |t| messy.add_task(t, [previous]) && (previous = t) } -messy.add_task(stray) - -puts "GRAPH STYLE GUIDE (#{STYLE.size} cops)" -{"tidy plan" => tidy, "messy plan" => messy}.each do |label, orchestrator| - offenses = lint(orchestrator.graph, STYLE) - puts - puts " #{label}: #{offenses.empty? ? "no offenses" : "#{offenses.size} offense(s)"}" - offenses.each do |cop, message| - puts " #{cop}: #{message}" - puts " (#{STYLE[cop][:why]})" - end -end - -puts -puts "style guides work because they argue once, in a config file," -puts "instead of every review. these thresholds are this team's taste -" -puts "yours may differ. that they're WRITTEN DOWN is the feature." diff --git a/examples/graph_to_specs.rb b/examples/graph_to_specs.rb deleted file mode 100644 index a9d8c38..0000000 --- a/examples/graph_to_specs.rb +++ /dev/null @@ -1,80 +0,0 @@ -# frozen_string_literal: true - -# Graph to Specs: the plan's structure dictates its test plan - roots -# need fixture cases, joins need one case per missing tributary, -# leaves need output assertions. This generates the RSpec skeleton -# from the graph, so "what should we test?" stops being a staring -# contest with a blank file. -# -# bundle exec ruby examples/graph_to_specs.rb -# -# Runs offline; prints a runnable-shaped spec skeleton. - -require_relative "../lib/agentic" - -def step(name) - Agentic::Task.new(description: name, agent_spec: {"name" => name, "instructions" => "work"}) -end - -orchestrator = Agentic::PlanOrchestrator.new -orders = step("fetch orders") -refunds = step("fetch refunds") -ledger = step("build ledger") -report = step("render report") - -orchestrator.add_task(orders) -orchestrator.add_task(refunds) -orchestrator.add_task(ledger, needs: {sales: orders, credits: refunds}) -orchestrator.add_task(report, [ledger]) - -graph = orchestrator.graph -stats = graph[:stats] -names = graph[:tasks].transform_values(&:description) - -puts "# generated from the plan's graph - one describe per task," -puts "# examples dictated by each task's structural role" -puts -puts "RSpec.describe \"the pipeline\" do" - -graph[:order].each do |id| - name = names[id] - deps = graph[:dependencies][id] - labeled = graph[:edges].select { |e| e[:to] == id && e[:label] } - role = [] - role << "root" if stats[:roots].include?(id) - role << "join" if deps.size >= 2 - role << "leaf" if stats[:leaves].include?(id) - - puts " describe \"#{name}\" do # #{role.join(", ")}" - - if stats[:roots].include?(id) - puts " it \"produces output from fixture input\" # roots own the boundary with the world" - puts " it \"raises a named error when the source is unreachable\"" - end - - if deps.size >= 2 - puts " context \"with all #{deps.size} inputs present\" do" - puts " it \"combines #{labeled.map { |e| e[:label] }.join(" and ")}\"" - puts " end" - labeled.each do |edge| - puts " context \"when #{edge[:label]} is missing\" do # joins fail per-tributary, not vaguely" - puts " it \"reports which input was absent\"" - puts " end" - end - elsif deps.size == 1 - puts " it \"transforms its upstream's output\" # assert on previous_output's shape" - end - - if stats[:leaves].include?(id) - puts " it \"produces the artifact consumers read\" # leaves are promises to the outside" - end - - puts " end" - puts -end -puts "end" -puts -puts "# #{graph[:tasks].size} tasks -> #{stats[:roots].size} boundary suites, " \ - "#{graph[:dependencies].count { |_, d| d.size >= 2 }} join suites with " \ - "per-tributary absence cases, #{stats[:leaves].size} artifact suites." -puts "# the graph decided what deserves a test; you decide what passes one." diff --git a/examples/haiku_agent.rb b/examples/haiku_agent.rb deleted file mode 100644 index 5cee845..0000000 --- a/examples/haiku_agent.rb +++ /dev/null @@ -1,51 +0,0 @@ -# frozen_string_literal: true - -# The three-line agent. Run me with no API key at all: -# -# bundle exec ruby examples/haiku_agent.rb -# -# An agent, a capability, a result - each expressed the way Ruby wants -# to express it: a block, a lambda, a hash. Nothing here talks to a -# network; capabilities are just callables, so the whole plan-and-execute -# idea is graspable in one screen. - -require_relative "../lib/agentic" - -# 1. An agent in three lines -poet = Agentic::Agent.build do |a| - a.name = "Basho" - a.role = "Haiku poet" -end - -# 2. A capability is a specification plus any callable -haiku = Agentic::CapabilitySpecification.new( - name: "haiku", - description: "Compose a haiku about a topic", - version: "1.0.0", - inputs: {topic: {type: "string", required: true}}, - outputs: {poem: {type: "string"}} -) - -brush = Agentic::CapabilityProvider.new( - capability: haiku, - implementation: ->(inputs) { - {poem: [ - "#{inputs[:topic].capitalize} at first light", - "an old pond holds the whole sky", - "ruby leaves drift down" - ].join("\n")} - } -) - -Agentic.register_capability(haiku, brush) -poet.add_capability("haiku") - -# 3. Ask the poet for a poem -puts poet.execute_capability("haiku", {topic: "autumn"})[:poem] - -# And when you do have an API key, the same agent, the same message, -# a real LLM - only the provider changes: -# -# Agentic.configure { |c| c.access_token = ENV["OPENAI_ACCESS_TOKEN"] } -# plan = Agentic::TaskPlanner.new("Write a haiku about autumn").plan -# puts plan.to_s diff --git a/examples/hill_chart.rb b/examples/hill_chart.rb deleted file mode 100644 index 0f43269..0000000 --- a/examples/hill_chart.rb +++ /dev/null @@ -1,93 +0,0 @@ -# frozen_string_literal: true - -# The Hill Chart: Basecamp's answer to "how's it going?" - work climbs -# the hill while it's still uncertain (queued, waiting on dependencies) -# and rolls down once it's just execution. Three live snapshots of a -# running plan, drawn from lifecycle hooks. No status meeting convened. -# -# bundle exec ruby examples/hill_chart.rb -# -# Runs offline; watch the letters roll downhill. - -require_relative "../lib/agentic" - -WORK = { - "A: audit copy" => {sleep: 0.05, deps: []}, - "B: build hero" => {sleep: 0.09, deps: []}, - "C: cut video" => {sleep: 0.12, deps: []}, - "D: draft email" => {sleep: 0.06, deps: ["A: audit copy"]}, - "E: embed video" => {sleep: 0.05, deps: ["B: build hero", "C: cut video"]}, - "F: final review" => {sleep: 0.04, deps: ["D: draft email", "E: embed video"]} -}.freeze - -# Position on the hill, 0.0 (left base) to 1.0 (right base) -POSITIONS = {pending: 0.15, queued: 0.35, running: 0.55, done: 0.9}.freeze - -states = WORK.keys.to_h { |name| [name, :pending] } -snapshots = [] - -take_snapshot = -> { snapshots << states.dup } - -hooks = { - before_task_execution: ->(task_id:, task:) { states[task.description] = :queued }, - task_slot_acquired: ->(task_id:, task:, waited:) { - states[task.description] = :running - take_snapshot.call - }, - after_task_success: ->(task_id:, task:, result:, duration:) { - states[task.description] = :done - } -} - -orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 2, lifecycle_hooks: hooks) -tasks = {} -WORK.each do |name, spec| - tasks[name] = Agentic::Task.new(description: name, - agent_spec: {"name" => name, "instructions" => "work"}, payload: spec[:sleep]) - orchestrator.add_task(tasks[name], spec[:deps].map { |d| tasks.fetch(d) }, - agent: ->(t) { sleep(t.payload) || :ok }) -end -orchestrator.execute_plan -take_snapshot.call # the finished hill - -# --- draw the hill ------------------------------------------------------------- -HILL = [ - " ___________ ", - " ____/ \\____ ", - " ____/ \\____ ", - " ____/ \\____ ", - "____/ \\____" -].freeze - -def draw_hill(states) - width = HILL.first.length - rows = HILL.map(&:dup) - - # Height of the hill surface at each column, from the art itself - surface = (0...width).map { |col| rows.index { |row| row[col] != " " } || rows.size - 1 } - - states.each do |name, state| - col = (POSITIONS.fetch(state) * (width - 1)).round - row = [surface[col] - 1, 0].max - letter = name[0] - col += 1 while rows[row][col] != " " && col < width - 1 - rows[row][col] = letter - end - rows.each { |row| puts " #{row}" } -end - -puts "THE HILL CHART (uphill = still uncertain, downhill = just execution)" -[0, snapshots.size / 2, snapshots.size - 1].uniq.each_with_index do |index, i| - snap = snapshots[index] - puts - puts " #{["early:", "mid-flight:", "at the end:"][i]}" - draw_hill(snap) -end - -puts -puts " legend: #{WORK.keys.map { |n| n.split(":").first + "=" + n.split(": ").last }.join(", ")}" -puts -puts "the crest is the honest divider: left of it, tasks are waiting on" -puts "dependencies or a slot (uncertainty you can't schedule away);" -puts "right of it, it's just execution. the chart never asks anyone" -puts "'percent complete?' - the states are facts from hooks." diff --git a/examples/honest_doubles.rb b/examples/honest_doubles.rb deleted file mode 100644 index 7011b5e..0000000 --- a/examples/honest_doubles.rb +++ /dev/null @@ -1,101 +0,0 @@ -# frozen_string_literal: true - -# Honest Doubles: every fake LLM in every agent test suite is lying a -# little - the question is whether anyone checks. The discipline: -# (1) don't mock what you don't own - wrap the vendor in an adapter -# whose interface YOU define; (2) verify every double against that -# interface (methods AND arity), so a rename breaks the test suite -# loudly instead of letting a thousand fakes drift into fiction. -# -# bundle exec ruby examples/honest_doubles.rb -# -# Runs offline; one double is honest, one drifted. Guess which passes. - -require_relative "../lib/agentic" - -Agentic.logger.level = :fatal - -# --- the owned boundary ---------------------------------------------------------- -# We do NOT stub Agentic::LlmClient (we don't own it; its interface -# can change under us at gem-update speed). We define OUR port: -class CompletionPort - # The whole vendor surface we permit ourselves to use, in one place - def complete(prompt, max_tokens:) - raise NotImplementedError - end -end - -# The real adapter would wrap Agentic::LlmClient. For tests, doubles: -class HonestDouble < CompletionPort - def initialize(scripted) - @scripted = scripted - end - - def complete(prompt, max_tokens:) - @scripted.fetch(prompt[/\w+/]) - end -end - -# This one was written against LAST QUARTER's port and nobody noticed -# the port grew a keyword since - classic double drift -class DriftedDouble - def complete(prompt) - "sure, whatever you say" - end -end - -# --- the verifier: doubles must match the port they claim to be ----------------- -def verify_double!(double, port) - port_methods = port.public_instance_methods(false) - port_methods.each do |name| - unless double.respond_to?(name) - raise ArgumentError, "double #{double.class} is missing ##{name}" - end - - expected = port.instance_method(name).parameters - actual = double.method(name).parameters - # Compare shapes: required/optional/keyword names must line up - if expected.map { |kind, n| [kind, n] } != actual.map { |kind, n| [kind, n] } - raise ArgumentError, "double #{double.class}##{name} has drifted: " \ - "port takes #{expected.inspect}, double takes #{actual.inspect}" - end - end -end - -# --- a consumer under test ------------------------------------------------------- -def triage(port, ticket) - label = port.complete("classify: #{ticket}", max_tokens: 5) - {ticket: ticket, label: label} -end - -puts "HONEST DOUBLES (verify the fake against the port, every time)" -puts - -honest = HonestDouble.new("classify" => "billing") -verify_double!(honest, CompletionPort) -puts " honest double: verified against CompletionPort - method AND arity match" -result = triage(honest, "I was charged twice") -puts " triage under test: #{result.inspect}" -puts - -drifted = DriftedDouble.new -begin - verify_double!(drifted, CompletionPort) - puts " drifted double: verified?! the verifier has no teeth" - exit(1) -rescue ArgumentError => e - puts " drifted double: REJECTED before any test ran -" - puts " #{e.message}" -end -puts -puts " without the verifier, the drifted double PASSES every test you" -puts " write with it - `complete` responds, strings come back, green" -puts " everywhere - while the real adapter takes max_tokens: and would" -puts " raise ArgumentError on the very first production call. that's" -puts " the treachery of unverified fakes: they don't fail, they VOUCH." -puts " the two rules, cheap to follow: own the boundary (one port class" -puts " names everything you use from the vendor - the census says the" -puts " smaller that surface, the better), and verify every double" -puts " against it in the double's own definition, so interface drift" -puts " breaks the suite at load time, not the demo. your tests are" -puts " only as honest as their most casual fake." diff --git a/examples/hostile_inputs.rb b/examples/hostile_inputs.rb deleted file mode 100644 index dbeabaf..0000000 --- a/examples/hostile_inputs.rb +++ /dev/null @@ -1,83 +0,0 @@ -# frozen_string_literal: true - -# 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. 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 if any hostile file draws blood again. - -require_relative "../lib/agentic" -require "tmpdir" -require "json" - -GOOD = %({"event":"task_succeeded","task_id":"t1","description":"t1","duration":0.1,"output":"ok"}) - -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, state.damage] -rescue => e - [:crashed, e.class.to_s, []] -end - -PROBES = { - "clean file (control)" => [GOOD, GOOD.sub("t1", "t2")], - "torn tail (crash mid-write)" => [GOOD, %({"event":"task_succ)], - "binary garbage line" => [GOOD, "\x00\x01\xFFnot json at all"], - "empty + whitespace lines" => [GOOD, "", " ", GOOD.sub("t1", "t2")], - "8MB single line" => [GOOD, %({"event":"task_succeeded","task_id":"big","description":"big","duration":0.1,"output":"#{"x" * 8_000_000}"})], - "valid JSON, wrong shape" => [GOOD, %({"event":"task_succeeded","task_id":42,"duration":"fast"})], - "unknown event type" => [GOOD, %({"event":"solar_flare","task_id":"t9"})], - "duplicate success lines" => [GOOD, GOOD] -}.freeze - -puts "HOSTILE INPUTS (#{PROBES.size} probes against ExecutionJournal.replay)" -puts -blood = [] -PROBES.each do |name, 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, - 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 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: #{blood.join("; ")} - the tail is no longer tolerated." -end -exit(blood.empty? ? 0 : 1) diff --git a/examples/impl_shootout.rb b/examples/impl_shootout.rb deleted file mode 100644 index 5767f7c..0000000 --- a/examples/impl_shootout.rb +++ /dev/null @@ -1,104 +0,0 @@ -# frozen_string_literal: true - -# The Implementation Shootout: two candidates for the same capability, -# one eval set, and a verdict computed instead of vibed. v1 is a fast -# regex; v2 is a slower keyword-weight model. The scoreboard reports -# quality AND latency, because "which is better" has two axes and -# every README that hides one is selling something. -# -# bundle exec ruby examples/impl_shootout.rb -# -# Runs offline; the verdict includes the price of the quality. - -require_relative "../lib/agentic" - -SPEC = Agentic::CapabilitySpecification.new( - name: "route_ticket", description: "Route a ticket to a queue", version: "?", - inputs: {text: {type: "string", required: true}}, - outputs: {queue: {type: "string", required: true, enum: %w[billing bug account general]}} -) - -# Candidate 1: the regex that shipped in an afternoon -V1 = lambda do |i| - queue = case i[:text].downcase - when /refund|charge|invoice/ then "billing" - when /crash|error|broken/ then "bug" - when /password|login|email/ then "account" - else "general" - end - sleep(0.002) - {queue: queue} -end - -# Candidate 2: stem weights, summed as evidence - slower, subtler -WEIGHTS = { - "billing" => {"refund" => 3, "charge" => 2, "invoice" => 3, "paid" => 2, "money" => 1}, - "bug" => {"crash" => 3, "error" => 2, "broken" => 2, "lost" => 1, "fail" => 2}, - "account" => {"password" => 3, "login" => 3, "email" => 2, "lock" => 2} -}.freeze -V2 = lambda do |i| - words = i[:text].downcase.scan(/[a-z]+/) - scores = WEIGHTS.transform_values { |stems| - stems.sum { |stem, weight| (words.any? { |w| w.start_with?(stem) }) ? weight : 0 } - } - best, score = scores.max_by { |_, s| s } - sleep(0.01) - {queue: (score > 0) ? best : "general"} -end - -EVALS = [ - {text: "I was charged twice, I want a refund", queue: "billing"}, - {text: "App crashes when I open settings", queue: "bug"}, - {text: "Can't login, password reset email never arrives", queue: "account"}, - {text: "I paid but my invoice shows money owed", queue: "billing"}, - {text: "The export fails and I lost my work", queue: "bug"}, - {text: "My account is locked after the update", queue: "account"}, - {text: "How do I change my plan?", queue: "general"}, - # The decider: one bug word, five points of account evidence - {text: "Password reset email shows an error page", queue: "account"} -].freeze - -def run_candidate(impl) - EVALS.map do |eval_case| - started = Process.clock_gettime(Process::CLOCK_MONOTONIC) - output = impl.call(text: eval_case[:text]) - { - correct: output[:queue] == eval_case[:queue], - got: output[:queue], - latency: Process.clock_gettime(Process::CLOCK_MONOTONIC) - started - } - end -end - -results = {"v1 regex" => run_candidate(V1), "v2 weights" => run_candidate(V2)} - -puts "IMPLEMENTATION SHOOTOUT: #{SPEC.name} (#{EVALS.size} eval cases)" -puts -puts format(" %-46s %-12s %s", "case (expected)", "v1 regex", "v2 weights") -EVALS.each_with_index do |eval_case, index| - marks = results.values.map { |r| - r[index][:correct] ? "pass" : "FAIL(#{r[index][:got]})" - } - puts format(" %-46s %-12s %s", "#{eval_case[:text][0, 36]}... (#{eval_case[:queue]})", *marks) -end - -puts -puts " scoreboard:" -results.each do |name, rows| - accuracy = rows.count { |r| r[:correct] } / EVALS.size.to_f - p50 = rows.map { |r| r[:latency] }.sort[rows.size / 2] - puts format(" %-12s accuracy %3d%% p50 %.1fms", name, (accuracy * 100).round, p50 * 1000) -end - -v1_acc = results["v1 regex"].count { |r| r[:correct] } -v2_acc = results["v2 weights"].count { |r| r[:correct] } -puts -puts " verdict: v2 wins #{v2_acc}/#{EVALS.size} to #{v1_acc}/#{EVALS.size} - and costs 5x the latency." -puts " the deciding cases share a shape: 'password reset email shows an" -puts " error page' has one bug word and five points of account evidence." -puts " first-match regex answers by clause order - an accident of code" -puts " layout - while weights answer by total evidence. whether that is" -puts " worth 8ms per ticket is YOUR call; the shootout's job is to put" -puts " both axes on one table so the tradeoff is chosen, not discovered." -puts " and a perfect v2 score means the EVAL SET stopped discriminating," -puts " not that v2 is done - add cases until your best candidate fails." diff --git a/examples/incident_report.rb b/examples/incident_report.rb deleted file mode 100644 index 2ec4026..0000000 --- a/examples/incident_report.rb +++ /dev/null @@ -1,83 +0,0 @@ -# frozen_string_literal: true - -# The Incident Report: a nightly batch dies at 3am. The on-call's -# first three questions - what ran? what broke? what do I resume? - -# answered from the journal replay, formatted for the incident -# channel. Nobody greps logs at 3am if the journal can already speak. -# -# bundle exec ruby examples/incident_report.rb -# -# Runs offline; the outage is scripted. - -require_relative "../lib/agentic" -require "tmpdir" - -Agentic.logger.level = :fatal - -JOURNAL = File.join(Dir.tmpdir, "agentic_incident.journal.jsonl") -File.delete(JOURNAL) if File.exist?(JOURNAL) - -journal = Agentic::ExecutionJournal.new(path: JOURNAL) - -NIGHTLY = { - "extract:orders" => {time: 0.05}, - "extract:refunds" => {time: 0.04}, - "transform:ledger" => {time: 0.07, deps: %w[extract:orders extract:refunds]}, - "load:warehouse" => {time: 0.03, deps: %w[transform:ledger], - error: Agentic::Errors::LlmAuthenticationError.new("warehouse credentials expired")}, - "verify:totals" => {time: 0.02, deps: %w[load:warehouse]}, - "notify:finance" => {time: 0.01, deps: %w[verify:totals]} -}.freeze - -orchestrator = nil -hooks = journal.lifecycle_hooks( - after_task_failure: ->(task_id:, task:, failure:, duration:) { orchestrator.cancel_plan } -) -orchestrator = Agentic::PlanOrchestrator.new( - concurrency_limit: 2, lifecycle_hooks: hooks, - retry_policy: {max_retries: 0, retryable_errors: []} -) - -tasks = {} -NIGHTLY.each do |name, spec| - tasks[name] = Agentic::Task.new(description: name, - agent_spec: {"name" => name, "instructions" => "run"}, payload: spec) - orchestrator.add_task(tasks[name], (spec[:deps] || []).map { |d| tasks.fetch(d) }, agent: ->(t) { - sleep(t.payload[:time]) - raise t.payload[:error] if t.payload[:error] - - :ok - }) -end -orchestrator.execute_plan - -# --- the report: everything below reads ONLY the journal --------------------- -state = Agentic::ExecutionJournal.replay(path: JOURNAL) -all_tasks = NIGHTLY.keys -completed = state.completed_descriptions -failed = state.events.select { |e| e[:event] == "task_failed" } -never_ran = all_tasks - completed - failed.map { |f| f[:description] } - -puts "INCIDENT REPORT - nightly batch" -puts "=" * 52 -puts -puts "impact:" -puts " #{completed.size}/#{all_tasks.size} tasks completed before the stop" -failed.each do |f| - puts " ROOT CAUSE: #{f[:description]} - #{f[:error_type]}" - puts " \"#{f[:error]}\"" -end -puts -puts "completed (do NOT re-run - outputs are journaled):" -completed.each { |d| puts format(" + %-20s %4.0fms", d, (state.durations[d] || 0) * 1000) } -puts -puts "never started (blocked behind the failure):" -never_ran.each { |d| puts " . #{d}" } -puts -puts "resume plan:" -puts " 1. rotate the warehouse credentials (error is LlmAuthenticationError:" -puts " retryable? => false; retrying without fixing creds is theater)" -puts " 2. re-run the batch - completed?(description) will skip the" -puts " #{completed.size} journaled tasks; only #{all_tasks.size - completed.size} run" -puts format(" 3. budget: ~%.0fms of work already banked, don't pay twice", - state.durations.values.sum * 1000) diff --git a/examples/invariant_sentinel.rb b/examples/invariant_sentinel.rb deleted file mode 100644 index 879e606..0000000 --- a/examples/invariant_sentinel.rb +++ /dev/null @@ -1,100 +0,0 @@ -# frozen_string_literal: true - -# The Invariant Sentinel: domain invariants checked after EVERY task, -# from a lifecycle hook. When a task leaves the world in an illegal -# state, the sentinel names the task, names the broken law, and stops -# the plan before the corruption compounds. One of the pickers below -# has an off-by-one; watch how far it gets. -# -# bundle exec ruby examples/invariant_sentinel.rb -# -# Runs offline and deterministically. - -require_relative "../lib/agentic" - -WAREHOUSE = {stock: {"widget" => 10, "gadget" => 8}, received: {}, picked: {}} - -INVARIANTS = { - "stock is never negative" => -> { - WAREHOUSE[:stock].values.all? { |count| count >= 0 } - }, - "stock equals initial + received - picked" => -> { - WAREHOUSE[:stock].all? do |sku, count| - initial = {"widget" => 10, "gadget" => 8}.fetch(sku) - count == initial + WAREHOUSE[:received].fetch(sku, 0) - WAREHOUSE[:picked].fetch(sku, 0) - end - } -}.freeze - -JOBS = [ - {name: "receive 5 widgets", work: -> { - WAREHOUSE[:stock]["widget"] += 5 - WAREHOUSE[:received]["widget"] = WAREHOUSE[:received].fetch("widget", 0) + 5 - }}, - {name: "pick 3 gadgets", work: -> { - WAREHOUSE[:stock]["gadget"] -= 3 - WAREHOUSE[:picked]["gadget"] = WAREHOUSE[:picked].fetch("gadget", 0) + 3 - }}, - {name: "pick 2 widgets (buggy picker)", work: -> { - WAREHOUSE[:stock]["widget"] -= 3 # decrements 3, records 2: the bug - WAREHOUSE[:picked]["widget"] = WAREHOUSE[:picked].fetch("widget", 0) + 2 - }}, - {name: "receive 4 gadgets", work: -> { - WAREHOUSE[:stock]["gadget"] += 4 - WAREHOUSE[:received]["gadget"] = WAREHOUSE[:received].fetch("gadget", 0) + 4 - }} -].freeze - -violations = [] -orchestrator = nil - -sentinel = lambda do |task_id:, task:, result:, duration:| - INVARIANTS.each do |law, check| - next if check.call - - violations << {task: task.description, law: law, state: Marshal.load(Marshal.dump(WAREHOUSE))} - orchestrator.cancel_plan - end -end - -orchestrator = Agentic::PlanOrchestrator.new( - concurrency_limit: 1, # deterministic order so the culprit is unambiguous - lifecycle_hooks: {after_task_success: sentinel} -) - -previous = nil -JOBS.each do |job| - task = Agentic::Task.new( - description: job[:name], - agent_spec: {"name" => "warehouse", "instructions" => "do the job"}, - payload: job[:work] - ) - orchestrator.add_task(task, previous ? [previous] : [], agent: ->(t) { t.payload.call || :done }) - previous = task -end - -result = orchestrator.execute_plan - -puts "INVARIANT SENTINEL: #{INVARIANTS.size} laws watching #{JOBS.size} jobs" -puts -puts "plan status: #{result.status}" -completed = result.results.values.count(&:successful?) -puts "jobs completed before the stop: #{completed} of #{JOBS.size}" -puts - -if violations.empty? - puts "every law held. suspiciously well-behaved." -else - violations.each do |violation| - puts "LAW BROKEN: \"#{violation[:law]}\"" - puts " by: #{violation[:task]}" - puts " world state at the moment of arrest:" - puts " stock: #{violation[:state][:stock]}" - puts " received: #{violation[:state][:received]}" - puts " picked: #{violation[:state][:picked]}" - end - puts - puts "the plan stopped at the FIRST broken law - 'receive 4 gadgets'" - puts "never ran. corruption caught at the task that caused it is a" - puts "bug report; corruption found at month-end close is an incident." -end diff --git a/examples/jitter_shootout.rb b/examples/jitter_shootout.rb deleted file mode 100644 index 55467fc..0000000 --- a/examples/jitter_shootout.rb +++ /dev/null @@ -1,84 +0,0 @@ -# frozen_string_literal: true - -# The Jitter Shootout: none vs equal (+/-25%, the default) vs full -# (uniform over [0, delay], new this round) - same forty workers, same -# synchronized failure, three retry-arrival histograms. Pick your -# herd size with your eyes open. -# -# bundle exec ruby examples/jitter_shootout.rb [seed] -# -# Runs offline and deterministically. - -require_relative "../lib/agentic" - -Agentic.logger.level = :fatal - -WORKERS = 40 -BACKOFF = 0.15 -BUCKET_MS = 25 - -def run_mode(jitter, seed) - srand(seed) - started = Process.clock_gettime(Process::CLOCK_MONOTONIC) - arrivals = [] - attempts = Hash.new(0) - - orchestrator = Agentic::PlanOrchestrator.new( - concurrency_limit: WORKERS, - retry_policy: { - max_retries: 2, - retryable_errors: ["RuntimeError"], - backoff_strategy: :constant, - backoff_constant: BACKOFF, - backoff_jitter: jitter - } - ) - - WORKERS.times do |i| - orchestrator.add_task(Agentic::Task.new( - description: "worker-#{i}", - agent_spec: {"name" => "worker", "instructions" => "call"} - ), agent: ->(t) { - attempts[t.description] += 1 - raise "hiccup" if attempts[t.description] == 1 - - arrivals << Process.clock_gettime(Process::CLOCK_MONOTONIC) - started - :ok - }) - end - orchestrator.execute_plan - arrivals -end - -def report(label, arrivals) - buckets = arrivals.group_by { |t| (t * 1000 / BUCKET_MS).floor } - peak = buckets.values.map(&:size).max - spread = ((arrivals.max - arrivals.min) * 1000).round - - puts " #{label}:" - buckets.sort.each do |bucket, hits| - puts format(" %4d-%4dms %-40s %d", bucket * BUCKET_MS, (bucket + 1) * BUCKET_MS, - "#" * hits.size, hits.size) - end - puts format(" peak %d workers per bucket, spread %dms", peak, spread) - puts - peak -end - -seed = (ARGV.first || 20260707).to_i -puts "JITTER SHOOTOUT: #{WORKERS} workers, synchronized failure, " \ - "#{(BACKOFF * 1000).round}ms base backoff (seed #{seed})" -puts - -peaks = { - "none (jitter: false)" => run_mode(false, seed), - "equal +/-25% (the default)" => run_mode(true, seed), - "full [0, delay] (jitter: :full)" => run_mode(:full, seed) -}.map { |label, arrivals| [label, report(label, arrivals)] } - -puts "scoreboard (peak herd, smaller is safer):" -peaks.each { |label, peak| puts format(" %-32s %2d of %d", label, peak, WORKERS) } -puts -puts "full jitter trades punctuality for survival: retries land anywhere" -puts "in [0, delay], so some come back early and few come back TOGETHER." -puts "when the upstream is already hurting, together is the only wrong time." diff --git a/examples/job_adapter.rb b/examples/job_adapter.rb deleted file mode 100644 index ecc27c1..0000000 --- a/examples/job_adapter.rb +++ /dev/null @@ -1,102 +0,0 @@ -# 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_audit.rb b/examples/journal_audit.rb deleted file mode 100644 index 46bc2ab..0000000 --- a/examples/journal_audit.rb +++ /dev/null @@ -1,94 +0,0 @@ -# frozen_string_literal: true - -# The Journal Audit: seven tools now trust the journal, so the journal -# itself gets audited - well-formed lines, monotonic timestamps, no -# success without a start, no double-success, plan_completed present. -# A corrupted journal is fed in; every planted defect is caught. -# -# bundle exec ruby examples/journal_audit.rb -# -# Runs offline. Trust, then verify the thing you trust. - -require_relative "../lib/agentic" -require "tmpdir" -require "json" - -CHECKS = { - "well-formed JSON per line" => ->(entries, raw) { - raw.each_with_index.reject { |line, _| - begin - JSON.parse(line) - rescue - nil - end - } - .map { |_, i| "line #{i + 1} is not valid JSON" } - }, - "timestamps monotonic" => ->(entries, _raw) { - entries.each_cons(2).with_index.filter_map { |(a, b), i| - "line #{i + 2} time-travels (#{b[:at]} < #{a[:at]})" if b[:at] && a[:at] && b[:at] < a[:at] - } - }, - "no success without a start" => ->(entries, _raw) { - started = entries.select { |e| e[:event] == "task_started" }.map { |e| e[:task_id] } - entries.select { |e| e[:event] == "task_succeeded" && !started.include?(e[:task_id]) } - .map { |e| "#{e[:description] || e[:task_id]} succeeded without ever starting" } - }, - "no double success" => ->(entries, _raw) { - entries.select { |e| e[:event] == "task_succeeded" } - .group_by { |e| e[:task_id] }.select { |_, v| v.size > 1 } - .map { |id, v| "task #{v.first[:description] || id} succeeded #{v.size} times" } - }, - "durations non-negative" => ->(entries, _raw) { - entries.select { |e| e[:duration]&.negative? } - .map { |e| "#{e[:description]} has negative duration #{e[:duration]}" } - } -}.freeze - -def audit(path) - raw = File.readlines(path, encoding: "UTF-8").map(&:strip).reject(&:empty?) - entries = raw.filter_map do |line| - JSON.parse(line, symbolize_names: true) - rescue JSON::ParserError - nil - end - CHECKS.transform_values { |check| check.call(entries, raw) } -end - -# --- a healthy journal, written by the real machinery ------------------------ -dir = Dir.mktmpdir -healthy_path = File.join(dir, "healthy.jsonl") -journal = Agentic::ExecutionJournal.new(path: healthy_path) -orchestrator = Agentic::PlanOrchestrator.new(lifecycle_hooks: journal.lifecycle_hooks) -task = Agentic::Task.new(description: "honest work", agent_spec: {"name" => "w", "instructions" => "w"}) -orchestrator.add_task(task, agent: ->(_t) { :ok }) -orchestrator.execute_plan - -# --- a tampered journal: four planted defects --------------------------------- -tampered_path = File.join(dir, "tampered.jsonl") -lines = File.readlines(healthy_path).map(&:strip) -File.open(tampered_path, "w") do |f| - f.puts lines[0] # task_started - f.puts '{"event": "task_succeeded", "task_id": "ghost-1", "description": "phantom deploy", "at": "2026-07-07T00:00:00.000Z", "duration": 0.01}' - f.puts lines[1] # the real success - f.puts lines[1] # ...twice - f.puts '{"event": "task_succeeded", "task_id": "neg-1", "description": "time thief", "at": "2020-01-01T00:00:00.000Z", "duration": -3}' - f.puts "this line is not json at all" -end - -puts "JOURNAL AUDIT (#{CHECKS.size} integrity checks)" -[["healthy journal", healthy_path], ["tampered journal", tampered_path]].each do |label, path| - findings = audit(path) - total = findings.values.sum(&:size) - puts - puts " #{label}: #{total.zero? ? "clean" : "#{total} defect(s)"}" - findings.each do |check, problems| - problems.each { |problem| puts " [#{check}] #{problem}" } - end -end - -puts -puts "the journal underwrites resume, baselines, check-ins, and incident" -puts "reports - four products built on one file's honesty. an audit that" -puts "runs before replay is how you keep seven tools from inheriting one" -puts "corruption. auditors get audited; that's what makes them auditors." diff --git a/examples/journal_tail.rb b/examples/journal_tail.rb deleted file mode 100644 index 7b27e0a..0000000 --- a/examples/journal_tail.rb +++ /dev/null @@ -1,106 +0,0 @@ -# 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/json_schema_export.rb b/examples/json_schema_export.rb deleted file mode 100644 index f5a3ffa..0000000 --- a/examples/json_schema_export.rb +++ /dev/null @@ -1,131 +0,0 @@ -# frozen_string_literal: true - -# The Schema Export: a capability contract emitted as draft-07 JSON -# Schema (new this round), then PROVEN faithful - the same payloads -# are judged by Agentic's validator and by an independent interpreter -# reading only the exported document. Any disagreement means the -# projection lies. 200 seeded payloads: zero disagreements. -# -# bundle exec ruby examples/json_schema_export.rb [seed] -# -# Runs offline; prints the schema and the agreement score. - -require_relative "../lib/agentic" -require "json" - -seed = (ARGV.first || 20260707).to_i -rng = Random.new(seed) - -spec = Agentic::CapabilitySpecification.new( - name: "book_shipment", - description: "Book a shipment", - version: "1.0.0", - inputs: { - mode: {type: "string", required: true, enum: %w[air sea road]}, - weight_kg: {type: "number", required: true, min: 1, max: 30_000}, - reference: {type: "string", required: true, non_empty: true}, - fragile: {type: "boolean"}, - tags: {type: "array", non_empty: true} - } -) - -schema = spec.to_json_schema - -puts "THE EXPORTED SCHEMA" -puts JSON.pretty_generate(schema).gsub(/^/, " ") -puts - -# --- an independent interpreter that knows ONLY the JSON document ------------ -def schema_accepts?(schema, payload) - data = payload.transform_keys(&:to_s) - - return false unless schema["required"].all? { |key| data.key?(key) } - - schema["properties"].all? do |key, rules| - next true unless data.key?(key) - - value = data[key] - type_ok = case rules["type"] - when "string" then value.is_a?(String) - when "number" then value.is_a?(Numeric) - when "boolean" then value == true || value == false - when "array" then value.is_a?(Array) - else true - end - - type_ok && - (rules["enum"].nil? || rules["enum"].include?(value)) && - (rules["minimum"].nil? || (value.is_a?(Numeric) && value >= rules["minimum"])) && - (rules["maximum"].nil? || (value.is_a?(Numeric) && value <= rules["maximum"])) && - (rules["minLength"].nil? || (value.respond_to?(:length) && value.length >= rules["minLength"])) && - (rules["minItems"].nil? || (value.is_a?(Array) && value.size >= rules["minItems"])) - end -end - -# --- generate payloads that wander in and out of validity -------------------- -def valid_payload(rng) - { - mode: %w[air sea road].sample(random: rng), - weight_kg: rng.rand(1..30_000), - reference: "REF-#{rng.rand(1000)}", - fragile: [true, false].sample(random: rng) - } -end - -def corrupted_payload(rng) - payload = valid_payload(rng) - if rng.rand < 0.6 - corruptions = { - mode: ["teleport", 7], weight_kg: [0, "heavy", 50_000], - reference: [""], fragile: ["yes"], tags: [[]] - } - field = corruptions.keys.sample(random: rng) - payload[field] = corruptions[field].sample(random: rng) - end - payload -end - -def chaos_payload(rng) - payload = {} - payload[:mode] = ["air", "sea", "teleport", 7].sample(random: rng) if rng.rand < 0.8 - payload[:weight_kg] = [rng.rand(1..30_000), -4, "heavy"].sample(random: rng) if rng.rand < 0.8 - payload[:reference] = ["REF-#{rng.rand(1000)}", ""].sample(random: rng) if rng.rand < 0.8 - payload[:tags] = [["a"], []].sample(random: rng) if rng.rand < 0.4 - payload -end - -def random_payload(rng) - # Half start valid and get one field corrupted (or not); half are chaos - (rng.rand < 0.5) ? corrupted_payload(rng) : chaos_payload(rng) -end - -validator = Agentic::CapabilityValidator.new(spec) -trials = 200 -disagreements = [] -accepted = 0 - -trials.times do - payload = random_payload(rng) - - agentic_verdict = begin - validator.validate_inputs!(payload) - true - rescue Agentic::Errors::ValidationError - false - end - - schema_verdict = schema_accepts?(schema, payload) - accepted += 1 if agentic_verdict - disagreements << payload if agentic_verdict != schema_verdict -end - -puts "THE AGREEMENT PROOF (#{trials} seeded payloads, #{accepted} valid)" -if disagreements.empty? - puts " the exported schema and the live validator agreed on every payload." - puts " the projection is faithful: what the JSON document promises is" - puts " exactly what the boundary enforces." -else - puts " DISAGREEMENTS (the export lies about the contract):" - disagreements.first(5).each { |payload| puts " - #{payload.inspect}" } - exit 1 -end diff --git a/examples/kanban_board.rb b/examples/kanban_board.rb deleted file mode 100644 index ca57aa1..0000000 --- a/examples/kanban_board.rb +++ /dev/null @@ -1,73 +0,0 @@ -# frozen_string_literal: true - -# The Kanban Board: a plan rendered as the three columns everyone -# actually understands - To Do, Doing, Done - reprinted at every state -# change while the plan runs. No standup, no sync meeting, no PM tool -# subscription: the orchestrator IS the board. -# -# bundle exec ruby examples/kanban_board.rb -# -# Runs offline; watch the cards move. - -require_relative "../lib/agentic" - -CARDS = { - "write copy" => 0.06, - "shoot photos" => 0.10, - "edit photos" => 0.05, - "layout page" => 0.08, - "review" => 0.04, - "publish" => 0.03 -}.freeze - -DEPS = { - "edit photos" => ["shoot photos"], - "layout page" => ["write copy", "edit photos"], - "review" => ["layout page"], - "publish" => ["review"] -}.freeze - -board = {todo: CARDS.keys.dup, doing: [], done: []} -frames = [] - -move = lambda do |card, from, to| - board[from].delete(card) - board[to] << card - columns = [:todo, :doing, :done].map { |col| board[col] } - height = columns.map(&:size).max - frame = +" %-16s %-16s %-16s\n" % ["TO DO", "DOING", "DONE"] - frame << " #{"-" * 14} #{"-" * 14} #{"-" * 14}\n" - height.times do |i| - frame << (" %-16s %-16s %-16s\n" % columns.map { |col| col[i] || "" }) - end - frames << frame -end - -hooks = { - task_slot_acquired: ->(task_id:, task:, waited:) { move.call(task.description, :todo, :doing) }, - after_task_success: ->(task_id:, task:, result:, duration:) { move.call(task.description, :doing, :done) } -} - -orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 2, lifecycle_hooks: hooks) -tasks = {} -CARDS.each do |name, duration| - tasks[name] = Agentic::Task.new( - description: name, - agent_spec: {"name" => name, "instructions" => "do the work"}, - payload: duration - ) - orchestrator.add_task(tasks[name], (DEPS[name] || []).map { |d| tasks.fetch(d) }, - agent: ->(t) { sleep(t.payload) || :done }) -end - -result = orchestrator.execute_plan - -puts "KANBAN (#{frames.size} board states, #{(result.execution_time * 1000).round}ms wall, 2 people on the team)" -puts -# Show the opening, one mid-flight frame, and the final board -[frames.first, frames[frames.size / 2], frames.last].each_with_index do |frame, i| - puts ["at the start:", "mid-flight:", "at the end:"][i] - puts frame -end -puts "every frame above was captured live from lifecycle hooks - the" -puts "board is the plan's actual state, not somebody's memory of it." diff --git a/examples/knee_finder.rb b/examples/knee_finder.rb deleted file mode 100644 index 2343e00..0000000 --- a/examples/knee_finder.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -# The Knee Finder: runs the same plan at increasing concurrency limits, -# measures wall time and total queue-wait via the task_slot_acquired -# hook, and recommends the limit where adding lanes stops paying. -# Guessing concurrency limits is a superstition; this is a measurement. -# -# bundle exec ruby examples/knee_finder.rb -# -# Runs offline; the workload is 12 simulated API calls of mixed latency. - -require_relative "../lib/agentic" - -# One slow call dominates, as it always does in production -LATENCIES = [0.08, 0.05, 0.12, 0.06, 0.30, 0.05, 0.15, 0.07, 0.05, 0.09, 0.06, 0.12].freeze - -def run_at(limit) - queue_wait = 0.0 - orchestrator = Agentic::PlanOrchestrator.new( - concurrency_limit: limit, - lifecycle_hooks: { - task_slot_acquired: ->(task_id:, task:, waited:) { queue_wait += waited } - } - ) - - LATENCIES.each_with_index do |latency, i| - orchestrator.add_task(Agentic::Task.new( - description: "call-#{i}", - agent_spec: {"name" => "api", "instructions" => "wait"}, - payload: latency - ), agent: ->(t) { sleep(t.payload) || :ok }) - end - - result = orchestrator.execute_plan - {wall: result.execution_time, queue_wait: queue_wait} -end - -puts "KNEE FINDER: #{LATENCIES.size} calls, #{(LATENCIES.sum * 1000).round}ms of total IO" -puts -puts " limit wall total queue-wait" - -measurements = [1, 2, 3, 4, 6, 8, 12].to_h do |limit| - m = run_at(limit) - bar = "#" * (m[:wall] * 40).round - puts format(" %5d %4dms %6dms %s", limit, m[:wall] * 1000, m[:queue_wait] * 1000, bar) - [limit, m] -end - -# The knee: smallest limit whose wall time is within 15% of the best -best = measurements.values.map { |m| m[:wall] }.min -knee = measurements.find { |_, m| m[:wall] <= best * 1.15 }.first - -puts -puts " recommendation: concurrency_limit #{knee}" -puts " (smallest limit within 15% of the best wall time - beyond it you" -puts " hold more connections open to save less time than the jitter)" diff --git a/examples/live_dashboard.rb b/examples/live_dashboard.rb deleted file mode 100644 index d6c61e1..0000000 --- a/examples/live_dashboard.rb +++ /dev/null @@ -1,80 +0,0 @@ -# frozen_string_literal: true - -# The Live Dashboard: lifecycle hooks publish events onto an -# Async::Queue; a consumer task IN THE SAME REACTOR renders the plan's -# state as it changes. This is the "streaming observability" the -# architecture documents promise, built from a queue and the hooks -# that already exist - about thirty structural lines. -# -# bundle exec ruby examples/live_dashboard.rb -# -# Runs offline; watch the states flip while the plan executes. - -require_relative "../lib/agentic" -require "async/queue" - -WORK = { - "resize:thumbnails" => {sleep: 0.15, deps: []}, - "transcode:video" => {sleep: 0.30, deps: []}, - "extract:captions" => {sleep: 0.10, deps: []}, - "compose:preview" => {sleep: 0.12, deps: ["resize:thumbnails", "extract:captions"]}, - "publish:episode" => {sleep: 0.05, deps: ["compose:preview", "transcode:video"]} -}.freeze - -events = Async::Queue.new - -hooks = { - before_task_execution: ->(task_id:, task:) { events.enqueue([:queued, task.description]) }, - after_agent_build: ->(task_id:, task:, agent:, build_duration:) { events.enqueue([:running, task.description]) }, - after_task_success: ->(task_id:, task:, result:, duration:) { - events.enqueue([:done, task.description, duration]) - }, - plan_completed: ->(plan_id:, status:, execution_time:, tasks:, results:) { - events.enqueue([:plan_done, status, execution_time]) - } -} - -orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 2, lifecycle_hooks: hooks) -tasks = {} -WORK.each do |name, spec| - task = Agentic::Task.new( - description: name, - agent_spec: {"name" => name, "instructions" => "process"}, - payload: spec[:sleep] - ) - tasks[name] = task - orchestrator.add_task(task, spec[:deps].map { |d| tasks.fetch(d) }, agent: ->(t) { - sleep(t.payload) - :ok - }) -end - -started = Process.clock_gettime(Process::CLOCK_MONOTONIC) -stamp = -> { format("%5dms", (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000) } - -puts "LIVE DASHBOARD (plan and renderer sharing one reactor, concurrency 2)" -puts - -Sync do |host| - # The renderer: a sibling task consuming the event stream live - renderer = host.async do - loop do - event = events.dequeue - case event.first - when :queued then puts "#{stamp.call} ~ queued #{event[1]}" - when :running then puts "#{stamp.call} > running #{event[1]}" - when :done then puts format("%s + done %-20s (ran %dms)", stamp.call, event[1], event[2] * 1000) - when :plan_done - puts format("%s = plan %s in %dms", stamp.call, event[1], event[2] * 1000) - break - end - end - end - - orchestrator.execute_plan - renderer.wait -end - -puts -puts "every line above was printed WHILE the plan ran - the hooks are a" -puts "live event stream, not a post-mortem log" diff --git a/examples/money_discipline.rb b/examples/money_discipline.rb deleted file mode 100644 index d64ad56..0000000 --- a/examples/money_discipline.rb +++ /dev/null @@ -1,103 +0,0 @@ -# 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/namespace_cartographer.rb b/examples/namespace_cartographer.rb deleted file mode 100644 index 647717e..0000000 --- a/examples/namespace_cartographer.rb +++ /dev/null @@ -1,113 +0,0 @@ -# frozen_string_literal: true - -# The Namespace Cartographer: maps a gem's constant tree and audits -# every file against the constant Zeitwerk expects it to define. -# One orchestrator task per file; Prism reads the actual definitions. -# -# bundle exec ruby examples/namespace_cartographer.rb [lib_dir] -# -# Defaults to surveying this gem. A conforming codebase produces a map -# with no annotations; every deviation is listed with what was expected -# and what was found. - -require_relative "../lib/agentic" -require "prism" - -LIB = File.expand_path(ARGV.first || "#{__dir__}/../lib") -INFLECTIONS = {"cli" => "CLI", "ui" => "UI"}.freeze - -def camelize(segment) - INFLECTIONS.fetch(segment) { segment.split("_").map(&:capitalize).join } -end - -# The constant Zeitwerk expects lib/foo/bar_baz.rb to define. -# Zeitwerk::GemInflector special-cases the gem's version.rb: it expects -# Foo::VERSION, not Foo::Version - a lesson this cartographer learned -# by first drawing the deviation on its own map. -def expected_constant(relative_path) - segments = relative_path.delete_suffix(".rb").split("/") - return "#{camelize(segments.first)}::VERSION" if segments.length == 2 && segments.last == "version" - - segments.map { |seg| camelize(seg) }.join("::") -end - -# Collects every module/class defined in a parse tree, as full paths -def collect_definitions(node, namespace, found) - return unless node - - case node - when Prism::ModuleNode, Prism::ClassNode - name = node.constant_path.slice - full = [namespace, name].reject(&:empty?).join("::") - found << full - node.child_nodes.each { |child| collect_definitions(child, full, found) } - when Prism::ConstantWriteNode - found << [namespace, node.name.to_s].reject(&:empty?).join("::") - else - node.child_nodes.each { |child| collect_definitions(child, namespace, found) } - end -end - -spec = Agentic::CapabilitySpecification.new( - name: "survey_file", - description: "Chart the constants a Ruby file defines", - version: "1.0.0", - inputs: {path: {type: "string", required: true}}, - outputs: {defined: {type: "array", required: true}} -) -Agentic.register_capability(spec, Agentic::CapabilityProvider.new( - capability: spec, - implementation: ->(inputs) { - found = [] - collect_definitions(Prism.parse_file(inputs[:path]).value, "", found) - {defined: found} - } -)) - -surveyor = Agentic::Agent.build { |a| a.name = "Cartographer" } -surveyor.add_capability("survey_file") - -files = Dir[File.join(LIB, "**", "*.rb")].sort -orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 8) -tasks = files.to_h do |path| - task = Agentic::Task.new( - description: File.basename(path), - agent_spec: {"name" => "Cartographer", "instructions" => "Survey the file"}, - payload: path - ) - orchestrator.add_task(task, agent: ->(t) { - surveyor.execute_capability("survey_file", {path: t.payload})[:defined] - }) - [path, task] -end -result = orchestrator.execute_plan -charts = tasks.transform_values { |task| result.results[task.id].output } - -# Compare the map against the territory -deviations = [] -tree = Hash.new(0) -files.each do |path| - relative = path.delete_prefix("#{LIB}/") - expected = expected_constant(relative) - defined = charts.fetch(path, []) - - tree[relative.split("/").first(2).join("/").delete_suffix(".rb")] += 1 - unless defined.include?(expected) - deviations << {file: relative, expected: expected, found: defined.first(3)} - end -end - -puts "NAMESPACE MAP of #{LIB}" -puts "(#{files.size} files surveyed, #{result.status} in #{(result.execution_time * 1000).round}ms)" -puts -tree.sort.each { |region, count| puts format(" %-46s %3d file(s)", region, count) } -puts -if deviations.empty? - puts "Every file defines the constant its path promises. The map IS the territory." -else - puts "DEVIATIONS (#{deviations.size}):" - deviations.each do |d| - puts " #{d[:file]}" - puts " expected #{d[:expected]}, defines #{d[:found].join(", ")}" - end -end diff --git a/examples/onboarding_trail.rb b/examples/onboarding_trail.rb deleted file mode 100644 index 9fe189f..0000000 --- a/examples/onboarding_trail.rb +++ /dev/null @@ -1,71 +0,0 @@ -# frozen_string_literal: true - -# The Onboarding Trail: a codebase is a place people live, and new -# teammates don't need a map of every pipe - they need a TOUR: which -# room to enter first, and why each room makes sense given the ones -# you've seen. This computes the tour from the code itself: scan who -# mentions whom, then order the rooms so no stop assumes a concept -# you haven't met yet. -# -# bundle exec ruby examples/onboarding_trail.rb -# -# Runs offline; the trail is derived, not curated (mostly). - -require_relative "../lib/agentic" - -LIB = File.expand_path("../lib/agentic", __dir__) - -# What each room is FOR - the one sentence a tour guide adds that a -# dependency graph can't -ROOM_NOTES = { - "task_failure" => "how this house talks about things going wrong (failure as data)", - "task_result" => "the envelope every outcome arrives in", - "task" => "the unit of work: lifecycle, payloads, needs", - "rate_limit" => "the shared front door: ceilings, windows, resize", - "execution_journal" => "the house's memory: fsynced, replayable, per-shard", - "relation_rules" => "predicates as data - rules tools can read", - "capability_specification" => "contracts: declared inputs, outputs, rules", - "capability_validator" => "the barricade that enforces the contracts", - "plan_orchestrator" => "the living room where everything meets: scheduling, hooks, the graph" -}.freeze - -# Who mentions whom, from the source itself -files = ROOM_NOTES.keys.to_h do |name| - source = File.read(File.join(LIB, "#{name}.rb"), encoding: "UTF-8") - constants = source.scan(/\b(?:Agentic::)?([A-Z][A-Za-z]+)\b/).flatten.uniq - mentioned = ROOM_NOTES.keys.select { |other| - other != name && constants.include?(other.split("_").map(&:capitalize).join) - } - [name, {mentions: mentioned, lines: source.lines.size}] -end - -# The trail: repeatedly visit the room with the fewest unmet mentions -trail = [] -until trail.size == files.size - next_room = files.keys.reject { |f| trail.include?(f) } - .min_by { |f| [(files[f][:mentions] - trail).size, files[f][:lines]] } - trail << next_room -end - -puts "THE ONBOARDING TRAIL (computed from who-mentions-whom)" -puts -puts " day one, in order - no room assumes one you haven't seen:" -puts -trail.each_with_index do |room, index| - unmet = files[room][:mentions] - trail[0..index] - puts format(" %d. %-26s %4d lines %s", index + 1, room, files[room][:lines], ROOM_NOTES[room]) - puts format(" %s", "(mentions #{files[room][:mentions].join(", ")})") if files[room][:mentions].any? - puts " WARNING: tour visits this before #{unmet.join(", ")}" if unmet.any? -end - -puts -puts " why a trail instead of a map: a map answers \"where is\" and" -puts " nobody's first question is where - it's \"what should I read" -puts " FIRST so the rest makes sense?\" the ordering came from the" -puts " code (fewest unmet concepts next), and the one-line room notes" -puts " came from a human, which is the correct split: structure is" -puts " derivable, PURPOSE isn't. notice the trail starts with failure -" -puts " this house talks about failure before it talks about work, and" -puts " a new teammate who learns that on day one has learned the" -puts " house's values, not just its layout. codebases are places" -puts " people live; give the new roommate a tour, not a blueprint." diff --git a/examples/one_file_api.rb b/examples/one_file_api.rb deleted file mode 100644 index e0007da..0000000 --- a/examples/one_file_api.rb +++ /dev/null @@ -1,89 +0,0 @@ -# frozen_string_literal: true - -# The One-File API: an endpoint is a contract wearing HTTP. Declare -# the capability once and the rest is derived - the 422s (with -# relation rules explained), the 201, and the machine-readable schema -# your client generator reads. No serializer classes, no validator -# classes, no docs pipeline. One declaration, three doors. -# -# bundle exec ruby examples/one_file_api.rb -# -# Runs offline; requests are simulated, responses are real. - -require_relative "../lib/agentic" -require "json" - -QUOTES = Agentic::CapabilitySpecification.new( - name: "quotes", description: "Quote a shipment", version: "3.0.0", - inputs: { - mode: {type: "string", required: true, enum: %w[air sea road]}, - weight: {type: "number", required: true, min: 1, max: 5_000}, - volume: {type: "number", min: 0}, - express: {type: "boolean"}, - customs_code: {type: "string"} - }, - outputs: {price_cents: {type: "number", required: true}}, - rules: { - fits: {relation: :sum_lte, fields: [:weight, :volume], limit: 6_000}, - customs: {relation: :requires, fields: [:express, :customs_code]} - } -) - -RATES = {"air" => 9, "sea" => 2, "road" => 4}.freeze - -# The entire app. Everything else in this file is derived from QUOTES. -def create_quote(params) - {price_cents: (params[:weight] * RATES[params[:mode]] * (params[:express] ? 2 : 1)).round} -end - -# --- the derived API layer ----------------------------------------------------- -def handle(method, path, body = nil) - case [method, path] - in ["GET", "/quotes/schema"] - [200, QUOTES.to_json_schema] - in ["POST", "/quotes"] - validator = Agentic::CapabilityValidator.new(QUOTES) - begin - params = body.transform_keys(&:to_sym) - validator.validate_inputs!(params) - output = create_quote(params) - validator.validate_outputs!(output) # the contract guards BOTH doors - [201, output] - rescue Agentic::Errors::ValidationError => e - errors = e.violations.except(:base).map { |field, messages| {field: field, errors: messages} } - errors += e.rule_violations.map { |v| {rule: v[:rule], fields: v[:fields], error: v[:message]} } - [422, {errors: errors}] - end - else - [404, {error: "no such route"}] - end -end - -REQUESTS = [ - ["GET", "/quotes/schema", nil], - ["POST", "/quotes", {"mode" => "teleport", "weight" => 9_000}], - ["POST", "/quotes", {"mode" => "air", "weight" => 4_000, "volume" => 3_000}], - ["POST", "/quotes", {"mode" => "air", "weight" => 100, "express" => true}], - ["POST", "/quotes", {"mode" => "air", "weight" => 100, "express" => true, "customs_code" => "HS-42"}] -].freeze - -puts "THE ONE-FILE API (#{QUOTES.name} v#{QUOTES.version})" -puts -REQUESTS.each do |method, path, body| - status, response = handle(method, path, body) - puts " #{method} #{path}#{body ? " #{JSON.generate(body)}" : ""}" - rendered = JSON.generate(response) - rendered = "#{rendered[0, 100]}... (#{rendered.size} bytes)" if rendered.size > 110 - puts " -> #{status} #{rendered}" - puts -end - -schema = QUOTES.to_json_schema -puts " count what you didn't write: the 422 renderer never mentions a" -puts " field name, the schema endpoint is one method call, and the" -puts " relation rules flow to BOTH doors - the 422 explains" -puts " \"#{QUOTES.rules[:customs][:fields].first} requires #{QUOTES.rules[:customs][:fields].last}\" to humans, while the schema's" -puts " dependencies clause (#{JSON.generate(schema["dependencies"])}) tells" -puts " client generators the same law in draft-07. one declaration," -puts " and the API layer is just... reading it. the best code in your" -puts " app is the code that isn't there." diff --git a/examples/perf_diff.rb b/examples/perf_diff.rb deleted file mode 100644 index 8dd8ef9..0000000 --- a/examples/perf_diff.rb +++ /dev/null @@ -1,98 +0,0 @@ -# frozen_string_literal: true - -# The Perf Diff: run the plan before and after a change, diff per-task -# durations, and flag regressions - with the one qualifier that decides -# whether anyone should care: is the regressed task ON the critical -# path? Off-path regressions are trivia; on-path regressions are the -# release note nobody wrote. -# -# bundle exec ruby examples/perf_diff.rb -# -# Runs offline; the "change" speeds one task up and quietly breaks -# another, as changes do. - -require_relative "../lib/agentic" - -BASELINE = { - "fetch:prices" => {sleep: 0.10, deps: []}, - "fetch:inventory" => {sleep: 0.06, deps: []}, - "reprice:catalog" => {sleep: 0.08, deps: ["fetch:prices", "fetch:inventory"]}, - "index:search" => {sleep: 0.05, deps: ["reprice:catalog"]}, - "warm:cache" => {sleep: 0.04, deps: ["reprice:catalog"]} -}.freeze - -# The optimization sped up repricing... and the same PR made -# fetch:prices slower. Ship it? Let's find out. -AFTER_THE_PR = BASELINE.merge( - "reprice:catalog" => BASELINE["reprice:catalog"].merge(sleep: 0.03), - "fetch:prices" => BASELINE["fetch:prices"].merge(sleep: 0.16) -).freeze - -def measure(work) - durations = {} - orchestrator = Agentic::PlanOrchestrator.new( - concurrency_limit: 4, - lifecycle_hooks: { - after_task_success: ->(task_id:, task:, result:, duration:) { durations[task.description] = duration } - } - ) - tasks = {} - work.each do |name, spec| - tasks[name] = Agentic::Task.new(description: name, - agent_spec: {"name" => name, "instructions" => "work"}, payload: spec[:sleep]) - orchestrator.add_task(tasks[name], spec[:deps].map { |d| tasks.fetch(d) }, - agent: ->(t) { sleep(t.payload) || :ok }) - end - result = orchestrator.execute_plan - [durations, result.execution_time, orchestrator.graph] -end - -def critical_path_names(graph, durations) - names = graph[:tasks].transform_values(&:description) - memo = {} - walk = lambda do |task_id| - memo[task_id] ||= begin - best = graph[:dependencies][task_id].map { |d| walk.call(d) }.max_by { |p| p[:cost] } || {cost: 0.0, path: []} - {cost: best[:cost] + durations[names[task_id]], path: best[:path] + [names[task_id]]} - end - end - graph[:order].map { |id| walk.call(id) }.max_by { |p| p[:cost] }[:path] -end - -NOISE_MS = 15 - -before, wall_before, = measure(BASELINE) -after, wall_after, graph = measure(AFTER_THE_PR) -path_after = critical_path_names(graph, after) - -puts "PERF DIFF (noise floor #{NOISE_MS}ms)" -puts -puts format(" %-18s %9s %9s %9s %s", "task", "before", "after", "delta", "") -regressions = [] -BASELINE.each_key do |name| - delta_ms = (after[name] - before[name]) * 1000 - marker = - if delta_ms.abs < NOISE_MS then "" - elsif delta_ms.negative? then "faster" - else - on_path = path_after.include?(name) - regressions << {name: name, on_path: on_path} - on_path ? "SLOWER + ON CRITICAL PATH" : "slower (off-path)" - end - puts format(" %-18s %7dms %7dms %+8dms %s", - name, before[name] * 1000, after[name] * 1000, delta_ms, marker) -end - -puts -puts format(" wall clock: %dms -> %dms (%+dms)", - wall_before * 1000, wall_after * 1000, (wall_after - wall_before) * 1000) -puts -blocking = regressions.select { |r| r[:on_path] } -if blocking.any? - puts " VERDICT: don't ship. #{blocking.map { |r| r[:name] }.join(", ")} regressed" - puts " on the critical path - the repricing win is real and the users" - puts " will never feel it, because the wall clock got worse anyway." - exit 1 -else - puts " VERDICT: ship it." -end diff --git a/examples/perf_history.rb b/examples/perf_history.rb deleted file mode 100644 index bbaf3cc..0000000 --- a/examples/perf_history.rb +++ /dev/null @@ -1,90 +0,0 @@ -# frozen_string_literal: true - -# Perf History: last release's run left a journal; this release's run -# is compared against it. No synthetic baseline, no same-process -# rerun - the baseline is what production actually did, keyed by task -# description, straight from ExecutionJournal durations. -# -# bundle exec ruby examples/perf_history.rb -# -# Runs offline; "last release" is journaled first, then "this release" -# runs against its recorded history. - -require_relative "../lib/agentic" -require "tmpdir" - -BASELINE_JOURNAL = File.join(Dir.tmpdir, "agentic_perf_history.jsonl") -File.delete(BASELINE_JOURNAL) if File.exist?(BASELINE_JOURNAL) - -RELEASE_1 = { - "resize:images" => 0.06, - "transcode:audio" => 0.11, - "generate:captions" => 0.08, - "package:episode" => 0.05 -}.freeze - -# This release: captions got slower (new model), packaging got faster -RELEASE_2 = RELEASE_1.merge( - "generate:captions" => 0.14, - "package:episode" => 0.02 -).freeze - -def run_release(work, journal: nil, collect: nil) - hooks = {} - hooks = journal.lifecycle_hooks if journal - if collect - hooks = (journal ? journal.lifecycle_hooks : {}).merge( - after_task_success: ->(task_id:, task:, result:, duration:) { collect[task.description] = duration } - ) - end - - orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 4, lifecycle_hooks: hooks) - previous = nil - work.each do |name, latency| - task = Agentic::Task.new(description: name, - agent_spec: {"name" => name, "instructions" => "work"}, payload: latency) - orchestrator.add_task(task, previous ? [previous] : [], agent: ->(t) { sleep(t.payload) || :ok }) - previous = task - end - orchestrator.execute_plan -end - -# --- release 1 ships; its journal IS the baseline ---------------------------- -run_release(RELEASE_1, journal: Agentic::ExecutionJournal.new(path: BASELINE_JOURNAL)) -baseline = Agentic::ExecutionJournal.replay(path: BASELINE_JOURNAL).durations - -# --- release 2 runs; the journal from last time judges it -------------------- -current = {} -run_release(RELEASE_2, collect: current) - -NOISE_MS = 15 - -puts "PERF HISTORY (baseline: #{BASELINE_JOURNAL.split("/").last}, noise floor #{NOISE_MS}ms)" -puts -puts format(" %-20s %10s %10s %9s", "task", "last release", "this one", "delta") -regressions = [] -current.each do |name, duration| - recorded = baseline[name] - next unless recorded - - delta_ms = (duration - recorded) * 1000 - verdict = - if delta_ms.abs < NOISE_MS then "" - elsif delta_ms.negative? then "faster" - else - regressions << name - "REGRESSED" - end - puts format(" %-20s %9.0fms %9.0fms %+8.0fms %s", - name, recorded * 1000, duration * 1000, delta_ms, verdict) -end - -puts -if regressions.any? - puts " #{regressions.join(", ")} regressed against the journal of the" - puts " last shipped release. the baseline wasn't a benchmark rig - it" - puts " was what actually ran, fsynced as it happened." - exit 1 -else - puts " no regressions against recorded history. ship." -end diff --git a/examples/performance_detective.rb b/examples/performance_detective.rb deleted file mode 100644 index d5ae85d..0000000 --- a/examples/performance_detective.rb +++ /dev/null @@ -1,92 +0,0 @@ -# frozen_string_literal: true - -# The Performance Detective: one task per Ruby file in lib/, fanned out -# through the orchestrator, each dissecting a file for long methods. -# The victim is this very gem. The report names names. -# -# bundle exec ruby examples/performance_detective.rb [concurrency] -# -# Runs offline - the "agent" here is Prism, Ruby's own parser, because -# the best LLM for counting your method lengths is the actual grammar. - -require_relative "../lib/agentic" -require "prism" - -LIB = File.expand_path("../lib", __dir__) - -# Walks a parsed tree collecting every def with its measured length -def collect_defs(node, found) - return unless node - - if node.is_a?(Prism::DefNode) - found << { - name: node.receiver ? "self.#{node.name}" : node.name.to_s, - line: node.location.start_line, - lines: node.location.end_line - node.location.start_line + 1 - } - end - node.child_nodes.each { |child| collect_defs(child, found) } -end - -# A capability that dissects one file: every def, with its length -spec = Agentic::CapabilitySpecification.new( - name: "dissect_file", - description: "Measure the methods in one Ruby file", - version: "1.0.0", - inputs: {path: {type: "string", required: true}}, - outputs: {methods: {type: "array", required: true}, lines: {type: "number", required: true}} -) -provider = Agentic::CapabilityProvider.new( - capability: spec, - implementation: ->(inputs) { - parsed = Prism.parse_file(inputs[:path]) - methods = [] - collect_defs(parsed.value, methods) - - {methods: methods, lines: parsed.source.source.count("\n")} - } -) -Agentic.register_capability(spec, provider) - -detective = Agentic::Agent.build { |a| a.name = "Detective" } -detective.add_capability("dissect_file") - -# Every file is a lead; every lead gets a task with the path as payload -concurrency = (ARGV.first || 16).to_i -files = Dir[File.join(LIB, "**", "*.rb")].sort - -orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: concurrency) -tasks = files.map do |path| - task = Agentic::Task.new( - description: File.basename(path), - agent_spec: {"name" => "Detective", "instructions" => "Dissect the file"}, - payload: path - ) - orchestrator.add_task(task, agent: ->(t) { - detective.execute_capability("dissect_file", {path: t.payload}) - }) - task -end - -started = Process.clock_gettime(Process::CLOCK_MONOTONIC) -result = orchestrator.execute_plan -elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round - -evidence = tasks.to_h { |task| [task.payload, result.results[task.id].output] } -all_methods = evidence.flat_map do |path, report| - report[:methods].map { |m| m.merge(file: path.delete_prefix("#{LIB}/")) } -end - -puts "CASE FILE: #{files.size} files, #{all_methods.size} methods, " \ - "#{evidence.values.sum { |r| r[:lines] }} lines" -puts "(#{result.status} in #{elapsed_ms}ms at concurrency #{concurrency})" -puts -puts "THE USUAL SUSPECTS (longest methods):" -all_methods.sort_by { |m| -m[:lines] }.first(7).each do |m| - puts format(" %3d lines %s (%s:%d)", m[:lines], m[:name], m[:file], m[:line]) -end -puts -puts "DENSEST NEIGHBORHOODS (methods per file):" -evidence.sort_by { |_, r| -r[:methods].size }.first(5).each do |path, r| - puts format(" %3d methods %s", r[:methods].size, path.delete_prefix("#{LIB}/")) -end diff --git a/examples/plan_diagram.rb b/examples/plan_diagram.rb deleted file mode 100644 index 828b9c1..0000000 --- a/examples/plan_diagram.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -# The Plan Diagrammer: any orchestrator's graph, emitted as Mermaid - -# paste it into a README, GitHub renders it, and the diagram can never -# drift from the plan because it is generated FROM the plan. -# -# bundle exec ruby examples/plan_diagram.rb -# -# Runs offline; prints Mermaid source. Named dependencies become -# labeled edges; plain dependencies become arrows. - -require_relative "../lib/agentic" - -# A representative plan: the editorial pipeline with a named fan-in -orchestrator = Agentic::PlanOrchestrator.new - -def step(name) - Agentic::Task.new(description: name, agent_spec: {"name" => name, "instructions" => "work"}) -end - -research = step("research topic") -outline = step("draft outline") -sources = step("verify sources") -draft = step("write draft") -publish = step("publish") - -orchestrator.add_task(research) -orchestrator.add_task(outline, [research]) -orchestrator.add_task(sources, [research]) -orchestrator.add_task(draft, needs: {skeleton: outline, citations: sources}) -orchestrator.add_task(publish, [draft]) - -# --- the diagrammer: graph in, mermaid out ----------------------------------- -# graph[:edges] arrives pre-merged with labels and graph[:order] gives -# stable, topological node numbering - both were this example's asks. -def to_mermaid(graph) - names = graph[:tasks].transform_values(&:description) - ids = graph[:order].each_with_index.to_h { |task_id, i| [task_id, "T#{i}"] } - - lines = ["graph TD"] - graph[:order].each { |task_id| lines << " #{ids[task_id]}[\"#{names[task_id]}\"]" } - graph[:edges].each do |edge| - arrow = edge[:label] ? "-- #{edge[:label]} -->" : "-->" - lines << " #{ids[edge[:from]]} #{arrow} #{ids[edge[:to]]}" - end - lines.join("\n") -end - -mermaid = to_mermaid(orchestrator.graph) - -puts "```mermaid" -puts mermaid -puts "```" -puts -puts "paste the block above into any GitHub markdown file. the arrows" -puts "labeled 'skeleton' and 'citations' are the named dependencies -" -puts "the diagram documents not just THAT draft waits, but WHY." diff --git a/examples/plan_dsl.rb b/examples/plan_dsl.rb deleted file mode 100644 index 600a128..0000000 --- a/examples/plan_dsl.rb +++ /dev/null @@ -1,92 +0,0 @@ -# frozen_string_literal: true - -# The Plan DSL: Sinatra's whole argument was that an API is a user -# interface, and a user interface should read like what it means. -# The orchestrator's API is honest but administrative - ids, task -# objects, add_task bookkeeping. Thirty lines of DSL later, a plan -# reads like a plan. No engine changes: sugar OVER the API, never -# reaching into it. -# -# bundle exec ruby examples/plan_dsl.rb -# -# Runs offline; the DSL builds a real orchestrator underneath. - -require_relative "../lib/agentic" - -Agentic.logger.level = :fatal - -# The whole DSL. Symbols in, wiring out; the block IS the agent. -module Plan - def self.define(&block) - builder = Builder.new - builder.instance_eval(&block) - builder - end - - class Builder - attr_reader :orchestrator - - def initialize - @orchestrator = Agentic::PlanOrchestrator.new - @tasks = {} - end - - def step(name, after: [], needs: nil, &work) - task = Agentic::Task.new(description: name.to_s, agent_spec: {"name" => name.to_s, "instructions" => name.to_s}) - @tasks[name] = task - deps = Array(after).map { |n| @tasks.fetch(n) } - named = needs&.transform_values { |n| @tasks.fetch(n) } - @orchestrator.add_task(task, deps, needs: named, agent: ->(t) { work&.call(t) }) - self - end - - def run - @orchestrator.execute_plan - end - - def output_of(name, result) - result.task_result(@tasks.fetch(name).id).output - end - end -end - -# --- a plan that reads like a plan ---------------------------------------------- -plan = Plan.define do - step :fetch_orders do - [{id: 1, total: 120}, {id: 2, total: 80}] - end - - step :fetch_refunds do - [{order_id: 2, amount: 80}] - end - - step :ledger, needs: {orders: :fetch_orders, refunds: :fetch_refunds} do |t| - t.needs[:orders].sum { |o| o[:total] } - t.needs[:refunds].sum { |r| r[:amount] } - end - - step :report, after: :ledger do |t| - "net revenue: $#{t.previous_output}" - end -end - -result = plan.run -puts "THE PLAN DSL (thirty lines of sugar over the real API)" -puts -puts " #{plan.output_of(:report, result)}" -puts - -graph = plan.orchestrator.graph -puts " and it's all real underneath: #{graph[:tasks].size} tasks, labeled edges" -puts " (#{graph[:edges].filter_map { |e| e[:label] }.join(", ")}), same graph every round-5-to-11 tool consumes." -puts -puts " what the sugar buys: names instead of ids (symbols resolve to" -puts " tasks at definition time, so a typo'd :fetch_order fails at" -puts " DEFINE, not at run); the block IS the agent (the work sits" -puts " inside the step that owns it); and after:/needs: read as" -puts " English. what the sugar refuses: reaching into the engine." -puts " every line delegates to public API - add_task, execute_plan," -puts " graph - so the DSL can never drift ahead of what the engine" -puts " supports, and anything the DSL can't express, you drop down" -puts " one layer without rewriting. Sinatra's rule: the frontend" -puts " should be a pleasure and the escape hatch should be a door," -puts " not a wall." diff --git a/examples/plan_flog.rb b/examples/plan_flog.rb deleted file mode 100644 index f878c52..0000000 --- a/examples/plan_flog.rb +++ /dev/null @@ -1,114 +0,0 @@ -# frozen_string_literal: true - -# Plan Flog: flog gives every Ruby method a pain score; this gives -# every plan one. Fan-in hurts (joins hide coupling), depth hurts -# (chains hide latency), unlabeled edges hurt (anonymous data flow), -# and orphans hurt (dead code that runs). One number per task, one -# number per plan, and a threshold that means "refactor me". Yes, -# it's opinionated. So is flog. That's the point. -# -# bundle exec ruby examples/plan_flog.rb -# -# Runs offline; three plans walk in, one gets told the truth. - -require_relative "../lib/agentic" - -def task_named(name) - Agentic::Task.new(description: name, agent_spec: {"name" => name, "instructions" => "w"}) -end - -def tidy_pipeline - o = Agentic::PlanOrchestrator.new - fetch, clean, render = %w[fetch clean render].map { |n| task_named(n) } - o.add_task(fetch) - o.add_task(clean, [fetch]) - o.add_task(render, [clean]) - o -end - -def labeled_diamond - o = Agentic::PlanOrchestrator.new - orders, refunds, ledger, report = %w[orders refunds ledger report].map { |n| task_named(n) } - o.add_task(orders) - o.add_task(refunds) - o.add_task(ledger, needs: {sales: orders, credits: refunds}) - o.add_task(report, [ledger]) - o -end - -def the_monster - o = Agentic::PlanOrchestrator.new - sources = 6.times.map { |i| task_named("src#{i}") } - sources.each { |s| o.add_task(s) } - god = task_named("do_everything") - o.add_task(god, sources) # six unlabeled inputs - chain = god - 4.times do |i| - step = task_named("then#{i}") - o.add_task(step, [chain]) - chain = step - end - o.add_task(task_named("orphan")) # added in a refactor, feeds nothing... but runs - o -end - -# The scoring, flog-style: pain per structural sin -def flog(graph) - stats = graph[:stats] - labeled = graph[:edges].count { |e| e[:label] } - scores = graph[:tasks].keys.to_h do |id| - fan_in = graph[:dependencies][id].size - fan_out = graph[:edges].count { |e| e[:from] == id } - unlabeled_in = (fan_in >= 2) ? graph[:edges].count { |e| e[:to] == id && !e[:label] } : 0 - orphan = (stats[:roots].include?(id) && stats[:leaves].include?(id) && graph[:tasks].size > 1) ? 5.0 : 0 - score = [fan_in - 1, 0].max * 1.5 + # a pipe is free; every EXTRA join input is coupling - [fan_out - 2, 0].max * 1.0 + # fan-out past 2 spreads blame - (stats[:depth][id] - 3).clamp(0, 99) * 0.8 + # depth past 3 hides latency - unlabeled_in * 1.2 + # anonymous inputs, where they can be confused - orphan # runs, feeds nothing: pay attention - [id, score] - end - [scores, labeled] -end - -PLANS = { - "tidy pipeline" => tidy_pipeline, - "labeled diamond" => labeled_diamond, - "the monster" => the_monster -}.freeze - -puts "PLAN FLOG (pain points per plan; > 12 total means refactor me)" -puts -PLANS.each do |name, orchestrator| - graph = orchestrator.graph - scores, = flog(graph) - total = scores.values.sum - names = graph[:tasks].transform_values(&:description) - worst = scores.max_by(2) { |_, s| s }.select { |_, s| s > 0 } - - verdict = if total > 12 - "REFACTOR ME" - else - ((total > 6) ? "watch it" : "fine") - end - puts format(" %-18s %5.1f %-12s %s", name, total, verdict, - worst.map { |id, s| "#{names[id]}=#{s.round(1)}" }.join(" ")) -end - -puts -monster = the_monster.graph -scores, = flog(monster) -names = monster[:tasks].transform_values(&:description) -top = scores.max_by { |_, s| s } -total = scores.values.sum -puts " the monster's breakdown, because a score you can't argue with" -puts " is a score you can't learn from: #{names[top[0]]} costs #{top[1].round(1)} - five" -puts " EXTRA join inputs at 1.5 coupling each, plus six anonymous ones" -puts " at 1.2. the orphan costs 5.0 flat: it runs on every execution" -puts " and feeds nothing, which is either a bug or a billing strategy." -puts " and the tidy pipeline scores 0.0, because a pipe is free and" -puts " boring plans should be." -puts -puts " numbers don't refactor code and they don't refactor plans -" -puts " but they end the meeting about whether the monster is fine." -puts " it's a #{total.round}. it's not fine." diff --git a/examples/plan_forest.rb b/examples/plan_forest.rb deleted file mode 100644 index 1d28773..0000000 --- a/examples/plan_forest.rb +++ /dev/null @@ -1,66 +0,0 @@ -# frozen_string_literal: true - -# The Plan Forest: your graph drawn as a forest - roots at the soil, -# leaves in the canopy, every task planted at its depth. stats[:roots] -# and stats[:leaves] (new this round) do the gardening. -# -# bundle exec ruby examples/plan_forest.rb -# -# Runs offline; no photosynthesis required. - -require_relative "../lib/agentic" - -def step(name) - Agentic::Task.new(description: name, agent_spec: {"name" => name, "instructions" => "grow"}) -end - -orchestrator = Agentic::PlanOrchestrator.new -seeds = step("gather seeds") -till = step("till the soil") -plant = step("plant rows") -water = step("water daily") -weed = step("pull weeds") -harvest = step("harvest") -preserve = step("preserve jars") -feast = step("feast") - -orchestrator.add_task(seeds) -orchestrator.add_task(till) -orchestrator.add_task(plant, needs: {seed_stock: seeds, bed: till}) -orchestrator.add_task(water, [plant]) -orchestrator.add_task(weed, [plant]) -orchestrator.add_task(harvest, needs: {growth: water, clear_rows: weed}) -orchestrator.add_task(preserve, [harvest]) -orchestrator.add_task(feast, [harvest]) - -graph = orchestrator.graph -stats = graph[:stats] -names = graph[:tasks].transform_values(&:description) - -# --- the forest: depth becomes altitude --------------------------------------- -canopy = stats[:depth].values.max -rows = (2..canopy).map { |level| stats[:depth].select { |_, d| d == level }.keys } - -puts "THE PLAN FOREST" -puts -rows.reverse_each.with_index do |ids, i| - level = canopy - i - ids.each do |id| - leaf = stats[:leaves].include?(id) - indent = " " * (level - 1) - puts format(" %s%s %-16s %s", indent, leaf ? "(@)" : " | ", names[id], - leaf ? "<- canopy" : "") - end -end -stats[:roots].each do |id| - puts format(" \\_/ %-16s <- root", names[id]) -end -puts " #{"~" * 40} soil" -puts -puts format(" %d trees from %d roots to %d leaves, canopy %d high", - graph[:tasks].size, stats[:roots].size, stats[:leaves].size, canopy) -puts -puts " the shape at a glance: two roots feed one trunk (plant rows)," -puts " the trunk splits to water and weeds, rejoins at harvest, and" -puts " the canopy bears two fruits. stats[:roots] and stats[:leaves]" -puts " told the gardener where the soil and sunlight are." diff --git a/examples/plan_fortune.rb b/examples/plan_fortune.rb deleted file mode 100644 index 04ca3ad..0000000 --- a/examples/plan_fortune.rb +++ /dev/null @@ -1,82 +0,0 @@ -# frozen_string_literal: true - -# The Plan Fortune Teller: reads your graph's palm - depth, fan-in, -# roots, breadth - and tells its fortune. Every fortune is a real -# structural fact wearing a mystic's robe; the entertainment is a -# delivery mechanism for the diagnosis. -# -# bundle exec ruby examples/plan_fortune.rb -# -# Runs offline. The stars are graph[:stats]. - -require_relative "../lib/agentic" - -def step(name) - Agentic::Task.new(description: name, agent_spec: {"name" => name, "instructions" => "toil"}) -end - -# A seeker arrives with their plan -orchestrator = Agentic::PlanOrchestrator.new -gather = 4.times.map { |i| step("gather-#{i}") } -sift = step("sift") -weigh = step("weigh") -judge = step("judge") -scribe = step("scribe") - -gather.each { |t| orchestrator.add_task(t) } -orchestrator.add_task(sift, gather) -orchestrator.add_task(weigh, [sift]) -orchestrator.add_task(judge, [weigh]) -orchestrator.add_task(scribe, [judge]) - -# --- the reading -------------------------------------------------------------- -graph = orchestrator.graph -stats = graph[:stats] -roots = graph[:dependencies].count { |_, deps| deps.empty? } -leaves = graph[:dependencies].keys.count { |id| graph[:edges].none? { |e| e[:from] == id } } -tasks = graph[:tasks].size - -fortunes = [] - -fortunes << if roots >= 4 - "You begin in many places at once, child of parallelism - " \ - "your mornings are wide (#{roots} roots)." -else - "You begin cautiously (#{roots} root#{(roots == 1) ? "" : "s"}) - " \ - "the fates smile on those who fan out." -end - -fortunes << if stats[:max_fan_in] >= 4 - "Beware: all rivers flow through one gate (fan-in #{stats[:max_fan_in]}). " \ - "When that gate falters, all waters still." -else - "Your joinings are modest (fan-in #{stats[:max_fan_in]}) - no single " \ - "gate holds your fate." -end - -fortunes << if stats[:max_depth] > tasks / 2 - "I see a long road, #{stats[:max_depth]} stations deep, in a caravan of " \ - "only #{tasks}. More than half your journey walks single file - " \ - "latency stalks you, and the critical path knows your name." -else - "Your road is short for your numbers (depth #{stats[:max_depth]} of " \ - "#{tasks}) - swiftness favors you." -end - -fortunes << if leaves == 1 - "All ends in a single scroll (1 leaf). Tidy. The ancestors approve of " \ - "plans that know what they produce." -else - "Your plan ends in #{leaves} places - be sure someone reads them all." -end - -puts "THE PLAN FORTUNE TELLER" -puts -puts " the seeker presents a plan of #{tasks} tasks..." -puts " the teller studies graph[:stats] (the palm never lies):" -puts -fortunes.each { |fortune| puts " * #{fortune}" } -puts -puts " cross my palm with a refactor and return: the fortune about the" -puts " long road is a real diagnosis - see refactor_receipts.rb for" -puts " the cure, and three_shapes.rb to choose your next incarnation." diff --git a/examples/plan_gantt.rb b/examples/plan_gantt.rb deleted file mode 100644 index 7bac663..0000000 --- a/examples/plan_gantt.rb +++ /dev/null @@ -1,79 +0,0 @@ -# frozen_string_literal: true - -# The Plan Gantt: lifecycle hooks timestamp every task, then the run is -# rendered as an ASCII timeline - where your wall clock actually went. -# A diamond dependency graph with a tight concurrency limit makes the -# scheduler's decisions visible to the naked eye. -# -# bundle exec ruby examples/plan_gantt.rb [concurrency] -# -# Runs offline; task durations are simulated IO. - -require_relative "../lib/agentic" - -WORK = { - "fetch:users" => {sleep: 0.12, deps: []}, - "fetch:orders" => {sleep: 0.20, deps: []}, - "fetch:events" => {sleep: 0.08, deps: []}, - "join:activity" => {sleep: 0.10, deps: ["fetch:users", "fetch:events"]}, - "join:revenue" => {sleep: 0.15, deps: ["fetch:users", "fetch:orders"]}, - "report:weekly" => {sleep: 0.06, deps: ["join:activity", "join:revenue"]} -}.freeze - -concurrency = (ARGV.first || 2).to_i -timeline = {} -plan_start = nil - -hooks = { - before_task_execution: ->(task_id:, task:) { - plan_start ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) - (timeline[task.description] ||= {})[:start] = - Process.clock_gettime(Process::CLOCK_MONOTONIC) - plan_start - }, - task_slot_acquired: ->(task_id:, task:, waited:) { - timeline[task.description][:running] = - Process.clock_gettime(Process::CLOCK_MONOTONIC) - plan_start - }, - after_task_success: ->(task_id:, task:, result:, duration:) { - timeline[task.description][:finish] = - Process.clock_gettime(Process::CLOCK_MONOTONIC) - plan_start - } -} - -orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: concurrency, lifecycle_hooks: hooks) -tasks = {} -WORK.each do |name, spec| - task = Agentic::Task.new( - description: name, - agent_spec: {"name" => name, "instructions" => "simulate"}, - payload: spec[:sleep] - ) - tasks[name] = task - orchestrator.add_task(task, spec[:deps].map { |d| tasks.fetch(d) }, agent: ->(t) { - sleep(t.payload) - :done - }) -end - -result = orchestrator.execute_plan - -# Render: 1 column = 10ms -total = timeline.values.map { |t| t[:finish] }.max -columns = (total * 100).ceil -puts "PLAN GANTT (concurrency #{concurrency}, #{(result.execution_time * 1000).round}ms wall)" -puts -timeline.each do |name, t| - from = (t[:start] * 100).round - slot = ((t[:running] || t[:start]) * 100).round - queued = [slot - from, 0].max - width = [((t[:finish] - (t[:running] || t[:start])) * 100).round, 1].max - bar = ((" " * from) + ("." * queued) + ("#" * width)).ljust(columns) - puts format(" %-16s |%s| %3d-%3dms", name, bar, t[:start] * 1000, t[:finish] * 1000) -end -puts -puts format(" %-16s |%s|", "", (0..columns).step(10).map { |c| (c / 10).to_s.ljust(10) }.join[0, columns + 1]) -puts " (one column = 10ms; '.' = queued for a slot, '#' = running)" -puts -serial_floor = WORK.values.sum { |w| w[:sleep] } -puts format(" serial floor %.0fms -> actual %.0fms (%.1fx from the scheduler)", - serial_floor * 1000, result.execution_time * 1000, serial_floor / result.execution_time) diff --git a/examples/plan_kata.rb b/examples/plan_kata.rb deleted file mode 100644 index 36ee4a7..0000000 --- a/examples/plan_kata.rb +++ /dev/null @@ -1,94 +0,0 @@ -# frozen_string_literal: true - -# The Plan Kata: red, green, refactor - for a plan. The "tests" are -# assertions about the graph (one root, one leaf, labeled joins, -# nothing too deep), written BEFORE any tasks exist. Each step adds -# the smallest thing that moves a red line green, and the refactor -# step changes structure with the assertions standing guard. You've -# TDD'd methods; plans deserve the same discipline. -# -# bundle exec ruby examples/plan_kata.rb -# -# Runs offline; exits 1 if the kata ends with a red assertion. - -require_relative "../lib/agentic" - -def task_named(name) - Agentic::Task.new(description: name, agent_spec: {"name" => name, "instructions" => "w"}) -end - -# The test list, written first - what a GOOD ingest plan looks like, -# structurally, before we know what the tasks are -ASSERTIONS = { - "has exactly one entry point" => ->(g) { g[:stats][:roots].size == 1 }, - "has exactly one deliverable" => ->(g) { g[:stats][:leaves].size == 1 }, - "every join names its inputs" => ->(g) { - g[:dependencies].select { |_, d| d.size >= 2 }.keys.all? { |id| - g[:edges].select { |e| e[:to] == id }.all? { |e| e[:label] } - } - }, - "no deeper than four stages" => ->(g) { g[:stats][:max_depth] <= 4 }, - "no orphan tasks" => ->(g) { - g[:tasks].size < 2 || (g[:stats][:roots] & g[:stats][:leaves]).empty? - } -}.freeze - -def check(orchestrator) - graph = orchestrator.graph - ASSERTIONS.transform_values { |assertion| assertion.call(graph) } -end - -def report(step, results) - reds = results.count { |_, ok| !ok } - puts " step: #{step}" - results.each { |name, ok| puts format(" %-32s %s", name, ok ? "green" : "RED") } - puts format(" -> %d red", reds) - puts -end - -puts "THE PLAN KATA (assertions first, tasks second)" -puts - -# RED: no tasks at all - most assertions can't hold on emptiness -o = Agentic::PlanOrchestrator.new -report("empty plan (the honest starting point)", check(o)) - -# GREEN, smallest step: one task satisfies one-root-one-leaf trivially -ingest = task_named("ingest") -o.add_task(ingest) -report("add the entry point", check(o)) - -# Grow: parse feeds off ingest; deliverable moves - still green -parse = task_named("parse") -o.add_task(parse, [ingest]) -report("add parse", check(o)) - -# RED on purpose: a second source creates a second root, and an -# unlabeled join - two assertions object, and they name the problem -prices = task_named("prices") -merge = task_named("merge") -o.add_task(prices) -o.add_task(merge, [parse, prices]) -report("bolt on a price feed (two sins)", check(o)) - -# GREEN again: REFACTOR IN PLACE - the round-12 release gave plans -# rewire_task, so fixing the shape no longer means demolishing it. -# Route the price feed through the one door, and give the merge its -# labels; the assertions stand guard the whole time. -o.rewire_task(prices, [ingest]) -o.rewire_task(merge, needs: {parsed: parse, prices: prices}) -report_task = task_named("report") -o.add_task(report_task, [merge]) -final = check(o) -report("refactor in place: rewire, relabel", final) - -reds = final.count { |_, ok| !ok } -puts " the kata's shape is the point: the assertions existed before" -puts " the plan did, every addition was the smallest thing that moved" -puts " a line, and the two deliberate sins were CAUGHT and NAMED by" -puts " tests written when nobody was defensive about the design yet." -puts " and the refactor was a real refactor this time: rewire_task" -puts " (round 12, this kata's own ask) changed the plan's shape without" -puts " demolishing its identity - red, green, REFACTOR, with all three" -puts " words meaning what they say. #{(reds == 0) ? "kata complete, all green." : "KATA INCOMPLETE."}" -exit((reds == 0) ? 0 : 1) diff --git a/examples/plan_merge.rb b/examples/plan_merge.rb deleted file mode 100644 index a23a5eb..0000000 --- a/examples/plan_merge.rb +++ /dev/null @@ -1,112 +0,0 @@ -# frozen_string_literal: true - -# The Plan Merge: base, ours, theirs - a three-way merge of plan wire -# formats. Independent changes combine; the same edge rewired two -# different ways is a CONFLICT, reported in topology vocabulary, not -# JSON-line vocabulary. Round 7 gave plans diff; this gives them merge. -# -# bundle exec ruby examples/plan_merge.rb -# -# Runs offline; two teammates edit the same pipeline. - -require_relative "../lib/agentic" - -# The wire format from plan_roundtrip: tasks + labeled edges -BASE = { - "tasks" => ["fetch", "parse", "rank", "publish"], - "edges" => [ - {"from" => "fetch", "to" => "parse", "label" => nil}, - {"from" => "parse", "to" => "rank", "label" => "entries"}, - {"from" => "rank", "to" => "publish", "label" => nil} - ] -}.freeze - -# Ours: adds dedupe between parse and rank -OURS = { - "tasks" => ["fetch", "parse", "dedupe", "rank", "publish"], - "edges" => [ - {"from" => "fetch", "to" => "parse", "label" => nil}, - {"from" => "parse", "to" => "dedupe", "label" => "entries"}, - {"from" => "dedupe", "to" => "rank", "label" => "candidates"}, - {"from" => "rank", "to" => "publish", "label" => nil} - ] -}.freeze - -# Theirs: adds moderation between parse and rank (same seam!) -# and independently adds an audit leaf off publish -THEIRS = { - "tasks" => ["fetch", "parse", "moderate", "rank", "publish", "audit"], - "edges" => [ - {"from" => "fetch", "to" => "parse", "label" => nil}, - {"from" => "parse", "to" => "moderate", "label" => "entries"}, - {"from" => "moderate", "to" => "rank", "label" => "safe_entries"}, - {"from" => "rank", "to" => "publish", "label" => nil}, - {"from" => "publish", "to" => "audit", "label" => nil} - ] -}.freeze - -def edge_map(wire) - wire["edges"].to_h { |e| [[e["from"], e["to"]], e["label"]] } -end - -def merge(base, ours, theirs) - base_edges = edge_map(base) - our_edges = edge_map(ours) - their_edges = edge_map(theirs) - - conflicts = [] - merged_tasks = (base["tasks"] | ours["tasks"] | theirs["tasks"]) - - # An edge's fate in each branch: kept, removed, or added - all_keys = (base_edges.keys | our_edges.keys | their_edges.keys) - merged_edges = all_keys.filter_map do |key| - in_base = base_edges.key?(key) - in_ours = our_edges.key?(key) - in_theirs = their_edges.key?(key) - - if in_base && !in_ours && !in_theirs - # Both branches removed this edge - but did they replace it the - # same way? If both rewired the same seam differently, conflict. - our_replacement = our_edges.keys.find { |k| k[0] == key[0] && !base_edges.key?(k) } - their_replacement = their_edges.keys.find { |k| k[0] == key[0] && !base_edges.key?(k) } - if our_replacement && their_replacement && our_replacement != their_replacement - conflicts << {seam: key, ours: our_replacement, theirs: their_replacement} - end - nil - elsif in_base && in_ours && in_theirs - [key, base_edges[key]] # unchanged everywhere - elsif !in_base - [key, (our_edges[key] || their_edges[key])] # added by one branch - else - [key, (in_ours ? our_edges[key] : their_edges[key])] # kept by one, removed by other -> keep? no: removed wins - end - end - - [{"tasks" => merged_tasks, "edges" => merged_edges.map { |(from, to), label| - {"from" => from, "to" => to, "label" => label} - }}, conflicts] -end - -merged, conflicts = merge(BASE, OURS, THEIRS) - -puts "PLAN MERGE (base + ours + theirs)" -puts -puts " cleanly merged:" -puts " tasks: #{merged["tasks"].join(", ")}" -(merged["edges"] - BASE["edges"]).each do |e| - puts " + #{e["from"]} -> #{e["to"]}#{e["label"] ? " (#{e["label"]})" : ""}" -end -puts -if conflicts.any? - puts " CONFLICTS (both branches rewired the same seam):" - conflicts.each do |c| - puts " seam #{c[:seam][0]} -> #{c[:seam][1]}:" - puts " ours: #{c[:seam][0]} -> #{c[:ours][1]} -> ..." - puts " theirs: #{c[:seam][0]} -> #{c[:theirs][1]} -> ..." - end - puts - puts " resolution is a DESIGN decision - should dedupe run before" - puts " moderation, after it, or fused? no textual merge can answer" - puts " that, which is why the conflict is reported in topology terms:" - puts " the humans must decide the order of the new stages." -end diff --git a/examples/plan_roundtrip.rb b/examples/plan_roundtrip.rb deleted file mode 100644 index 163e309..0000000 --- a/examples/plan_roundtrip.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true - -# The Round Trip: serialize a plan's graph to JSON, rebuild a fresh -# orchestrator from the JSON, and prove the rebuilt topology is -# isomorphic to the original - same shape, same labels, new ids. A -# projection you can't invert is a projection you can't trust with -# your plans. -# -# bundle exec ruby examples/plan_roundtrip.rb -# -# Runs offline; prints the wire format and the verdict. - -require_relative "../lib/agentic" -require "json" - -def step(name) - Agentic::Task.new(description: name, agent_spec: {"name" => name, "instructions" => "work"}) -end - -# --- an original plan with every edge flavor --------------------------------- -original = Agentic::PlanOrchestrator.new -gather = step("gather") -check = step("check") -weave = step("weave") -ship = step("ship") - -original.add_task(gather) -original.add_task(check) -original.add_task(weave, [check], needs: {threads: gather}) -original.add_task(ship, [weave]) - -# --- serialize: graph -> wire format (ids replaced by descriptions) --------- -def serialize(graph) - names = graph[:tasks].transform_values(&:description) - { - "tasks" => graph[:order].map { |id| names[id] }, - "edges" => graph[:edges].map { |e| - {"from" => names[e[:from]], "to" => names[e[:to]], "label" => e[:label]&.to_s} - } - } -end - -# --- deserialize: wire format -> a fresh orchestrator ------------------------ -def deserialize(data) - orchestrator = Agentic::PlanOrchestrator.new - tasks = data["tasks"].to_h { |name| [name, step(name)] } - - data["tasks"].each do |name| - edges_in = data["edges"].select { |e| e["to"] == name } - plain = edges_in.reject { |e| e["label"] }.map { |e| tasks.fetch(e["from"]) } - named = edges_in.select { |e| e["label"] } - .to_h { |e| [e["label"].to_sym, tasks.fetch(e["from"])] } - - orchestrator.add_task(tasks[name], plain, needs: named.empty? ? nil : named) - end - orchestrator -end - -wire = JSON.pretty_generate(serialize(original.graph)) -rebuilt = deserialize(JSON.parse(wire)) - -puts "THE WIRE FORMAT" -puts wire.gsub(/^/, " ") -puts - -# --- the isomorphism check: compare shapes, not ids -------------------------- -def shape(graph) - names = graph[:tasks].transform_values(&:description) - { - order: graph[:order].map { |id| names[id] }, - edges: graph[:edges].map { |e| [names[e[:from]], names[e[:to]], e[:label]] }.sort_by(&:to_s) - } -end - -before = shape(original.graph) -after = shape(rebuilt.graph) - -puts "THE VERDICT" -if before == after - puts " round trip is faithful: #{before[:edges].size} edges, labels intact," - puts " topological order preserved (#{after[:order].join(" -> ")})" -else - puts " DRIFT DETECTED:" - puts " before: #{before.inspect}" - puts " after: #{after.inspect}" - exit 1 -end -puts -puts "task ids are per-process and correctly absent from the wire format -" -puts "identity travels as description, structure travels as edges, and" -puts "needs: labels survive because graph[:edges] carries them." diff --git a/examples/plan_server.rb b/examples/plan_server.rb deleted file mode 100644 index a44b5d7..0000000 --- a/examples/plan_server.rb +++ /dev/null @@ -1,133 +0,0 @@ -# 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/plan_structural_diff.rb b/examples/plan_structural_diff.rb deleted file mode 100644 index 06aac80..0000000 --- a/examples/plan_structural_diff.rb +++ /dev/null @@ -1,87 +0,0 @@ -# frozen_string_literal: true - -# The Structural Diff: two versions of a plan's wire format, diffed as -# TOPOLOGY - tasks added and removed, edges rewired, labels renamed. -# A line diff of plan JSON tells you bytes changed; this tells you what -# changed about the plan. -# -# bundle exec ruby examples/plan_structural_diff.rb -# -# Runs offline; v1 and v2 are built in-process via the round-trip wire -# format, as they would be loaded from two commits of plan.json. - -require_relative "../lib/agentic" - -def step(name) - Agentic::Task.new(description: name, agent_spec: {"name" => name, "instructions" => "work"}) -end - -def wire(orchestrator) - graph = orchestrator.graph - names = graph[:tasks].transform_values(&:description) - { - "tasks" => graph[:order].map { |id| names[id] }, - "edges" => graph[:edges].map { |e| - {"from" => names[e[:from]], "to" => names[e[:to]], "label" => e[:label]&.to_s} - } - } -end - -# --- version 1: last sprint's pipeline --------------------------------------- -v1 = Agentic::PlanOrchestrator.new -fetch = step("fetch feed") -parse = step("parse entries") -rank = step("rank entries") -publish = step("publish digest") -v1.add_task(fetch) -v1.add_task(parse, [fetch]) -v1.add_task(rank, needs: {entries: parse}) -v1.add_task(publish, [rank]) - -# --- version 2: this sprint's - dedupe added, ranking rewired ---------------- -v2 = Agentic::PlanOrchestrator.new -fetch2 = step("fetch feed") -parse2 = step("parse entries") -dedupe2 = step("dedupe entries") -rank2 = step("rank entries") -publish2 = step("publish digest") -v2.add_task(fetch2) -v2.add_task(parse2, [fetch2]) -v2.add_task(dedupe2, needs: {entries: parse2}) -v2.add_task(rank2, needs: {candidates: dedupe2}) -v2.add_task(publish2, [rank2]) - -# --- the diff: sets of names and labeled edges -------------------------------- -def structural_diff(before, after) - edge_key = ->(e) { [e["from"], e["to"]] } - - before_edges = before["edges"].to_h { |e| [edge_key.call(e), e["label"]] } - after_edges = after["edges"].to_h { |e| [edge_key.call(e), e["label"]] } - - { - tasks_added: after["tasks"] - before["tasks"], - tasks_removed: before["tasks"] - after["tasks"], - edges_added: (after_edges.keys - before_edges.keys), - edges_removed: (before_edges.keys - after_edges.keys), - labels_changed: before_edges.keys.intersection(after_edges.keys) - .reject { |k| before_edges[k] == after_edges[k] } - .map { |k| [k, before_edges[k], after_edges[k]] } - } -end - -diff = structural_diff(wire(v1), wire(v2)) - -puts "PLAN STRUCTURAL DIFF (v1 -> v2)" -puts -diff[:tasks_added].each { |t| puts " + task #{t}" } -diff[:tasks_removed].each { |t| puts " - task #{t}" } -diff[:edges_added].each { |(from, to)| puts " + edge #{from} -> #{to}" } -diff[:edges_removed].each { |(from, to)| puts " - edge #{from} -> #{to}" } -diff[:labels_changed].each { |(from, to), old, new| puts " ~ label #{from} -> #{to}: #{old.inspect} => #{new.inspect}" } - -puts -total = diff.values.sum(&:size) -puts " #{total} structural changes. the review question is no longer" -puts " 'what do these 40 changed JSON lines mean' but 'should ranking" -puts " consume deduped candidates instead of raw entries' - which is" -puts " a question a human can actually answer." diff --git a/examples/plan_tour.rb b/examples/plan_tour.rb deleted file mode 100644 index aa2f022..0000000 --- a/examples/plan_tour.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -# The Plan Tour: hand any orchestrator to the guide and it narrates the -# plan as prose - first this, then that, meanwhile the other - read -# straight from graph[:order] and graph[:edges]. If the prose sounds -# wrong, your plan IS wrong, and you found out before running it. -# -# bundle exec ruby examples/plan_tour.rb -# -# Runs offline; narration only, no execution. - -require_relative "../lib/agentic" - -# A breakfast, planned properly -orchestrator = Agentic::PlanOrchestrator.new - -def step(name) - Agentic::Task.new(description: name, agent_spec: {"name" => name, "instructions" => "cook"}) -end - -kettle = step("boil the kettle") -eggs = step("soft-boil the eggs") -bread = step("slice the bread") -toast = step("toast the bread") -tea = step("steep the tea") -plate = step("plate everything") - -orchestrator.add_task(kettle) -orchestrator.add_task(bread) -orchestrator.add_task(eggs, [kettle]) -orchestrator.add_task(tea, [kettle]) -orchestrator.add_task(toast, [bread]) -orchestrator.add_task(plate, needs: {protein: eggs, crunch: toast, comfort: tea}) - -# --- the guide: graph in, prose out ------------------------------------------ -def narrate(graph) - names = graph[:tasks].transform_values(&:description) - incoming = graph[:edges].group_by { |e| e[:to] } - narrated = [] - sentences = [] - - graph[:order].each do |task_id| - edges_in = incoming[task_id] || [] - sentence = - if edges_in.empty? - opener = narrated.empty? ? "First" : "Meanwhile" - "#{opener}, #{names[task_id]}." - else - reasons = edges_in.map { |e| - e[:label] ? "\"#{names[e[:from]]}\" (your #{e[:label]})" : "\"#{names[e[:from]]}\"" - } - joined = (reasons.size > 1) ? reasons[0..-2].join(", ") + " and #{reasons.last}" : reasons.first - "After #{joined}: #{names[task_id]}." - end - sentences << sentence - narrated << task_id - end - - sentences -end - -puts "THE PLAN, AS PROSE (nobody has cooked anything yet)" -puts -narrate(orchestrator.graph).each { |sentence| puts " #{sentence}" } -puts -puts "the narration is generated from graph[:order] and graph[:edges] -" -puts "the same topology the scheduler will execute. read your plan aloud" -puts "before you run it; ears catch what eyes skim." diff --git a/examples/plans_as_automata.rb b/examples/plans_as_automata.rb deleted file mode 100644 index 04ac373..0000000 --- a/examples/plans_as_automata.rb +++ /dev/null @@ -1,108 +0,0 @@ -# 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/polite_form.rb b/examples/polite_form.rb deleted file mode 100644 index 27eb41a..0000000 --- a/examples/polite_form.rb +++ /dev/null @@ -1,104 +0,0 @@ -# frozen_string_literal: true - -# The Polite Form: a contract usually speaks AFTER you fail - a 422, -# a stack of violations. This assistant makes it speak FIRST, turning -# every declaration into a question: required keys become requests, -# bounds become gentle corrections, and relation rules become the -# follow-ups a good clerk asks ("express? then I'll need a customs -# code"). Zero errors are ever shown; the contract is the script. -# -# bundle exec ruby examples/polite_form.rb -# -# Runs offline; the "user" answers from a queue. - -require_relative "../lib/agentic" -require "json" - -SPEC = 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: {type: "number", required: true, min: 1, max: 5_000}, - volume: {type: "number", min: 0}, - express: {type: "boolean"}, - customs_code: {type: "string"}, - api_key: {type: "string"}, - oauth_token: {type: "string"} - }, - rules: { - fits: {relation: :sum_lte, fields: [:weight, :volume], limit: 6_000}, - customs: {relation: :requires, fields: [:express, :customs_code]}, - one_auth: {relation: :mutually_exclusive, fields: [:api_key, :oauth_token]} - } -) - -# The half-filled form the user pasted in -answers = {express: true, api_key: "k-123", oauth_token: "t-456", volume: 2_500} - -# What the user will say when asked (a queue per field) -REPLIES = { - mode: ["air"], - weight: [6_000, 4_500], # first too heavy, then adjusted - volume: [1_500], # reduced when the total is too much - customs_code: ["HS-42"], - keep: [:api_key] -}.transform_values(&:dup) - -def say(role, line) - puts format(" %-10s %s", "#{role}:", line) -end - -def ask(field, question, answers) - say("assistant", question) - reply = REPLIES.fetch(field).shift - say("user", reply.inspect) - answers[field] = reply -end - -validator = Agentic::CapabilityValidator.new(SPEC) -puts "THE POLITE FORM (#{SPEC.name} v#{SPEC.version})" -puts -say("user", "here's what I have: #{JSON.generate(answers)}") - -10.times do - validator.validate_inputs!(answers) - break -rescue Agentic::Errors::ValidationError => e - if e.rule_violations.any? - violation = e.rule_violations.first - rule = SPEC.rules[violation[:rule]] - case rule[:relation] - when :requires - needed = rule[:fields].drop(1).find { |f| answers[f].nil? } - ask(needed, "since you chose #{rule[:fields].first}, I'll also need your #{needed} - what is it?", answers) - when :sum_lte - total = rule[:fields].sum { |f| answers[f] || 0 } - target = rule[:fields].last - ask(target, "together #{rule[:fields].join(" and ")} come to #{total}, and #{rule[:limit]} is our limit - could we lower the #{target}?", answers) - when :mutually_exclusive - say("assistant", "you've given me #{violation[:fields].join(" and ")} - I only need one; which shall we keep?") - keep = REPLIES.fetch(:keep).shift - say("user", keep.inspect) - (violation[:fields] - [keep]).each { |f| answers.delete(f) } - end - else - field, messages = e.violations.first - if messages.first.include?("missing") - ask(field, "may I have your #{field}? (#{SPEC.inputs[field][:enum]&.join(", ") || SPEC.inputs[field][:type]})", answers) - else - ask(field, "ah - #{field} #{messages.first}. shall we adjust it?", answers) - end - end -end - -puts -say("assistant", "all set. here's your form: #{JSON.generate(answers)}") -validator.validate_inputs!(answers) # the countersignature -puts -puts " the same contract that would have stacked up 422s asked six" -puts " questions instead. nothing here was written twice: the" -puts " requests came from required:, the correction from max:, and" -puts " the follow-ups from the relations - requires became \"then I'll" -puts " also need\", sum_lte became \"could we lower it\", and" -puts " mutually_exclusive became \"which shall we keep?\". an error" -puts " message is just a question you asked too late." diff --git a/examples/ports_and_adapters.rb b/examples/ports_and_adapters.rb deleted file mode 100644 index 2ee1b9a..0000000 --- a/examples/ports_and_adapters.rb +++ /dev/null @@ -1,96 +0,0 @@ -# frozen_string_literal: true - -# Ports and Adapters: the domain is the part of your app that would -# survive a framework migration - IF you kept it clean. Here the -# use-case (quote a shipment) is pure Ruby speaking only to PORTS; -# the adapters live at the edge; and Agentic is the delivery -# mechanism, replaced in the second act by a bare call to prove the -# domain never knew it was there. The proof is mechanical: the -# domain's source is scanned for framework constants. -# -# bundle exec ruby examples/ports_and_adapters.rb -# -# Runs offline; exits 1 if the domain mentions the framework. - -require_relative "../lib/agentic" - -Agentic.logger.level = :fatal - -# --- the domain (would survive the migration) ----------------------------------- -DOMAIN_SOURCE = <<~RUBY - class QuoteShipment - Result = Struct.new(:price_cents, :carrier, keyword_init: true) - - def initialize(rate_source:, quote_repository:) - @rate_source = rate_source # port: #rate_for(mode) - @quote_repository = quote_repository # port: #save(result) - end - - def call(mode:, weight:) - rate = @rate_source.rate_for(mode) - result = Result.new(price_cents: (weight * rate).round, carrier: rate > 5 ? "premium" : "standard") - @quote_repository.save(result) - result - end - end -RUBY -eval(DOMAIN_SOURCE) # standard:disable Security/Eval -- the string exists so the purity scan below is honest - -# --- the adapters (edge; disposable) -------------------------------------------- -class StaticRates - def rate_for(mode) = {"air" => 9, "sea" => 2}.fetch(mode) -end - -class MemoryQuotes - def all = @all ||= [] - - def save(result) = all << result -end - -# --- act one: Agentic as the delivery mechanism --------------------------------- -repo = MemoryQuotes.new -use_case = QuoteShipment.new(rate_source: StaticRates.new, quote_repository: repo) - -orchestrator = Agentic::PlanOrchestrator.new -quote_task = Agentic::Task.new( - description: "quote", agent_spec: {"name" => "quote", "instructions" => "quote"}, - payload: {mode: "air", weight: 120} -) -orchestrator.add_task(quote_task, agent: ->(t) { use_case.call(**t.payload) }) -orchestrator.execute_plan - -puts "PORTS AND ADAPTERS (the domain would survive the migration)" -puts -puts " act one - delivered by Agentic:" -puts " plan ran the use-case: #{repo.all.last.to_h}" -puts - -# --- act two: the framework leaves; the domain doesn't notice ------------------- -bare = use_case.call(mode: "sea", weight: 300) -puts " act two - delivered by a bare method call:" -puts " same use-case, no orchestrator: #{bare.to_h}" -puts " repository holds #{repo.all.size} quotes; the domain never knew who called." -puts - -# --- the proof: scan the domain for framework constants ------------------------- -leaks = DOMAIN_SOURCE.scan(/\b(?:Agentic|PlanOrchestrator|Task|CapabilityS\w+)\b/).uniq - ["Task"] -leaks += DOMAIN_SOURCE.scan(/\bAgentic::\w+/) -puts " the purity scan: grep the domain's source for framework constants" -if leaks.empty? - puts " 0 mentions of the framework in the domain. the dependency" - puts " arrow points ONE way: the edge knows the center; the center" - puts " has never heard of the edge." -else - puts " LEAKED: #{leaks.join(", ")} - the domain is coupled to its delivery." -end -puts -puts " what Agentic added in act one wasn't the business logic - it was" -puts " everything AROUND it: retry policy, lifecycle hooks, journaling," -puts " concurrency, the graph. that's the correct division of labor:" -puts " frameworks orchestrate; domains decide. the ports (#rate_for," -puts " #save) are the entire vocabulary the domain needs from the" -puts " world, and both adapters fit in six lines because the ports" -puts " asked for so little. clean architecture isn't ceremony - it's" -puts " the freedom to change your mind about everything but the truth." - -exit(leaks.empty? ? 0 : 1) diff --git a/examples/process_drill.rb b/examples/process_drill.rb deleted file mode 100644 index d45a279..0000000 --- a/examples/process_drill.rb +++ /dev/null @@ -1,71 +0,0 @@ -# frozen_string_literal: true - -# The Process Drill: threads share a Mutex; PROCESSES share nothing -# but the file. The journal claims flock+fsync, which is a promise -# about processes - so this drill forks real ones, points them all at -# one journal, and lets the kernel referee. Then replay must find -# every event whole: no torn lines, no interleaved halves, no losses. -# -# bundle exec ruby examples/process_drill.rb -# -# Runs offline; exits 1 if any process's write was torn or lost. - -require_relative "../lib/agentic" -require "tmpdir" -require "json" - -Agentic.logger.level = :fatal - -PROCESSES = 4 -EVENTS = 250 -PATH = File.join(Dir.tmpdir, "agentic_process_drill.journal.jsonl") -File.delete(PATH) if File.exist?(PATH) - -pids = PROCESSES.times.map do |p| - fork do - journal = Agentic::ExecutionJournal.new(path: PATH) - EVENTS.times do |i| - journal.record(:task_succeeded, - task_id: "p#{p}-#{i}", description: "p#{p}-#{i}", - duration: 0.001, output: "payload-#{p}-" + ("x" * (50 + (i % 100)))) - end - exit!(0) - end -end -statuses = pids.map { |pid| Process.wait2(pid).last.exitstatus } - -# --- the referee ---------------------------------------------------------------- -lines = File.readlines(PATH) -torn = lines.reject do |line| - JSON.parse(line) - true -rescue JSON::ParserError - false -end -state = Agentic::ExecutionJournal.replay(path: PATH) -expected = PROCESSES * EVENTS -per_process = PROCESSES.times.map { |p| - state.completed_task_ids.count { |id| id.start_with?("p#{p}-") } -} - -puts "PROCESS DRILL (#{PROCESSES} forked writers x #{EVENTS} events, one journal)" -puts -puts format(" processes exited cleanly: %s", statuses.all?(&:zero?) ? "yes" : "NO") -puts format(" lines on disk: %d/%d", lines.size, expected) -puts format(" torn lines: %d", torn.size) -puts format(" replay recovered per proc: %s", per_process.join(", ")) -puts - -ok = statuses.all?(&:zero?) && lines.size == expected && torn.empty? && per_process.all?(EVENTS) -if ok - puts " the flock claim is now a certificate, not a comment: four" - puts " processes - separate GVLs, separate heaps, separate everything -" - puts " interleaved a thousand writes into one file and the kernel's" - puts " advisory lock kept every line whole. this is the half of the" - puts " journal's promise the threads drill couldn't reach: a Mutex" - puts " means nothing across fork(2); only the fd-level lock does." - puts " crash-recovery tooling stands on exactly this property." -else - puts " DRILL FAILED - the promise about processes is broken." -end -exit(ok ? 0 : 1) diff --git a/examples/projection_agreement.rb b/examples/projection_agreement.rb deleted file mode 100644 index 85d61a4..0000000 --- a/examples/projection_agreement.rb +++ /dev/null @@ -1,119 +0,0 @@ -# frozen_string_literal: true - -# The Projection Agreement Prover: relation rules now render twice - -# the validator enforces them in Ruby, and to_json_schema projects -# them into draft-07 keywords (dependencies, not-required). Two -# renderings of one law can drift, so this prover evaluates BOTH -# against every presence combination and demands they agree. It also -# walks to the exact frontier where they don't: JSON's null. -# -# bundle exec ruby examples/projection_agreement.rb -# -# Runs offline; exits 1 if the projections disagree on the nil-free plane. - -require_relative "../lib/agentic" - -SPEC = Agentic::CapabilitySpecification.new( - name: "connect", description: "Connect an integration", version: "1.0.0", - inputs: { - express: {type: "boolean"}, - customs_code: {type: "string"}, - api_key: {type: "string"}, - oauth_token: {type: "string"} - }, - rules: { - customs: {relation: :requires, fields: [:express, :customs_code]}, - one_auth: {relation: :mutually_exclusive, fields: [:api_key, :oauth_token]} - } -) - -VALUES = {express: true, customs_code: "HS-1", api_key: "k", oauth_token: "t"}.freeze - -# A four-line draft-07 evaluator for exactly the projected keywords -def schema_allows?(schema, payload) - keys = payload.keys.map(&:to_s) - (schema["dependencies"] || {}).each do |trigger, needed| - return false if keys.include?(trigger) && !(needed - keys).empty? - end - (schema["allOf"] || []).each do |clause| - required = clause.dig("not", "required") - return false if required && (required - keys).empty? - end - true -end - -def validator_allows?(validator, payload) - validator.validate_inputs!(payload) - true -rescue Agentic::Errors::ValidationError - false -end - -schema = SPEC.to_json_schema -validator = Agentic::CapabilityValidator.new(SPEC) -fields = VALUES.keys - -puts "PROJECTION AGREEMENT PROVER (#{fields.size} fields -> #{2**fields.size} presence combinations)" -puts - -disagreements = 0 -(0...2**fields.size).each do |mask| - payload = fields.each_with_index.select { |_, i| mask[i] == 1 }.to_h { |f, _| [f, VALUES[f]] } - ruby = validator_allows?(validator, payload) - json = schema_allows?(schema, payload) - disagreements += 1 if ruby != json - - next unless ruby != json || !ruby # print the interesting rows only - - puts format(" {%-40s} validator: %-6s schema: %-6s %s", - payload.keys.join(", "), ruby ? "allow" : "reject", json ? "allow" : "reject", - (ruby == json) ? "agree" : "DISAGREE") -end - -puts -puts " #{2**fields.size} combinations, #{disagreements} disagreement(s) - the dependencies and" -puts " not-required clauses say exactly what the validator enforces," -puts " proven point by point rather than asserted." -puts - -# --- the frontier: explicit nulls --------------------------------------------- -# Ruby's relation presence is "given and non-nil"; JSON Schema's -# dependencies trigger on the PROPERTY existing, null or not. Two -# metaphysics of absence - walk to the exact spot where they part. -# -# First discovery: for TYPED fields the frontier is guarded. An -# explicit nil never reaches the relation check, because per-key -# typing rejects it first ("must be boolean"), and the schema rejects -# it too (dependencies fire) - agreement, but for different reasons. -frontier = {express: nil} -puts " the frontier: {express: nil}" -puts format(" typed field: validator %-7s (per-key: nil isn't a boolean)", validator_allows?(validator, frontier) ? "allows" : "rejects") -puts format(" schema %-7s (the property EXISTS - dependencies fire)", schema_allows?(schema, frontier) ? "allows" : "rejects") - -# In round 10 an untyped field exposed the true divergence: nil -# sailed past per-key checks, the validator's relation read it as -# absent, and the schema's dependencies read null as present. The -# round-11 release closes it from the projection side: a relation -# over any UNTYPED field stays out of the draft-07 keywords entirely -# (it still travels in x-agentic-rules), so the schema never claims -# a law it can't render faithfully. -untyped = Agentic::CapabilitySpecification.new( - name: "connect", description: "x", version: "1.0.0", - inputs: {express: {}, customs_code: {type: "string"}}, - rules: {customs: {relation: :requires, fields: [:express, :customs_code]}} -) -ruby = validator_allows?(Agentic::CapabilityValidator.new(untyped), frontier) -json = schema_allows?(untyped.to_json_schema, frontier) -projected = untyped.to_json_schema.key?("dependencies") -puts format(" untyped field: validator %-7s (nil is ABSENT - rule not triggered)", ruby ? "allows" : "rejects") -puts format(" schema %-7s (projection %s)", json ? "allows" : "rejects", - projected ? "STILL EMITTED - divergence is back!" : "declined - the keyword was never emitted") -puts -puts " on the nil-free plane the projection is faithful, point by point." -puts " and at the frontier the projection now knows its own limits: a" -puts " relation over untyped fields is not rendered into keywords it" -puts " cannot render truthfully - it rides x-agentic-rules instead." -puts " a projection that declines is honest; one that guesses is a trap." -exit(1) if projected - -exit(disagreements.zero? ? 0 : 1) diff --git a/examples/quota_keeper.rb b/examples/quota_keeper.rb deleted file mode 100644 index 16d9deb..0000000 --- a/examples/quota_keeper.rb +++ /dev/null @@ -1,64 +0,0 @@ -# frozen_string_literal: true - -# The Quota Keeper: the same 20 requests through two different laws. -# A concurrency ceiling ("3 in flight") models connection limits; a -# windowed quota ("5 per 200ms") models what providers actually bill. -# They are different physics, and the admission timeline proves it. -# -# bundle exec ruby examples/quota_keeper.rb -# -# Runs offline; calls are 10ms of simulated IO. - -require_relative "../lib/agentic" -require "async" - -REQUESTS = 20 -CALL_TIME = 0.01 - -def fire_through(limit) - admissions = [] - started = Process.clock_gettime(Process::CLOCK_MONOTONIC) - - Sync do - REQUESTS.times.map { - Async do - limit.acquire do - admissions << Process.clock_gettime(Process::CLOCK_MONOTONIC) - started - sleep(CALL_TIME) - end - end - }.each(&:wait) - end - - admissions.sort -end - -def admission_chart(admissions, bucket = 0.2) - buckets = admissions.group_by { |t| (t / bucket).floor } - (0..admissions.max / bucket).map { |i| - format(" %3d-%3dms %-22s %d", - i * bucket * 1000, (i + 1) * bucket * 1000, "#" * (buckets[i]&.size || 0), buckets[i]&.size || 0) - }.join("\n") -end - -puts "QUOTA KEEPER: #{REQUESTS} requests, #{(CALL_TIME * 1000).round}ms each, fired simultaneously" -puts - -concurrent = fire_through(Agentic::RateLimit.new(3)) -puts " law 1 - concurrency ceiling (3 in flight):" -puts admission_chart(concurrent) -puts format(" all admitted by %dms - completion frees a slot, so short", concurrent.last * 1000) -puts " calls drain the queue as fast as they finish" -puts - -windowed = fire_through(Agentic::RateLimit.new(5, per: 0.2)) -puts " law 2 - windowed quota (5 per 200ms):" -puts admission_chart(windowed) -puts format(" last admitted at %dms - finishing early buys NOTHING;", windowed.last * 1000) -puts " the window admits five per period no matter how quick the calls" -puts - -puts "same requests, ~#{(concurrent.last * 1000).round}ms versus ~#{(windowed.last * 1000).round}ms: pick the law your" -puts "provider actually enforces. connection pools are ceilings; billed" -puts "quotas are windows; production APIs are usually both at once -" -puts "which is why RateLimit lets you hold one of each." diff --git a/examples/ractor_shareability.rb b/examples/ractor_shareability.rb deleted file mode 100644 index 17c26e1..0000000 --- a/examples/ractor_shareability.rb +++ /dev/null @@ -1,103 +0,0 @@ -# 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 deleted file mode 100644 index 40d0584..0000000 --- a/examples/rbs_export.rb +++ /dev/null @@ -1,99 +0,0 @@ -# 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/readme_verifier.rb b/examples/readme_verifier.rb deleted file mode 100644 index 5bdae1e..0000000 --- a/examples/readme_verifier.rb +++ /dev/null @@ -1,94 +0,0 @@ -# frozen_string_literal: true - -# The README Verifier: every ruby code fence in the README is a promise. -# This extracts them all, syntax-checks each with Prism, and verifies -# that every Agentic constant a snippet mentions actually exists in the -# loaded gem. Docs rot silently; this makes the rot loud. -# -# bundle exec ruby examples/readme_verifier.rb [markdown_file] -# -# Runs offline. Exit 1 if the README promises anything the gem can't keep. - -require_relative "../lib/agentic" -require "prism" - -README = File.expand_path(ARGV.first || "#{__dir__}/../README.md") - -# Pull ruby code fences with their line numbers -snippets = [] -current = nil -File.readlines(README, encoding: "UTF-8").each_with_index do |line, index| - if current - if line.start_with?("```") - snippets << current - current = nil - else - current[:code] << line - end - elsif line.start_with?("```ruby") - current = {line: index + 2, code: +""} - end -end - -orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 8) - -checks = snippets.map do |snippet| - task = Agentic::Task.new( - description: "snippet at line #{snippet[:line]}", - agent_spec: {"name" => "Verifier", "instructions" => "verify the snippet"}, - payload: snippet - ) - orchestrator.add_task(task, agent: ->(t) { - code = t.payload[:code] - parsed = Prism.parse(code) - - missing = code.scan(/Agentic(?:::[A-Z]\w*)+/).uniq.reject { |const| - begin - Object.const_get(const) - true - rescue NameError - false - end - } - - { - line: t.payload[:line], - lines: code.lines.size, - syntax_errors: parsed.errors.map { |e| "#{e.message} (snippet line #{e.location.start_line})" }, - missing_constants: missing - } - }) - task -end - -verdict = Agentic::Task.new( - description: "the verdict", - agent_spec: {"name" => "Editor", "instructions" => "sum it up"} -) -orchestrator.add_task(verdict, checks, agent: ->(t) { - reports = checks.map { |c| t.output_of(c) } - { - total: reports.size, - total_lines: reports.sum { |r| r[:lines] }, - broken: reports.select { |r| r[:syntax_errors].any? || r[:missing_constants].any? } - } -}) - -result = orchestrator.execute_plan -report = result.results[verdict.id].output - -puts "README VERIFIER: #{File.basename(README)}" -puts " #{report[:total]} ruby snippets, #{report[:total_lines]} lines of promised code" -puts - -if report[:broken].empty? - puts " every snippet parses and every Agentic constant it names exists." - puts " the README keeps its promises." -else - report[:broken].each do |broken| - puts " BROKEN: snippet at README line #{broken[:line]}" - broken[:syntax_errors].each { |e| puts " syntax: #{e}" } - broken[:missing_constants].each { |c| puts " missing constant: #{c}" } - end - exit 1 -end diff --git a/examples/refactor_receipts.rb b/examples/refactor_receipts.rb deleted file mode 100644 index 4ccb22e..0000000 --- a/examples/refactor_receipts.rb +++ /dev/null @@ -1,81 +0,0 @@ -# frozen_string_literal: true - -# Refactor Receipts: the god-join plan from the graph critic, improved -# in two small steps - with a receipt after each one. Every step shows -# the smells found, the structure numbers, and the measured wall time, -# because "I made it better" is a claim and receipts are evidence. -# -# bundle exec ruby examples/refactor_receipts.rb -# -# Runs offline; each task is 30ms of simulated IO. - -require_relative "../lib/agentic" - -UNIT = 0.03 - -# Three versions of the same pipeline: five ingests feeding a report -SHAPES = { - "before: the god join" => { - "join" => %w[ingest_a ingest_b ingest_c ingest_d ingest_e], - "report" => %w[join] - }, - "step 1: stage the pairs" => { - "join_ab" => %w[ingest_a ingest_b], - "join_cde" => %w[ingest_c ingest_d ingest_e], - "join" => %w[join_ab join_cde], - "report" => %w[join] - }, - "step 2: report reads the stages" => { - "join_ab" => %w[ingest_a ingest_b], - "join_cde" => %w[ingest_c ingest_d ingest_e], - "report" => %w[join_ab join_cde] - } -}.freeze - -def build(deps) - orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 4) - names = (%w[ingest_a ingest_b ingest_c ingest_d ingest_e] + deps.keys).uniq - tasks = names.to_h { |n| [n, Agentic::Task.new(description: n, agent_spec: {"name" => n, "instructions" => "work"})] } - names.each do |name| - orchestrator.add_task(tasks[name], (deps[name] || []).map { |d| tasks.fetch(d) }, - agent: ->(_t) { sleep(UNIT) || :ok }) - end - orchestrator -end - -# The depth/fan-in walk this example used to hand-roll now ships as -# graph[:stats] - the critique is just thresholds over facts -def critique(graph) - stats = graph[:stats] - smells = [] - graph[:dependencies].each do |_id, deps| - smells << "god task (#{deps.size} deps)" if deps.size >= 4 - end - smells << "deep chain (#{stats[:max_depth]} levels)" if stats[:max_depth] >= 5 - [smells, stats[:max_depth], stats[:max_fan_in]] -end - -puts "REFACTOR RECEIPTS (five ingests -> report, 30ms per task)" -puts - -SHAPES.each do |label, deps| - orchestrator = build(deps) - smells, depth, fan_in = critique(orchestrator.graph) - result = orchestrator.execute_plan - - puts " #{label}" - puts format(" wall %3dms | depth %d | max fan-in %d | tasks %d", - result.execution_time * 1000, depth, fan_in, orchestrator.graph[:tasks].size) - if smells.empty? - puts " critic: no complaints" - else - smells.each { |smell| puts " critic: #{smell}" } - end - puts -end - -puts "read the receipts honestly: step 1 removed the smell but COST 30ms" -puts "(the extra join level) - a receipt you'd never notice without the" -puts "measurement. step 2 pays it back by letting the report read the" -puts "stages directly. intermediate steps may cost; receipts price them," -puts "and every step was still a shippable state." diff --git a/examples/refactoring_dojo.rb b/examples/refactoring_dojo.rb deleted file mode 100644 index 11231f3..0000000 --- a/examples/refactoring_dojo.rb +++ /dev/null @@ -1,126 +0,0 @@ -# frozen_string_literal: true - -# The Refactoring Dojo: a student submits a method, three critic agents -# review it from three distinct perspectives, and the sensei prescribes -# the ONE smallest next step. Today's student: this gem itself, -# submitting its second-longest method (schedule_task, 90 lines). -# -# bundle exec ruby examples/refactoring_dojo.rb [file] [method] -# -# Runs offline: the critics measure; they don't guess. - -require_relative "../lib/agentic" -require "prism" - -file = ARGV[0] || File.expand_path("../lib/agentic/plan_orchestrator.rb", __dir__) -method_name = (ARGV[1] || "schedule_task").to_sym - -# Find the student's submission -def find_def(node, name) - return node if node.is_a?(Prism::DefNode) && node.name == name - - node&.child_nodes&.each do |child| - found = find_def(child, name) - return found if found - end - nil -end - -submission = find_def(Prism.parse_file(file).value, method_name) || - abort("no method #{method_name} in #{file}") -source = submission.slice -lines = source.lines - -def critic(name, &impl) - spec = Agentic::CapabilitySpecification.new( - name: name, description: "The #{name} critic", version: "1.0.0", - inputs: {source: {type: "string", required: true}}, - outputs: {findings: {type: "array", required: true}} - ) - Agentic.register_capability( - spec, Agentic::CapabilityProvider.new(capability: spec, implementation: impl) - ) - - Agentic::Agent.build { |a| a.name = name }.tap { |a| a.add_capability(name) } -end - -critics = [] - -critics << critic("rule_keeper") do |input| - body = input[:source].lines - findings = [] - if body.size > 5 - findings << "#{body.size} lines; the rule is five. Every extra line is a place for a bug to live." - end - params = body.first[/\((.*)\)/, 1].to_s.split(",").size - if params > 4 - findings << "#{params} parameters; four is the ceiling before a parameter object is cheaper." - end - {findings: findings} -end - -critics << critic("squint_tester") do |input| - body = input[:source].lines - indents = body.map { |l| l[/\A */].size }.reject(&:zero?) - depth = (indents.max - indents.min) / 2 - findings = [] - if depth >= 3 - findings << "squinting shows #{depth} levels of shape change - each ridge is a concept asking for its own method." - end - branches = body.count { |l| l.strip.start_with?("if ", "elsif ", "unless ", "when ", "rescue") } - if branches >= 4 - findings << "#{branches} branch points in one method - this method makes decisions AND does work; split the two." - end - {findings: findings} -end - -critics << critic("name_watcher") do |input| - body = input[:source] - findings = [] - vague = body.scan(/\b(data|info|result|temp|obj|thing)\b/).flatten.tally - vague.each do |word, count| - findings << "'#{word}' appears #{count}x - a name that could mean anything means nothing. What IS it?" - end - if body.match?(/def \w+_and_\w+/) - findings << "the name contains 'and' - a confession that this is two methods in one costume." - end - {findings: findings} -end - -# The circle convenes: each critic rides its own task and reviews in parallel -orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 3) -seats = critics.to_h do |c| - task = Agentic::Task.new( - description: c.name, - agent_spec: {"name" => c.name, "instructions" => "Review the submission"}, - payload: c - ) - orchestrator.add_task(task, agent: ->(t) { - t.payload.execute_capability(t.payload.name, {source: source})[:findings] - }) - [c.name, task] -end -run = orchestrator.execute_plan -scrolls = seats.transform_values { |task| run.results[task.id].output } - -puts "REFACTORING DOJO" -puts "submission: ##{method_name} (#{lines.size} lines) from #{File.basename(file)}" -puts -scrolls.each do |critic_name, findings| - puts "#{critic_name.tr("_", " ")} says:" - findings.each { |f| puts " - #{f}" } - puts " - no complaints. rare." if findings.empty? - puts -end - -total = scrolls.values.sum(&:size) -puts "sensei's prescription:" -if total.zero? - puts " ship it, then find a harder kata." -else - first_move = scrolls["squint_tester"]&.first || scrolls.values.flatten.first - puts " #{total} findings, ONE next step - the smallest one:" - puts " start where the squint test hurts: #{first_move}" - puts " make that change, run the tests, come back. refactoring is many small" - puts " safe steps, not one brave rewrite." -end diff --git a/examples/relation_diff.rb b/examples/relation_diff.rb deleted file mode 100644 index 6c6f8b8..0000000 --- a/examples/relation_diff.rb +++ /dev/null @@ -1,88 +0,0 @@ -# frozen_string_literal: true - -# The Relation Diff: round 8's semver advisor classified declaration -# changes but had to shrug at rules - lambdas can't be compared. Now -# relations are data, so the RULES diff too: a tightened limit is -# breaking, a loosened one compatible, a new rule breaking, a removed -# one compatible, and a changed relation TYPE is a different law -# entirely. The last opaque corner of the contract joins semver. -# -# bundle exec ruby examples/relation_diff.rb -# -# Runs offline; v2 contains one of every interesting rule change. - -require_relative "../lib/agentic" - -V1_RULES = { - fits: {relation: :sum_lte, fields: [:weight, :volume], limit: 6_000}, - customs: {relation: :requires, fields: [:express, :customs_code]}, - one_auth: {relation: :mutually_exclusive, fields: [:api_key, :oauth_token]}, - legacy: {relation: :requires, fields: [:fragile, :packaging]}, - audited: {message: "audited accounts only", fields: [:account], check: ->(i) { true }} -}.freeze - -V2_RULES = { - fits: {relation: :sum_lte, fields: [:weight, :volume], limit: 4_000}, # tightened - customs: {relation: :requires, fields: [:express, :customs_code, :incoterm]}, # widened scope - one_auth: {relation: :requires, fields: [:api_key, :oauth_token]}, # DIFFERENT LAW - speedy: {relation: :sum_lte, fields: [:weight], limit: 100}, # new rule - # legacy: removed - audited: {message: "audited accounts only", fields: [:account], check: ->(i) { i[:account] != "test" }} -}.freeze - -def classify(v1, v2) - changes = [] - (v1.keys & v2.keys).each do |id| - old_rule, new_rule = v1[id], v2[id] - if old_rule[:relation] && new_rule[:relation] - if old_rule[:relation] != new_rule[:relation] - changes << [:breaking, "rule :#{id} changed LAW: #{old_rule[:relation]} -> #{new_rule[:relation]} - not an edit, a replacement"] - next - end - case new_rule[:relation] - when :sum_lte - changes << [:breaking, "rule :#{id} limit tightened #{old_rule[:limit]} -> #{new_rule[:limit]} - previously legal calls rejected"] if new_rule[:limit] < old_rule[:limit] - changes << [:compatible, "rule :#{id} limit loosened #{old_rule[:limit]} -> #{new_rule[:limit]}"] if new_rule[:limit] > old_rule[:limit] - when :requires - added = new_rule[:fields] - old_rule[:fields] - removed = old_rule[:fields] - new_rule[:fields] - changes << [:breaking, "rule :#{id} now also demands #{added.join(", ")} - callers satisfying v1 fail v2"] if added.any? - changes << [:compatible, "rule :#{id} no longer demands #{removed.join(", ")}"] if removed.any? && added.none? - when :mutually_exclusive - changes << [:breaking, "rule :#{id} exclusion widened to #{new_rule[:fields].join(", ")}"] if (new_rule[:fields] - old_rule[:fields]).any? - end - elsif old_rule[:relation].nil? && new_rule[:relation].nil? - changes << [:opaque, "rule :#{id} is a lambda in both versions - the diff cannot see inside; treat as breaking unless proven"] - end - end - (v2.keys - v1.keys).each do |id| - changes << [:breaking, "rule :#{id} added (#{V2_RULES[id][:relation]}) - a new law existing callers never agreed to"] - end - (v1.keys - v2.keys).each do |id| - changes << [:compatible, "rule :#{id} removed - every v1-legal call remains legal"] - end - changes -end - -changes = classify(V1_RULES, V2_RULES) -breaking = changes.count { |kind, _| kind == :breaking } - -puts "RELATION DIFF: quote_shipping rules, v1 -> v2" -puts -order = {breaking: 0, opaque: 1, compatible: 2} -changes.sort_by { |kind, _| order[kind] }.each do |kind, message| - puts format(" %-10s %s", kind.to_s.upcase, message) -end - -puts -puts " verdict: #{breaking} breaking rule change(s) -> major version bump." -puts -puts " round 8's advisor ended every report with a shrug: \"3 breaking" -puts " changes IN THE DECLARATIONS\" - rules were lambdas, invisible to" -puts " any diff. relations closed that: the limit, the fields, and the" -puts " law itself are data, so tightening 6000->4000 is as diffable as" -puts " a max: change. note the one law-change row: same rule id, same" -puts " fields, different relation - that's not an edit, it's a new" -puts " contract wearing an old name, and the diff says so. the lambda" -puts " rule still gets the shrug (OPAQUE, presumed breaking) - which is" -puts " now a choice you make per rule, not a ceiling on the tool." diff --git a/examples/relation_prober.rb b/examples/relation_prober.rb deleted file mode 100644 index bcd32d7..0000000 --- a/examples/relation_prober.rb +++ /dev/null @@ -1,115 +0,0 @@ -# frozen_string_literal: true - -# The Relation Prober: relation-typed rules are new, and new -# predicates deserve hostility. Each relation is probed with edge -# inputs - zeros, negatives, floats, missing keys, nils - and every -# verdict is checked against an independent hand-written oracle. -# The prober also walks off the paved road on purpose: in round 10 -# a rule referencing an undeclared field met a string and escaped as -# a raw TypeError; the round-11 release files that edge down, and -# this prober is the acceptance test that proves it stays down. -# -# bundle exec ruby examples/relation_prober.rb -# -# Runs offline; exits 1 if any probe draws blood again. - -require_relative "../lib/agentic" - -def spec_for(rules, inputs) - Agentic::CapabilitySpecification.new( - name: "probe", description: "probe", version: "1.0.0", inputs: inputs, rules: rules - ) -end - -def verdict(spec, payload) - Agentic::CapabilityValidator.new(spec).validate_inputs!(payload) - :allow -rescue Agentic::Errors::ValidationError - :reject -end - -NUMERIC = {a: {type: "number"}, b: {type: "number"}}.freeze -STRINGS = {x: {type: "string"}, y: {type: "string"}}.freeze - -# Each probe: [description, spec, payload, oracle verdict] -PROBES = [ - ["sum_lte: both at zero", spec_for({r: {relation: :sum_lte, fields: [:a, :b], limit: 0}}, NUMERIC), - {a: 0, b: 0}, :allow], - ["sum_lte: exactly at the limit", spec_for({r: {relation: :sum_lte, fields: [:a, :b], limit: 10}}, NUMERIC), - {a: 4, b: 6}, :allow], - ["sum_lte: one over, via floats", spec_for({r: {relation: :sum_lte, fields: [:a, :b], limit: 10}}, NUMERIC), - {a: 4.5, b: 5.6}, :reject], - ["sum_lte: negative rescues the sum", spec_for({r: {relation: :sum_lte, fields: [:a, :b], limit: 10}}, NUMERIC), - {a: 15, b: -6}, :allow], - ["sum_lte: missing field counts as 0", spec_for({r: {relation: :sum_lte, fields: [:a, :b], limit: 10}}, NUMERIC), - {a: 7}, :allow], - ["requires: trigger absent", spec_for({r: {relation: :requires, fields: [:x, :y]}}, STRINGS), - {y: "alone is fine"}, :allow], - ["requires: trigger present, need met", spec_for({r: {relation: :requires, fields: [:x, :y]}}, STRINGS), - {x: "t", y: "met"}, :allow], - ["requires: trigger present, need missing", spec_for({r: {relation: :requires, fields: [:x, :y]}}, STRINGS), - {x: "t"}, :reject], - ["requires: three-field chain broken", spec_for({r: {relation: :requires, fields: [:x, :y, :z]}}, STRINGS.merge(z: {type: "string"})), - {x: "t", y: "met"}, :reject], - ["mutually_exclusive: neither", spec_for({r: {relation: :mutually_exclusive, fields: [:x, :y]}}, STRINGS), - {}, :allow], - ["mutually_exclusive: one", spec_for({r: {relation: :mutually_exclusive, fields: [:x, :y]}}, STRINGS), - {x: "only"}, :allow], - ["mutually_exclusive: both", spec_for({r: {relation: :mutually_exclusive, fields: [:x, :y]}}, STRINGS), - {x: "one", y: "two"}, :reject], - ["mutually_exclusive: empty string is present", spec_for({r: {relation: :mutually_exclusive, fields: [:x, :y]}}, STRINGS), - {x: "", y: "two"}, :reject] -].freeze - -puts "RELATION PROBER (#{PROBES.size} probes against a hand-written oracle)" -puts -divergences = 0 -PROBES.each do |description, spec, payload, oracle| - actual = verdict(spec, payload) - divergences += 1 if actual != oracle - puts format(" %-42s oracle: %-7s got: %-7s %s", - description, oracle, actual, (actual == oracle) ? "ok" : "DIVERGED") -end - -puts -puts " #{PROBES.size} probes, #{divergences} divergence(s) on the paved road." -puts - -# --- off the paved road --------------------------------------------------------- -# In round 10, a rule referencing an undeclared field let a string -# reach sum_lte's arithmetic: raw TypeError, a 422 path turned 500 -# path. The round-11 fix refuses at CONSTRUCTION - the typo fails at -# boot, where it names itself, before any request can find it. -edges = { - "sum_lte over an UNDECLARED field" => - [{r: {relation: :sum_lte, fields: [:a, :undeclared], limit: 10}}, {a: {type: "number"}}], - "sum_lte over a declared STRING" => - [{r: {relation: :sum_lte, fields: [:a, :b], limit: 10}}, {a: {type: "number"}, b: {type: "string"}}], - "requires with a typo'd field (fail-open)" => - [{r: {relation: :requires, fields: [:a, :customs_kode]}}, {a: {type: "number"}, customs_code: {type: "string"}}] -} - -blood = 0 -puts " off the paved road: rules that must refuse to construct" -edges.each do |name, (rules, inputs)| - Agentic::CapabilityValidator.new(spec_for(rules, inputs)) - blood += 1 - puts format(" %-42s CONSTRUCTED - the edge is back", name) -rescue ArgumentError => e - puts format(" %-42s refused at boot: %s", name, e.message[0, 40] + "...") -rescue => e - blood += 1 - puts format(" %-42s wrong uniform: %s", name, e.class) -end - -puts -if divergences.zero? && blood.zero? - puts " the paved road holds and the roadside refuses construction." - puts " note the third edge: a typo'd field in requires used to fail" - puts " OPEN - the rule just never fired, which no test of valid inputs" - puts " would ever notice. now the typo can't boot. a validator's" - puts " errors must wear its uniform, and its typos must not compile." -else - puts " BLOOD DRAWN: #{divergences} divergence(s), #{blood} escaped edge(s)." -end -exit((divergences + blood).zero? ? 0 : 1) diff --git a/examples/renga_circle.rb b/examples/renga_circle.rb deleted file mode 100644 index 4dba311..0000000 --- a/examples/renga_circle.rb +++ /dev/null @@ -1,87 +0,0 @@ -# frozen_string_literal: true - -# A renga circle: three poet agents compose a linked-verse poem, each -# verse responding to the one before it. The dependency graph IS the -# poem's form - verse 2 cannot begin until verse 1 exists, and the -# orchestrator pipes each verse into the poet who answers it. -# -# bundle exec ruby examples/renga_circle.rb -# -# Runs offline: each poet's craft is a lambda-backed capability. - -require_relative "../lib/agentic" - -STYLES = { - "Basho" => ->(theme, previous) { - previous ? "#{previous.split.last} lingers -\n#{theme} on the temple bell\na crow shakes off rain" : "first light, #{theme} -\nthe pond remembers\nlast night's moon" - }, - "Buson" => ->(theme, previous) { - "answering #{previous.split.first}:\n#{theme} paints the hillside\nin a brush of geese" - }, - "Issa" => ->(theme, previous) { - "yes, #{previous.split.last} - and yet\neven this #{theme}\nis home to someone small" - } -}.freeze - -# Each poet is an agent with a single "verse" capability -poets = STYLES.to_h do |name, craft| - spec = Agentic::CapabilitySpecification.new( - name: "verse_#{name.downcase}", - description: "Compose a linked verse in #{name}'s voice", - version: "1.0.0", - inputs: { - theme: {type: "string", required: true}, - previous: {type: "string", description: "The verse being answered"} - }, - outputs: {verse: {type: "string", required: true}} - ) - - provider = Agentic::CapabilityProvider.new( - capability: spec, - implementation: ->(inputs) { {verse: craft.call(inputs[:theme], inputs[:previous])} } - ) - Agentic.register_capability(spec, provider) - - poet = Agentic::Agent.build do |a| - a.name = name - a.role = "Renga poet" - end - poet.add_capability("verse_#{name.downcase}") - - [name, poet] -end - -theme = ARGV.first || "autumn wind" -orchestrator = Agentic::PlanOrchestrator.new - -# The circle: each poet's task depends on the previous poet's, and the -# previous verse arrives through the dependency pipe - no shared state -tasks = [] -%w[Basho Buson Issa].each do |name| - task = Agentic::Task.new( - description: name, - agent_spec: Agentic::AgentSpecification.new( - name: name, description: "Renga poet", instructions: "Compose one linked verse" - ), - payload: theme - ) - - orchestrator.add_task(task, tasks.empty? ? [] : [tasks.last], agent: ->(t) { - previous = t.dependency_outputs.values.first - poets[t.description].execute_capability( - "verse_#{t.description.downcase}", - {theme: t.payload, previous: previous}.compact - )[:verse] - }) - tasks << task -end - -result = orchestrator.execute_plan - -puts " ~ a renga on \"#{theme}\" ~" -puts -tasks.each do |task| - puts result.results[task.id].output.split("\n").map { |line| " #{line}" } - puts -end -puts " (#{result.status} in #{(result.execution_time * 1000).round}ms)" diff --git a/examples/require_cost.rb b/examples/require_cost.rb deleted file mode 100644 index 47af4bc..0000000 --- a/examples/require_cost.rb +++ /dev/null @@ -1,77 +0,0 @@ -# frozen_string_literal: true - -# The Require Cost Report: `require` is a purchase - memory, objects, -# and boot time, paid again by every process you fork and every -# worker you scale. This measures what the gem and each major -# dependency cost AT REQUIRE TIME, each in a clean subprocess so -# nobody's cost gets billed to a neighbor's account. -# -# bundle exec ruby examples/require_cost.rb -# -# Runs offline; each row is an isolated child process. - -require "open3" -require "rbconfig" - -RUBY = RbConfig.ruby -LIB = File.expand_path("../lib", __dir__) - -# Measure inside a pristine child: RSS and allocated objects, before -# and after the require - so each row is that require's WHOLE bill, -# transitive dependencies included -PROBE = <<~'RUBY' - def rss_kb = File.read("/proc/self/status")[/VmRSS:\s+(\d+)/, 1].to_i - target, touch = ARGV - objects_before = GC.stat(:total_allocated_objects) - rss_before = rss_kb - t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) - require target - eval(touch) if touch && !touch.empty? # standard:disable Security/Eval - ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000 - puts [rss_kb - rss_before, GC.stat(:total_allocated_objects) - objects_before, ms.round(1)].join(",") -RUBY - -def cost_of(target, touch = "") - out, status = Open3.capture2(RUBY, "-I", LIB, "-e", PROBE, target, touch) - raise "probe failed for #{target}" unless status.success? - - rss_kb, objects, ms = out.strip.split(",") - {rss_mb: rss_kb.to_f / 1024, objects: objects.to_i, ms: ms.to_f} -end - -TARGETS = { - "json (stdlib)" => ["json"], - "zeitwerk" => ["zeitwerk"], - "async" => ["async"], - "dry-schema" => ["dry/schema"], - "agentic (require only)" => ["agentic"], - "agentic + first real touch" => ["agentic", - "Agentic::PlanOrchestrator.new; Agentic::CapabilityValidator"] -}.freeze - -puts "REQUIRE COST REPORT (each row measured in a pristine child process)" -puts -puts format(" %-28s %10s %14s %10s", "require", "RSS", "objects", "time") -rows = TARGETS.transform_values { |target, touch| cost_of(target, touch || "") } -rows.each do |name, cost| - puts format(" %-28s %8.1fMB %14d %8.0fms %s", - name, cost[:rss_mb], cost[:objects], cost[:ms], "#" * (cost[:rss_mb] * 2).round) -end - -bare = rows["agentic (require only)"] -touched = rows["agentic + first real touch"] - -puts -puts " the bill, read like a Heroku support ticket - and it's a plot" -puts format(" twist: `require \"agentic\"` costs %.1fMB / %dms, nearly FREE,", bare[:rss_mb], bare[:ms]) -puts " because Zeitwerk (the round-1 cleanup) defers every constant." -puts format(" the first real touch is where the bill lands: %.1fMB and %dms,", touched[:rss_mb], touched[:ms]) -puts " as async and dry-schema come in through the autoloader. deferred" -puts " is not free - it's a bill that arrives during your first" -puts " request instead of your boot, which is either exactly what you" -puts " want (CLI tools, tiny scripts pay only for what they touch) or" -puts " exactly what you don't (a web worker's first request eats the" -puts " latency). the moves this report funds: eager_load in servers +" -puts " preload_app (pay once, share copy-on-write), stay lazy in CLIs," -puts " and run this script in CI so a new dependency's bill arrives in" -puts " the PR that adds it - not in the invoice at month's end." diff --git a/examples/resize_torture.rb b/examples/resize_torture.rb deleted file mode 100644 index 43ee531..0000000 --- a/examples/resize_torture.rb +++ /dev/null @@ -1,135 +0,0 @@ -# frozen_string_literal: true - -# The Resize Torture Test: a feature that changes a limiter's ceiling -# while fibers are waiting on it had better say exactly what it -# guarantees - and then survive an attempt to break the guarantee. -# Three assaults: per-epoch ceilings under load, a mid-flight shrink, -# and a grow that must actually wake the waiters. -# -# bundle exec ruby examples/resize_torture.rb -# -# Runs offline; exits 1 if any guarantee cracks. - -require_relative "../lib/agentic" -require "async" - -violations = [] - -# --- assault 1: every epoch's ceiling holds under saturating load ------------- -# Resize through jagged ceilings; within each epoch, run far more jobs -# than lanes and record the max observed concurrency ourselves - we -# don't trust high_water, we recompute it. -limiter = Agentic::RateLimit.new(1) -EPOCHS = [1, 5, 2, 4, 1, 3].freeze - -Sync do - EPOCHS.each do |ceiling| - limiter.resize(ceiling) - concurrent = 0 - observed_max = 0 - - (ceiling * 4).times.map { - Async do - limiter.acquire do - concurrent += 1 - observed_max = [observed_max, concurrent].max - sleep(0.003) - concurrent -= 1 - end - end - }.each(&:wait) - - if observed_max > ceiling - violations << "epoch ceiling #{ceiling}: observed #{observed_max} concurrent" - end - puts format(" epoch ceiling %-3d observed max %-3d %s", - ceiling, observed_max, (observed_max > ceiling) ? "VIOLATED" : "held") - end -end - -# --- assault 2: shrink mid-flight admits nobody above the new mark ------------ -# Fill 5 lanes, shrink to 2 while all 5 are running, then submit a -# second wave. Every wave-2 admission must see <= 2 concurrent -# (in-flight holders from wave 1 drain; nothing new joins them). -puts -shrinker = Agentic::RateLimit.new(5) -wave2_snapshots = [] - -Sync do - concurrent = 0 - wave1 = 5.times.map { - Async do - shrinker.acquire do - concurrent += 1 - sleep(0.03) - concurrent -= 1 - end - end - } - Async do - sleep(0.005) # let wave 1 occupy all 5 lanes - shrinker.resize(2) - end.wait - - wave2 = 5.times.map { - Async do - shrinker.acquire do - concurrent += 1 - wave2_snapshots << concurrent - sleep(0.003) - concurrent -= 1 - end - end - } - (wave1 + wave2).each(&:wait) -end - -if wave2_snapshots.any? { |snapshot| snapshot > 2 } - violations << "post-shrink admission saw #{wave2_snapshots.max} concurrent" -end -puts format(" shrink 5->2 mid-flight: wave-2 admissions saw max %d concurrent %s", - wave2_snapshots.max, (wave2_snapshots.any? { |s| s > 2 }) ? "VIOLATED" : "(<= 2, held)") - -# --- assault 3: grow must wake the already-waiting ----------------------------- -# One lane, three long jobs queued behind it. Grow to 3; the queued -# jobs must be admitted promptly, not on the old schedule. -grower = Agentic::RateLimit.new(1) -admissions = [] - -Sync do - t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) - jobs = 3.times.map { - Async do - grower.acquire do - admissions << Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0 - sleep(0.05) - end - end - } - Async do - sleep(0.01) - grower.resize(3) - end.wait - jobs.each(&:wait) -end - -# Serial schedule would admit job 3 at ~0.10s; waking on grow admits it ~0.01s -late = admissions.max -if late > 0.04 - violations << "grow did not wake waiters (last admission at #{(late * 1000).round}ms)" -end -puts format(" grow 1->3 with 2 queued: last admission at %.0fms %s", - late * 1000, (late > 0.04) ? "VIOLATED (serial schedule)" : "(woken by resize, held)") - -puts -if violations.empty? - puts " 3 assaults, 0 cracks. the guarantees, as proven: an epoch's" - puts " ceiling binds every admission inside it; shrinking drains rather" - puts " than evicts, and nothing new is admitted above the new mark;" - puts " growing wakes waiters immediately instead of leaving them on" - puts " the old schedule. resize without these proofs is a data race" - puts " with a friendly method name." -else - puts " CRACKED: #{violations.join("; ")}" -end -exit(violations.empty? ? 0 : 1) diff --git a/examples/retry_budget.rb b/examples/retry_budget.rb deleted file mode 100644 index 3b78366..0000000 --- a/examples/retry_budget.rb +++ /dev/null @@ -1,111 +0,0 @@ -# frozen_string_literal: true - -# The Retry Budget: a retry storm is a self-inflicted DDoS - every -# job politely retrying 3x turns one outage into four. Retries are a -# SHARED resource, so give the fleet one wallet: transient failures -# spend from it, hopeless failures spend nothing (they get no retry -# at all), and when the wallet is empty the kindest thing left is -# failing fast - the fleet already knows the upstream is down. -# -# bundle exec ruby examples/retry_budget.rb -# -# Runs offline; the upstream is dead for the whole run. - -require_relative "../lib/agentic" -require "tmpdir" - -Agentic.logger.level = :fatal - -MAX_RETRIES = 3 -JOBS = 12 - -# The wallet is a windowed RateLimit asked politely: try_acquire -# (round 11) answers false RIGHT NOW instead of making the caller -# wait for capacity - and waiting for retry capacity during an -# outage would just be the storm with extra steps. The custom -# budget class this example shipped with has retired. -class RetryBudget - def initialize(allowance, per: 60) - @wallet = Agentic::RateLimit.new(allowance, per: per) - @spent = 0 - end - - attr_reader :spent - - def spend? - admitted = @wallet.try_acquire - @spent += 1 if admitted - admitted - end -end - -def run_job(name, error, journal) - orchestrator = Agentic::PlanOrchestrator.new( - lifecycle_hooks: journal.lifecycle_hooks, - retry_policy: {max_retries: 0, retryable_errors: []} - ) - orchestrator.add_task(Agentic::Task.new( - description: name, agent_spec: {"name" => "w", "instructions" => "sync"} - ), agent: ->(_t) { raise error }) - orchestrator.execute_plan -end - -def drill(strategy, budget: nil) - path = File.join(Dir.tmpdir, "agentic_budget_#{strategy}.jsonl") - File.delete(path) if File.exist?(path) - journal = Agentic::ExecutionJournal.new(path: path) - - calls = 0 - fast_failed = 0 - JOBS.times do |i| - # Job 7 hits a revoked key; everyone else hits the dead upstream - error = (i == 7) ? Agentic::Errors::LlmAuthenticationError.new("401") : Agentic::Errors::LlmServerError.new("503") - attempts = 0 - loop do - run_job("job#{i}", error, journal) - calls += 1 - attempts += 1 - - verdict = Agentic::ExecutionJournal.replay(path: path).events - .reverse.find { |e| e[:event] == "task_failed" }[:retryable] - break if verdict == false # hopeless: no retry spends anything, ever - break if attempts > MAX_RETRIES - - if budget - unless budget.spend? - fast_failed += 1 - break - end - end - end - end - [calls, fast_failed, budget&.spent] -end - -puts "RETRY BUDGET (#{JOBS} jobs, upstream dead, max #{MAX_RETRIES} retries each)" -puts - -calls_a, = drill("naive") -puts " strategy A - every job for itself:" -puts " #{calls_a} calls fired at a host that was down for all of them." -puts " 11 transient jobs x (1 + #{MAX_RETRIES} retries) + 1 auth job x 1 = #{calls_a}:" -puts " the outage was 1 incident; the fleet made it #{calls_a} requests." -puts - -budget = RetryBudget.new(5) -calls_b, fast_failed, spent = drill("budgeted", budget: budget) -puts " strategy B - one wallet of 5 retries for the whole fleet:" -puts " #{calls_b} calls total: #{JOBS} first attempts + #{spent} budgeted retries." -puts " #{fast_failed} jobs failed FAST once the wallet emptied - no call, no" -puts " timeout, no bill. the auth job spent nothing from the wallet:" -puts " hopeless failures don't get retries, so they can't drain the" -puts " budget the transient ones might still need." -puts -puts " the fleet cut #{calls_a - calls_b} pointless requests (#{calls_a} -> #{calls_b}) and lost nothing -" -puts " every retry was doomed anyway. per-job retry policies answer" -puts " \"should I try again?\"; the budget answers \"should ANYONE?\" -" -puts " round 9's breaker asked that per-upstream, this asks it per-" -puts " window, and both read the same journaled verdicts. retries are" -puts " a shared resource. give them a wallet, not a habit. (and the" -puts " wallet is now a real windowed RateLimit - try_acquire says no" -puts " without making anyone wait for it, which is the entire point.)" diff --git a/examples/rule_prober.rb b/examples/rule_prober.rb deleted file mode 100644 index 3f71ee0..0000000 --- a/examples/rule_prober.rb +++ /dev/null @@ -1,104 +0,0 @@ -# frozen_string_literal: true - -# The Rule Prober: structured rules declare which fields they read - -# so now that claim can be AUDITED. For each rule, perturb fields it -# does NOT declare; if the verdict flips, the rule is reading fields -# off the books. One of the rules below lies. The prober finds it. -# -# bundle exec ruby examples/rule_prober.rb [seed] -# -# Runs offline and deterministically. - -require_relative "../lib/agentic" - -seed = (ARGV.first || 20260707).to_i -rng = Random.new(seed) - -SPEC = Agentic::CapabilitySpecification.new( - name: "approve_loan", - description: "Approve a loan application", - version: "1.0.0", - inputs: { - amount: {type: "number", required: true, min: 1}, - income: {type: "number", required: true, min: 0}, - score: {type: "number", required: true, min: 300, max: 850}, - cosigned: {type: "boolean", required: true} - }, - rules: { - affordability: { - message: "amount may not exceed 5x income", - fields: [:amount, :income], - check: ->(i) { i[:amount] <= i[:income] * 5 } - }, - subprime_needs_cosigner: { - message: "scores under 600 require a cosigner", - fields: [:score, :cosigned], - check: ->(i) { i[:score] >= 600 || i[:cosigned] } - }, - # The liar: declares [:amount] but secretly reads :score too - jumbo_screening: { - message: "amounts over 400k get extra screening", - fields: [:amount], - check: ->(i) { i[:amount] <= 400_000 || i[:score] > 700 } - } - } -) - -# Generate a conforming application -def sample_application(rng) - { - amount: rng.rand(10_000..900_000), - income: rng.rand(30_000..250_000), - score: rng.rand(300..850), - cosigned: [true, false].sample(random: rng) - } -end - -# Perturb one field to another conforming value -def perturb(application, field, rng) - fresh = sample_application(rng) - application.merge(field => fresh[field]) -end - -TRIALS = 300 -findings = Hash.new { |h, k| h[k] = [] } - -SPEC.rules.each do |rule_id, definition| - undeclared = SPEC.inputs.keys - definition[:fields] - - TRIALS.times do - application = sample_application(rng) - verdict = definition[:check].call(application) - - undeclared.each do |field| - mutated = perturb(application, field, rng) - next if mutated == application - - if definition[:check].call(mutated) != verdict - findings[rule_id] << field - end - end - end -end - -puts "RULE PROBER (seed #{seed}, #{TRIALS} trials per rule)" -puts -SPEC.rules.each do |rule_id, definition| - flipped = findings[rule_id].tally - if flipped.empty? - puts " #{rule_id}: honest - declares #{definition[:fields].inspect}, " \ - "and no undeclared field ever changed its verdict" - else - puts " #{rule_id}: LYING - declares #{definition[:fields].inspect} but its verdict" - flipped.each do |field, count| - puts " flipped when :#{field} changed (#{count} of #{TRIALS} trials)" - end - end -end - -puts -puts "why it matters: Piotr's 422 renderer highlights the DECLARED fields." -puts "a rule that secretly reads :score sends users to fix :amount while" -puts "the real problem sits unhighlighted. field declarations are now" -puts "testable claims - so test them." -exit findings.empty? ? 0 : 1 diff --git a/examples/rule_shapes.rb b/examples/rule_shapes.rb deleted file mode 100644 index 11e7e90..0000000 --- a/examples/rule_shapes.rb +++ /dev/null @@ -1,93 +0,0 @@ -# frozen_string_literal: true - -# Rule Shapes: the same policy - "express shipments need a customs -# code" - written three ways: a lambda, a structured check, and a -# relation. Then four consumers try to use each shape: the validator, -# the message deriver, the fixture generator, and the schema export. -# Representation isn't style; it's a decision about who else gets to -# understand you. -# -# bundle exec ruby examples/rule_shapes.rb -# -# Runs offline; the table is the argument. - -require_relative "../lib/agentic" - -INPUTS = { - express: {type: "boolean"}, - customs_code: {type: "string"} -}.freeze - -SHAPES = { - "lambda" => { - "express needs customs" => ->(i) { !i[:express] || !i[:customs_code].nil? } - }, - "structured check" => { - customs: {message: "express shipments need a customs code", - fields: [:express, :customs_code], - check: ->(i) { !i[:express] || !i[:customs_code].nil? }} - }, - "relation" => { - customs: {relation: :requires, fields: [:express, :customs_code]} - } -}.freeze - -def spec_with(rules) - Agentic::CapabilitySpecification.new( - name: "ship", description: "Ship it", version: "1.0.0", inputs: INPUTS, rules: rules - ) -end - -# Consumer 1: can the validator enforce it? -def enforces?(spec) - Agentic::CapabilityValidator.new(spec).validate_inputs!(express: true) - false -rescue Agentic::Errors::ValidationError - true -end - -# Consumer 2: does a violation point at its fields, with a real message? -def explains?(spec) - Agentic::CapabilityValidator.new(spec).validate_inputs!(express: true) - false -rescue Agentic::Errors::ValidationError => e - violation = e.rule_violations.first - violation[:fields].any? && !violation[:message].match?(/\A(rule_)?\d*\z/) -end - -# Consumer 3: can a generator SATISFY it without running it blind? -# (Only a declared predicate can be satisfied constructively) -def generatable?(rules) - rules.values.all? { |d| !d.respond_to?(:call) && d[:relation] } -end - -# Consumer 4: does it reach the JSON Schema export as a real keyword? -def projects?(spec) - schema = spec.to_json_schema - !(schema["dependencies"] || schema["allOf"]).nil? -end - -puts "RULE SHAPES: one policy, three representations, four consumers" -puts -puts format(" %-22s %-10s %-10s %-12s %s", "shape", "enforced", "explains", "generatable", "projects") -SHAPES.each do |name, rules| - spec = spec_with(rules) - puts format(" %-22s %-10s %-10s %-12s %s", - name, - enforces?(spec) ? "yes" : "NO", - explains?(spec) ? "yes" : "no", - generatable?(rules) ? "yes" : "no", - projects?(spec) ? "yes" : "no") -end - -puts -puts " all three shapes enforce - if enforcement were the whole job," -puts " they'd be interchangeable and you'd pick by taste. but the" -puts " lambda answers ONE message (call) so it has ONE consumer; the" -puts " structured check adds fields: and message:, so violations can" -puts " explain themselves; and the relation makes the predicate itself" -puts " data, so tools that never RUN it - the generator, the schema" -puts " export, round 10's diff - can still read it. choose the" -puts " representation by counting who must understand it: code keeps" -puts " secrets, data makes friends. save lambdas for policies that" -puts " are genuinely secrets." diff --git a/examples/schema_advisor.rb b/examples/schema_advisor.rb deleted file mode 100644 index 2e20c85..0000000 --- a/examples/schema_advisor.rb +++ /dev/null @@ -1,139 +0,0 @@ -# frozen_string_literal: true - -# The Schema Advisor: give it a schema and a query log, get back the -# advisories a careful DBA would write - each rule its own capability, -# each table analyzed as its own task. -# -# bundle exec ruby examples/schema_advisor.rb -# -# Runs offline: the rules are deterministic. Rules that fire are facts -# about your schema, not opinions from a model. - -require_relative "../lib/agentic" - -SCHEMA = { - "users" => { - columns: { - "id" => {type: "integer", primary_key: true}, - "email" => {type: "text", null: true}, - "created_at" => {type: "timestamp", null: true} - }, - indexes: ["id"] - }, - "orders" => { - columns: { - "id" => {type: "integer", primary_key: true}, - "user_id" => {type: "integer", null: true}, - "status" => {type: "text", null: true}, - "total_cents" => {type: "float", null: true} - }, - indexes: ["id"] - }, - "audit_logs" => { - columns: { - "uuid" => {type: "text", primary_key: true}, - "payload" => {type: "text", null: true}, - "user_id" => {type: "integer", null: true} - }, - indexes: [] - } -}.freeze - -QUERY_LOG = [ - "SELECT * FROM orders WHERE user_id = ?", - "SELECT * FROM orders WHERE status = ? ORDER BY id DESC", - "SELECT * FROM users WHERE email = ?", - "SELECT * FROM audit_logs WHERE user_id = ?" -].freeze - -def register_rule(name, &impl) - spec = Agentic::CapabilitySpecification.new( - name: name, description: name.tr("_", " "), version: "1.0.0", - inputs: { - table: {type: "string", required: true}, - definition: {type: "object", required: true}, - queries: {type: "array", required: true} - }, - outputs: {advisories: {type: "array", required: true}} - ) - Agentic.register_capability( - spec, Agentic::CapabilityProvider.new(capability: spec, implementation: impl) - ) -end - -register_rule("check_missing_indexes") do |input| - table, definition, queries = input.values_at(:table, :definition, :queries) - filtered = queries.filter_map { |q| q[/FROM #{table} WHERE (\w+)/, 1] }.uniq - advisories = (filtered - definition[:indexes]).map do |column| - {severity: "high", table: table, - advice: "queries filter on #{table}.#{column} but no index covers it - add_index :#{table}, :#{column}"} - end - {advisories: advisories} -end - -register_rule("check_null_discipline") do |input| - table, definition = input.values_at(:table, :definition) - advisories = definition[:columns].filter_map do |column, meta| - next if meta[:primary_key] || meta[:null] == false - - {severity: "medium", table: table, - advice: "#{table}.#{column} allows NULL - if it's required, say so: NOT NULL with a default beats a validation"} - end - {advisories: advisories} -end - -register_rule("check_money_types") do |input| - table, definition = input.values_at(:table, :definition) - advisories = definition[:columns].filter_map do |column, meta| - next unless column.match?(/cents|price|amount|total/) && meta[:type] == "float" - - {severity: "high", table: table, - advice: "#{table}.#{column} stores money as float - use integer cents or decimal before rounding errors become refunds"} - end - {advisories: advisories} -end - -register_rule("check_text_primary_keys") do |input| - table, definition = input.values_at(:table, :definition) - advisories = definition[:columns].filter_map do |column, meta| - next unless meta[:primary_key] && meta[:type] == "text" - - {severity: "low", table: table, - advice: "#{table}.#{column} is a text primary key - fine if it's truly a UUID column type, expensive if it's a string"} - end - {advisories: advisories} -end - -RULES = %w[check_missing_indexes check_null_discipline check_money_types check_text_primary_keys].freeze - -dba = Agentic::Agent.build { |a| a.name = "DBA" } -RULES.each { |rule| dba.add_capability(rule) } - -orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 4) -SCHEMA.each do |table, definition| - orchestrator.add_task(Agentic::Task.new( - description: table, - agent_spec: {"name" => "DBA", "instructions" => "Review the table"}, - payload: definition - ), agent: ->(task) { - RULES.flat_map do |rule| - dba.execute_capability(rule, { - table: task.description, definition: task.payload, queries: QUERY_LOG - })[:advisories] - end - }) -end -result = orchestrator.execute_plan -findings = result.results.values.select(&:successful?).flat_map(&:output) - -puts "SCHEMA REVIEW: #{SCHEMA.size} tables, #{QUERY_LOG.size} logged queries, " \ - "#{findings.size} advisories (#{result.status})" -puts -%w[high medium low].each do |severity| - matching = findings.select { |f| f[:severity] == severity } - next if matching.empty? - - puts severity.upcase - matching.each { |f| puts " - #{f[:advice]}" } - puts -end diff --git a/examples/self_correcting_output.rb b/examples/self_correcting_output.rb deleted file mode 100644 index cfe3b00..0000000 --- a/examples/self_correcting_output.rb +++ /dev/null @@ -1,88 +0,0 @@ -# frozen_string_literal: true - -# Self-Correcting Output: the pattern that makes LLM components -# shippable. The model's output is validated against the capability's -# contract; violations don't raise to the user - they become the -# CORRECTION PROMPT for a bounded retry. The contract is the editor, -# the model is the writer, and the loop converges or fails honestly -# with the full paper trail. -# -# bundle exec ruby examples/self_correcting_output.rb -# -# Runs offline; the "model" is scripted to be sloppy, then coachable. - -require_relative "../lib/agentic" -require "json" - -Agentic.logger.level = :fatal - -CONTRACT = Agentic::CapabilitySpecification.new( - name: "extract_invoice", description: "Extract structured invoice data", version: "1.0.0", - inputs: {text: {type: "string", required: true}}, - outputs: { - vendor: {type: "string", required: true, non_empty: true}, - total_cents: {type: "number", required: true, min: 0}, - currency: {type: "string", required: true, enum: %w[USD EUR GBP]}, - due_date: {type: "string", required: true} - } -) -VALIDATOR = Agentic::CapabilityValidator.new(CONTRACT) - -# The "model": pass 1 is what models actually do to schemas; pass 2 -# reads the corrections like a chastened intern -MODEL = lambda do |prompt, attempt| - if attempt == 1 - # currency invented, total as a string, due_date forgotten - {vendor: "Initech Supply Co", total_cents: "4200", currency: "usd"} - else - # the correction prompt names each violation; the model complies - {vendor: "Initech Supply Co", total_cents: 4200, currency: "USD", due_date: "2026-08-01"} - end -end - -def correction_prompt(violations) - lines = violations.map { |field, messages| "- #{field}: #{messages.join("; ")}" } - "Your previous answer violated the output contract:\n#{lines.join("\n")}\n" \ - "Return the SAME data corrected to satisfy every constraint. Do not apologize; return JSON." -end - -MAX_ATTEMPTS = 3 -INVOICE = "Invoice from Initech Supply Co, total $42.00, due Aug 1 2026" - -puts "SELF-CORRECTING OUTPUT (the contract is the editor)" -puts -attempt = 0 -output = nil -prompt = "Extract the invoice fields from: #{INVOICE}" -loop do - attempt += 1 - output = MODEL.call(prompt, attempt) - puts " attempt #{attempt}: #{JSON.generate(output)}" - begin - VALIDATOR.validate_outputs!(output) - puts " -> contract satisfied. shipped after #{attempt} attempt(s)." - break - rescue Agentic::Errors::ValidationError => e - if attempt >= MAX_ATTEMPTS - puts " -> #{MAX_ATTEMPTS} attempts exhausted; failing HONESTLY with the paper trail." - raise - end - puts " -> rejected by the contract; violations become the next prompt:" - correction = correction_prompt(e.violations) - correction.lines.each { |l| puts " #{l.chomp}" } - prompt = correction - end -end - -puts -puts " the shape of the trick: nothing here trusts the model, and" -puts " nothing here burdens the user. the contract that documents the" -puts " capability (rounds 5-11 built six tools on it) turns out to be" -puts " the exact artifact a correction loop needs - violations arrive" -puts " pre-written as actionable feedback (\"currency: must be one of:" -puts " USD, EUR, GBP\"), which beats \"please try again\" by exactly the" -puts " margin your production error rate will show. the loop is" -puts " bounded (#{MAX_ATTEMPTS} attempts - unbounded self-correction is a billing" -puts " strategy), each retry costs one more model call and is worth" -puts " it, and when it fails it fails with every draft on record." -puts " ship the editor with the writer; never ship the writer alone." diff --git a/examples/setup_doctor.rb b/examples/setup_doctor.rb deleted file mode 100644 index e2b3d19..0000000 --- a/examples/setup_doctor.rb +++ /dev/null @@ -1,76 +0,0 @@ -# frozen_string_literal: true - -# The Setup Doctor: every onboarding wiki page is a bug. This runs the -# checks a README asks a new hire to do by hand - ruby version, bundle -# health, git state, test suite presence - in parallel, then one -# diagnosis task reads them all BY NAME and prescribes. -# -# bundle exec ruby examples/setup_doctor.rb -# -# Runs offline against the current repo. Exit 0 means "start coding". - -require_relative "../lib/agentic" - -ROOT = File.expand_path("..", __dir__) - -def check(description) - Agentic::Task.new( - description: description, - agent_spec: {"name" => description, "instructions" => "examine the machine"} - ) -end - -orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 4) - -ruby = check("ruby version") -orchestrator.add_task(ruby, agent: ->(_t) { - required = File.read(File.join(ROOT, "agentic.gemspec"))[/required_ruby_version.*?([\d.]+)/, 1] - {ok: Gem::Version.new(RUBY_VERSION) >= Gem::Version.new(required), - detail: "running #{RUBY_VERSION}, gem requires >= #{required}"} -}) - -bundle = check("bundle health") -orchestrator.add_task(bundle, agent: ->(_t) { - ok = system("bundle check > /dev/null 2>&1", chdir: ROOT) - {ok: ok, detail: ok ? "all gems installed" : "run bin/setup (or bundle install)"} -}) - -git = check("git state") -orchestrator.add_task(git, agent: ->(_t) { - dirty = `git -C #{ROOT} status --porcelain`.lines.size - branch = `git -C #{ROOT} branch --show-current`.strip - {ok: true, detail: "on #{branch}, #{dirty} uncommitted change(s)"} -}) - -suite = check("test suite") -orchestrator.add_task(suite, agent: ->(_t) { - specs = Dir[File.join(ROOT, "spec", "**", "*_spec.rb")].size - {ok: specs.positive?, detail: "#{specs} spec files ready (bundle exec rake spec)"} -}) - -diagnosis = check("diagnosis") -orchestrator.add_task(diagnosis, needs: {ruby: ruby, bundle: bundle, git: git, suite: suite}, agent: ->(t) { - findings = { - "ruby" => t.needs.ruby, - "bundle" => t.needs.bundle, - "git" => t.needs.git, - "tests" => t.needs.suite - } - {healthy: findings.values.all? { |f| f[:ok] }, findings: findings} -}) - -result = orchestrator.execute_plan -verdict = result.results[diagnosis.id].output - -puts "SETUP DOCTOR" -puts -verdict[:findings].each do |name, finding| - puts format(" %s %-8s %s", finding[:ok] ? "ok " : "FIX", name, finding[:detail]) -end -puts -if verdict[:healthy] - puts "you're good. write code, not wiki pages." -else - puts "fix the FIX lines above, run me again." - exit 1 -end diff --git a/examples/shared_rate_limit.rb b/examples/shared_rate_limit.rb deleted file mode 100644 index 6235e81..0000000 --- a/examples/shared_rate_limit.rb +++ /dev/null @@ -1,90 +0,0 @@ -# frozen_string_literal: true - -# The Shared Rate Limit: two plans run concurrently in one reactor, but -# the API key they share allows only 3 requests in flight. A single -# Async::Semaphore, passed to both, enforces the provider's ceiling -# across plan boundaries - because rate limits belong to credentials, -# not to orchestrators. -# -# bundle exec ruby examples/shared_rate_limit.rb -# -# Runs offline; the proof is the high-water mark. - -require_relative "../lib/agentic" -require "async" - -API_CEILING = 3 - -# The shared credential: Agentic::RateLimit (this example's original -# feature request, granted) plus a call log for the interleaving proof -class RateLimitedApi - attr_reader :limit - - def initialize(ceiling) - @limit = Agentic::RateLimit.new(ceiling) - @calls = [] - end - - def high_water = @limit.high_water - - def call(plan, name, latency) - @limit.acquire do - @calls << "#{plan}/#{name}" - sleep(latency) - "#{name}:ok" - end - end - - def interleaved? - plans = @calls.map { |c| c.split("/").first } - plans.uniq.size > 1 && plans != plans.sort - end -end - -def build_plan(label, task_count, api) - orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 10) - task_count.times do |i| - orchestrator.add_task(Agentic::Task.new( - description: "#{label}-#{i}", - agent_spec: {"name" => label, "instructions" => "call the API"}, - payload: 0.04 + (i % 3) * 0.02 - ), agent: ->(t) { api.call(label, t.description, t.payload) }) - end - orchestrator -end - -api = RateLimitedApi.new(API_CEILING) - -puts "SHARED RATE LIMIT: two plans, one credential, ceiling #{API_CEILING}" -puts - -wall = nil -Sync do - ingest = build_plan("ingest", 8, api) - enrich = build_plan("enrich", 8, api) - - started = Process.clock_gettime(Process::CLOCK_MONOTONIC) - - # Both plans run as siblings in this reactor; each would happily use - # 10 slots, but the shared semaphore is the credential's law - plans = [ingest, enrich].map do |orchestrator| - Async { orchestrator.execute_plan } - end - results = plans.map(&:wait) - wall = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started - - results.each_with_index do |result, i| - puts format(" plan %d: %s, %d tasks", i + 1, result.status, - result.results.count { |_, r| r.successful? }) - end -end - -puts -puts format(" wall time: %dms for 16 calls of ~60ms each", wall * 1000) -puts format(" in-flight high-water mark: %d (ceiling %d) %s", - api.high_water, API_CEILING, (api.high_water <= API_CEILING) ? "- held" : "- BREACHED") -puts " calls interleaved across plans: #{api.interleaved? ? "yes" : "no"}" -puts -puts "each orchestrator had concurrency_limit 10; the credential said 3." -puts "the credential won, across both plans, because the semaphore lives" -puts "with the resource it protects - not with either scheduler." diff --git a/examples/stampede_sim.rb b/examples/stampede_sim.rb deleted file mode 100644 index 9e388c9..0000000 --- a/examples/stampede_sim.rb +++ /dev/null @@ -1,87 +0,0 @@ -# frozen_string_literal: true - -# The Stampede Simulator: twenty workers hit a hiccuping upstream, all -# fail at once, all retry. With jitter OFF they come back as a single -# synchronized herd - the second outage. With jitter ON (the default, -# as of this round) the herd spreads. The histogram is the argument. -# -# bundle exec ruby examples/stampede_sim.rb [seed] -# -# Runs offline; the upstream is a shared counter with feelings. - -require_relative "../lib/agentic" - -# 40 scripted failures are the point, not news -Agentic.logger.level = :fatal - -WORKERS = 20 -BACKOFF = 0.12 -BUCKET_MS = 20 - -def run_stampede(jitter:, seed:) - srand(seed) # jitter uses Kernel#rand; seed it for a fair comparison - started = Process.clock_gettime(Process::CLOCK_MONOTONIC) - retry_arrivals = [] - attempts = Hash.new(0) - - orchestrator = Agentic::PlanOrchestrator.new( - concurrency_limit: WORKERS, - retry_policy: { - max_retries: 2, - retryable_errors: ["RuntimeError"], - backoff_strategy: :constant, - backoff_constant: BACKOFF, - backoff_jitter: jitter - } - ) - - WORKERS.times do |i| - task = Agentic::Task.new( - description: "worker-#{i}", - agent_spec: {"name" => "worker", "instructions" => "call upstream"} - ) - orchestrator.add_task(task, agent: ->(t) { - attempts[t.description] += 1 - if attempts[t.description] == 1 - raise "upstream hiccup" # everyone fails together at t=0 - end - - retry_arrivals << Process.clock_gettime(Process::CLOCK_MONOTONIC) - started - :ok - }) - end - - orchestrator.execute_plan - retry_arrivals -end - -def histogram(arrivals) - buckets = Hash.new(0) - arrivals.each { |t| buckets[(t * 1000 / BUCKET_MS).floor * BUCKET_MS] += 1 } - buckets.sort.map { |bucket_ms, count| - format(" %4d-%4dms %-20s %d", bucket_ms, bucket_ms + BUCKET_MS, "#" * count, count) - }.join("\n") -end - -seed = (ARGV.first || 20260707).to_i - -puts "STAMPEDE SIMULATOR: #{WORKERS} workers, all failing at t=0, " \ - "#{(BACKOFF * 1000).round}ms constant backoff" -puts - -herd = run_stampede(jitter: false, seed: seed) -puts " jitter OFF (retry arrivals per #{BUCKET_MS}ms bucket):" -puts histogram(herd) -peak_off = herd.group_by { |t| (t * 1000 / BUCKET_MS).floor }.values.map(&:size).max - -spread = run_stampede(jitter: true, seed: seed) -puts -puts " jitter ON - the default (same workers, same failure, same seed):" -puts histogram(spread) -peak_on = spread.group_by { |t| (t * 1000 / BUCKET_MS).floor }.values.map(&:size).max - -puts -puts format(" peak herd size per bucket: %d without jitter -> %d with", peak_off, peak_on) -puts -puts "twenty synchronized retries is a second outage wearing a recovery" -puts "costume. jitter is on by default now; the upstream sends its thanks." diff --git a/examples/standup_digest.rb b/examples/standup_digest.rb deleted file mode 100644 index 51d145d..0000000 --- a/examples/standup_digest.rb +++ /dev/null @@ -1,75 +0,0 @@ -# frozen_string_literal: true - -# The Standup Digest: three collectors gather from the repo in -# parallel - recent commits, TODO debt, test suite shape - and a writer -# task fans their outputs in through the dependency pipe and publishes -# the digest nobody has to attend a meeting for. -# -# bundle exec ruby examples/standup_digest.rb -# -# Runs offline against the current git repo. The meeting is cancelled. - -require_relative "../lib/agentic" - -ROOT = File.expand_path("..", __dir__) - -def repo_task(description, payload = nil) - Agentic::Task.new( - description: description, - agent_spec: {"name" => description, "instructions" => "Collect facts"}, - payload: payload - ) -end - -orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 3) - -commits = repo_task("recent commits") -orchestrator.add_task(commits, agent: ->(_t) { - log = `git -C #{ROOT} log --oneline -12 --pretty=format:"%s"`.lines.map(&:strip) - themes = log.group_by { |line| line[/\A(\w+)(?:\(|:)/, 1] || "misc" } - {count: log.size, themes: themes.transform_values(&:size), latest: log.first} -}) - -debt = repo_task("todo debt") -orchestrator.add_task(debt, agent: ->(_t) { - hits = Dir[File.join(ROOT, "lib", "**", "*.rb")].flat_map { |path| - File.readlines(path, encoding: "UTF-8").each_with_index.select { |line, _| line =~ /#.*(TODO|FIXME|HACK)/ } - .map { |line, i| "#{path.delete_prefix("#{ROOT}/")}:#{i + 1} #{line.strip.sub(/\A#\s*/, "")}" } - } - {count: hits.size, items: hits.first(5)} -}) - -tests = repo_task("test suite shape") -orchestrator.add_task(tests, agent: ->(_t) { - spec_files = Dir[File.join(ROOT, "spec", "**", "*_spec.rb")] - examples = spec_files.sum { |f| File.read(f, encoding: "UTF-8").scan(/^\s*it\s/).size } - {files: spec_files.size, examples: examples} -}) - -digest = repo_task("digest") -orchestrator.add_task(digest, [commits, debt, tests], agent: ->(t) { - shipped = t.output_of(commits) - owed = t.output_of(debt) - suite = t.output_of(tests) - - lines = [] - lines << "STANDUP DIGEST" - lines << "" - lines << "shipped: #{shipped[:count]} recent commits " \ - "(#{shipped[:themes].map { |k, v| "#{v} #{k}" }.join(", ")})" - lines << " latest: #{shipped[:latest]}" - lines << "" - lines << "owed: #{owed[:count]} TODO/FIXME/HACK markers in lib/" - owed[:items].each { |item| lines << " - #{item}" } - lines << " (clean!)" if owed[:count].zero? - lines << "" - lines << "guarded by: #{suite[:examples]} examples across #{suite[:files]} spec files" - lines.join("\n") -}) - -result = orchestrator.execute_plan - -puts result.results[digest.id].output -puts -puts "(three collectors in parallel + one writer, #{result.status} " \ - "in #{(result.execution_time * 1000).round}ms)" diff --git a/examples/state_machine.rb b/examples/state_machine.rb deleted file mode 100644 index e617dbf..0000000 --- a/examples/state_machine.rb +++ /dev/null @@ -1,88 +0,0 @@ -# frozen_string_literal: true - -# The Contract State Machine: each transition is a capability whose -# guard is not an if-statement but an enum predicate on its declared -# contract (new this round). An illegal transition doesn't fail - it -# never types-checks in the first place, and the violation names the -# states that WOULD have been legal. -# -# bundle exec ruby examples/state_machine.rb -# -# Runs offline. Watch "deliver" bounce off a cart-state order. - -require_relative "../lib/agentic" - -# from: is the transition guard, expressed as a contract enum -TRANSITIONS = { - "place" => {from: %w[cart], to: "placed"}, - "ship" => {from: %w[placed], to: "shipped"}, - "deliver" => {from: %w[shipped], to: "delivered"}, - "cancel" => {from: %w[cart placed], to: "canceled"} -}.freeze - -TRANSITIONS.each do |event, rule| - spec = Agentic::CapabilitySpecification.new( - name: event, - description: "Transition an order via #{event}", - version: "1.0.0", - inputs: { - order_id: {type: "string", required: true, non_empty: true}, - state: {type: "string", required: true, enum: rule[:from]} - }, - outputs: {state: {type: "string", required: true, enum: [rule[:to]]}} - ) - Agentic.register_capability(spec, Agentic::CapabilityProvider.new( - capability: spec, - implementation: ->(inputs) { {state: rule[:to]} } - )) -end - -# The machine: current state + registry lookup. No case statement, -# no transition table at runtime - the contracts ARE the table. -class Order - attr_reader :id, :state, :history - - def initialize(id) - @id = id - @state = "cart" - @history = ["cart"] - end - - def fire(event) - provider = Agentic::AgentCapabilityRegistry.instance.get_provider(event) or - return [:unknown_event, event] - - result = provider.execute(order_id: @id, state: @state) - @state = result[:state] - @history << @state - [:ok, @state] - rescue Agentic::Errors::ValidationError => e - # The violation now carries the contract's expectation - no - # side-channel lookup into the transition table needed - allowed = e.expectations.dig(:state, :enum) || [] - [:illegal, "cannot #{event} from '#{@state}' (legal from: #{allowed.join(", ")}) - #{e.violations.keys.join(", ")} violated"] - end -end - -order = Order.new("ord-7") - -SCRIPT = %w[deliver place place ship cancel deliver].freeze - -puts "CONTRACT STATE MACHINE: order #{order.id} begins in 'cart'" -puts -SCRIPT.each do |event| - verdict, detail = order.fire(event) - case verdict - when :ok then puts format(" %-8s -> now '%s'", event, detail) - when :illegal then puts format(" %-8s XX %s", event, detail) - when :unknown_event then puts format(" %-8s ?? no such transition", event) - end -end - -puts -puts "journey: #{order.history.join(" -> ")}" -puts -puts "the machine has no case statement and no runtime transition table:" -puts "each event's contract declares its legal source states as an enum," -puts "and the validator enforces the topology. illegal moves are type" -puts "errors with the legal alternatives in the message." diff --git a/examples/stdlib_census.rb b/examples/stdlib_census.rb deleted file mode 100644 index fd232a5..0000000 --- a/examples/stdlib_census.rb +++ /dev/null @@ -1,89 +0,0 @@ -# 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/telemetry_bus.rb b/examples/telemetry_bus.rb deleted file mode 100644 index 596110f..0000000 --- a/examples/telemetry_bus.rb +++ /dev/null @@ -1,108 +0,0 @@ -# frozen_string_literal: true - -# The Telemetry Bus: lifecycle hooks are callbacks - one producer, -# one consumer, coupled at configuration time. A telemetry bus -# inverts that: the orchestrator emits NAMED EVENTS into a bus, and -# any number of handlers attach, detach, and crash independently. -# The producer never learns who is listening. This is the :telemetry -# pattern Elixir converged on, because every library inventing its -# own instrumentation callbacks was the worse world. -# -# bundle exec ruby examples/telemetry_bus.rb -# -# Runs offline; three handlers listen, one detaches mid-flight. - -require_relative "../lib/agentic" - -Agentic.logger.level = :fatal - -# The bus: names, payloads, and isolation. A crashing handler is -# detached and reported - it never takes the plan down with it. -class TelemetryBus - def initialize - @handlers = Hash.new { |h, k| h[k] = {} } - end - - def attach(id, event, &handler) - @handlers[event][id] = handler - end - - def detach(id) - @handlers.each_value { |hs| hs.delete(id) } - end - - def execute(event, measurements, metadata = {}) - @handlers[event].each do |id, handler| - handler.call(measurements, metadata) - rescue => e - detach(id) - puts " [bus] handler #{id} crashed (#{e.class}) - detached, plan unharmed" - end - end -end - -BUS = TelemetryBus.new - -# The bridge: hooks in, events out. This is the ONLY place the -# orchestrator and the bus know about each other. -def telemetry_hooks(bus) - { - after_task_success: ->(task_id:, task:, result:, duration:) { - bus.execute([:agentic, :task, :success], {duration: duration}, {task: task.description}) - }, - after_task_failure: ->(task_id:, task:, failure:, duration:) { - bus.execute([:agentic, :task, :failure], {duration: duration}, {task: task.description, type: failure.type}) - }, - plan_completed: ->(plan_id:, status:, execution_time:, tasks:, results:) { - bus.execute([:agentic, :plan, :completed], {execution_time: execution_time}, {status: status}) - } - } -end - -# Handler 1: a metrics counter - knows nothing about logging or tracing -metrics = Hash.new(0) -BUS.attach(:metrics, [:agentic, :task, :success]) { |m, _| metrics[:tasks] += 1 } -BUS.attach(:metrics2, [:agentic, :task, :failure]) { |m, _| metrics[:failures] += 1 } - -# Handler 2: a slow-task tracer - only speaks when something is worth saying -BUS.attach(:tracer, [:agentic, :task, :success]) do |measurements, metadata| - puts " [trace] SLOW: #{metadata[:task]} took #{(measurements[:duration] * 1000).round}ms" if measurements[:duration] > 0.05 -end - -# Handler 3: a fragile exporter someone deployed on a Friday -BUS.attach(:exporter, [:agentic, :task, :success]) do |_m, metadata| - raise IOError, "export endpoint down" if metadata[:task] == "enrich" -end - -def run_plan(bus) - orchestrator = Agentic::PlanOrchestrator.new(lifecycle_hooks: telemetry_hooks(bus)) - fetch = Agentic::Task.new(description: "fetch", agent_spec: {"name" => "w", "instructions" => "w"}) - enrich = Agentic::Task.new(description: "enrich", agent_spec: {"name" => "w", "instructions" => "w"}) - publish = Agentic::Task.new(description: "publish", agent_spec: {"name" => "w", "instructions" => "w"}) - orchestrator.add_task(fetch, agent: ->(_t) { sleep(0.01) }) - orchestrator.add_task(enrich, [fetch], agent: ->(_t) { sleep(0.08) }) - orchestrator.add_task(publish, [enrich], agent: ->(_t) { sleep(0.01) }) - orchestrator.execute_plan -end - -puts "TELEMETRY BUS (three handlers, one bridge, zero coupling)" -puts -puts " run 1 - all handlers attached:" -run_plan(BUS) -puts " [metrics] #{metrics.inspect}" -puts - -puts " run 2 - tracer detached at runtime (ops got tired of it):" -BUS.detach(:tracer) -run_plan(BUS) -puts " [metrics] #{metrics.inspect}" -puts -puts " the orchestrator emitted the same events both runs - it cannot" -puts " tell that the tracer left or that the exporter crashed, and" -puts " that ignorance is the feature. hooks couple one producer to" -puts " one consumer at configuration time; a bus decouples N handlers" -puts " at RUNTIME, with isolation (the Friday exporter died alone)." -puts " event names are namespaced tuples, measurements are separated" -puts " from metadata - steal the whole :telemetry design; it was" -puts " right. the framework's hooks made the bridge ten lines, which" -puts " is exactly what hooks are for: being the floor a bus stands on." diff --git a/examples/telephone_game.rb b/examples/telephone_game.rb deleted file mode 100644 index 550968a..0000000 --- a/examples/telephone_game.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true - -# The telephone game: a rumor passes through five villagers, each of -# whom hears the previous version through the orchestrator's dependency -# pipe and repeats it... imperfectly. No shared state, no scroll passed -# around - the framework itself carries the whisper. -# -# bundle exec ruby examples/telephone_game.rb ["a rumor"] -# -# Runs offline. The bug is the feature. - -require_relative "../lib/agentic" - -QUIRKS = { - "the miller" => ->(s) { s.sub(/\bsaw\b/, "wrestled") }, - "the baker" => ->(s) { s.sub(/\ba (\w+)/, 'an enormous \1') }, - "the fisherman" => ->(s) { "#{s.chomp(".")}, down by the river" }, - "the innkeeper" => ->(s) { s.gsub(/\btwo\b/i, "twelve").gsub(/\bmice\b/, "wolves") }, - "the town crier" => ->(s) { "HEAR YE: #{s.upcase}!!" } -}.freeze - -rumor = ARGV.first || "Old Tom saw a cat chase two mice." - -orchestrator = Agentic::PlanOrchestrator.new -tasks = [] -QUIRKS.each_key do |villager| - task = Agentic::Task.new( - description: villager, - agent_spec: {"name" => villager, "instructions" => "Repeat what you heard"}, - payload: rumor - ) - orchestrator.add_task(task, tasks.empty? ? [] : [tasks.last], agent: ->(t) { - heard = t.dependency_outputs.values.first || t.payload - QUIRKS.fetch(t.description).call(heard) - }) - tasks << task -end - -result = orchestrator.execute_plan - -puts "the rumor: \"#{rumor}\"" -puts -tasks.each do |task| - puts format("%-14s heard it as: %s", task.description, result.results[task.id].output) -end -puts -puts "(#{result.status} in #{(result.execution_time * 1000).round}ms - " \ - "the whisper traveled through #{tasks.size} villagers)" diff --git a/examples/tenant_shards.rb b/examples/tenant_shards.rb deleted file mode 100644 index 0900180..0000000 --- a/examples/tenant_shards.rb +++ /dev/null @@ -1,89 +0,0 @@ -# frozen_string_literal: true - -# Tenant Shards: at scale, "the plan" becomes "the plan, per shard" - -# same pipeline, isolated blast radius. Each shard gets its own -# journal (its own recovery story) and its own rate limit (its own -# noisy neighbor containment), under one control plane. Shard 2 -# crashes mid-run; the rerun resumes ONLY what shard 2 didn't finish, -# because recovery, like everything at scale, must be per-shard. -# -# bundle exec ruby examples/tenant_shards.rb -# -# Runs offline; the crash is scripted, the resume is real. - -require_relative "../lib/agentic" -require "tmpdir" - -Agentic.logger.level = :fatal - -SHARDS = { - "shard_1" => %w[acme globex], - "shard_2" => %w[initech umbrella hooli], - "shard_3" => %w[wonka] -}.freeze -PIPELINE = %w[extract transform load].freeze - -def journal_path(shard) = File.join(Dir.tmpdir, "agentic_#{shard}.journal.jsonl") - -# One shard's run: its own journal, its own limiter, resume-aware -def run_shard(shard, tenants, crash_at: nil) - journal = Agentic::ExecutionJournal.new(path: journal_path(shard)) - done = Agentic::ExecutionJournal.replay(path: journal_path(shard)) - limiter = Agentic::RateLimit.new(2) # per-shard: a hot shard can't starve the others - - orchestrator = Agentic::PlanOrchestrator.new( - concurrency_limit: 2, lifecycle_hooks: journal.lifecycle_hooks, - retry_policy: {max_retries: 0, retryable_errors: []} - ) - ran = 0 - skipped = 0 - tenants.each do |tenant| - previous = nil - PIPELINE.each do |step| - name = "#{tenant}:#{step}" - if done.completed?(name) - skipped += 1 - next - end - - task = Agentic::Task.new(description: name, agent_spec: {"name" => name, "instructions" => "run"}) - orchestrator.add_task(task, previous ? [previous] : [], agent: ->(t) { - raise "power cut" if crash_at == t.description - - limiter.acquire { sleep(0.002) } - ran += 1 - :ok - }) - previous = task - end - end - status = orchestrator.execute_plan.status - [status, ran, skipped] -end - -puts "TENANT SHARDS (#{SHARDS.size} shards, #{SHARDS.values.sum(&:size)} tenants, pipeline: #{PIPELINE.join(" -> ")})" -puts -SHARDS.each_key { |shard| File.delete(journal_path(shard)) if File.exist?(journal_path(shard)) } - -puts " run 1 - shard_2 loses power mid-tenant:" -SHARDS.each do |shard, tenants| - crash = (shard == "shard_2") ? "umbrella:transform" : nil - status, ran, = run_shard(shard, tenants, crash_at: crash) - puts format(" %-9s %-16s %2d steps ran%s", shard, status, ran, - crash ? " <- crashed at #{crash}" : "") -end -puts - -puts " run 2 - control plane reruns everything; journals decide what that means:" -SHARDS.each do |shard, tenants| - status, ran, skipped = run_shard(shard, tenants) - puts format(" %-9s %-16s %2d steps ran, %2d skipped (already journaled)", shard, status, ran, skipped) -end -puts -puts " the rerun was issued fleet-wide - the control plane doesn't" -puts " track which shard crashed, and shouldn't have to. shard 1 and 3" -puts " skipped everything (their journals proved completion); shard 2" -puts " re-ran only from the crash point. that's the sharding contract:" -puts " one plan definition, N isolated executions, N recovery stories," -puts " N rate limits - and a blast radius that ends at the shard" -puts " boundary. scale isn't a bigger machine; it's smaller failures." diff --git a/examples/threads_drill.rb b/examples/threads_drill.rb deleted file mode 100644 index 7004def..0000000 --- a/examples/threads_drill.rb +++ /dev/null @@ -1,93 +0,0 @@ -# frozen_string_literal: true - -# The Threads Drill: fibers are polite; threads are not. Everything -# in this gem that claims to be shared-safe gets hammered by real -# Ruby threads - the kind that run truly parallel on JRuby, where -# there is no GVL to be your accidental bodyguard. The journal and -# registry hold. The windowed limiter's bookkeeping is the one to -# watch, and the drill says so out loud. -# -# bundle exec ruby examples/threads_drill.rb -# -# Runs offline; exits 1 if a guaranteed-safe structure corrupts. - -require_relative "../lib/agentic" -require "tmpdir" -require "json" - -Agentic.logger.level = :fatal - -THREADS = 8 -EVENTS = 150 -failures = 0 - -# --- drill 1: the journal under parallel writers -------------------------------- -path = File.join(Dir.tmpdir, "agentic_threads.journal.jsonl") -File.delete(path) if File.exist?(path) -journal = Agentic::ExecutionJournal.new(path: path) - -THREADS.times.map { |t| - Thread.new do - EVENTS.times { |i| journal.record(:task_succeeded, task_id: "t#{t}-#{i}", description: "t#{t}-#{i}", duration: 0.001, output: "x" * 64) } - end -}.each(&:join) - -lines = File.readlines(path) -parseable = lines.count do |line| - JSON.parse(line) - true -rescue JSON::ParserError - false -end -torn = lines.size - parseable -expected = THREADS * EVENTS -failures += 1 if lines.size != expected || torn.positive? -puts " drill 1 - journal, #{THREADS} threads x #{EVENTS} events:" -puts format(" %d/%d lines written, %d torn - %s", parseable, expected, torn, - (torn.zero? && lines.size == expected) ? "mutex + flock + fsync held" : "CORRUPTED") -puts - -# --- drill 2: the registry under concurrent registration ------------------------ -registry = Agentic::AgentCapabilityRegistry.instance -THREADS.times.map { |t| - Thread.new do - 50.times do |i| - spec = Agentic::CapabilitySpecification.new( - name: "cap-#{t}-#{i}", description: "x", version: "1.0.0", - inputs: {a: {type: "number", required: true}} - ) - Agentic.register_capability(spec, Agentic::CapabilityProvider.new(capability: spec, implementation: ->(inputs) { inputs })) - registry.get_provider("cap-#{t}-#{i}")&.execute(a: 1) - end - end -}.each(&:join) - -missing = THREADS.times.sum { |t| 50.times.count { |i| registry.get_provider("cap-#{t}-#{i}").nil? } } -failures += 1 if missing.positive? -puts " drill 2 - registry, #{THREADS} threads x 50 register+execute:" -puts format(" %d registrations lost - %s", missing, missing.zero? ? "registry held" : "RACE") -puts - -# --- drill 3: the windowed limiter's check-then-act ------------------------------ -# In round 11 this bookkeeping had no mutex and the drill called it -# "luck wearing a lab coat". The round-12 release put a real Mutex -# around the stamp dance, so the drill now ASSERTS what it could -# previously only observe. -limit = Agentic::RateLimit.new(50, per: 60) -admitted = THREADS.times.map { - Thread.new { 200.times.count { limit.try_acquire } } -}.map(&:value).sum - -failures += 1 if admitted != 50 -puts " drill 3 - windowed try_acquire, #{THREADS} threads x 200 attempts (ceiling 50):" -puts format(" admitted %d/50 - %s", admitted, - (admitted == 50) ? "the stamp bookkeeping holds a real Mutex now" : "OVER-ADMISSION - the lock is gone") -puts -puts " all three structures now hold under real threads for the right" -puts " reason: real locks (two Mutexes, flock, fsync), not scheduling" -puts " luck. this drill went from characterization to CERTIFICATION" -puts " when the round-12 release paid the limiter's lock debt - the" -puts " answer is now the same on every Ruby, which is the only kind" -puts " of thread-safety worth writing in a README." - -exit(failures.zero? ? 0 : 1) diff --git a/examples/three_shapes.rb b/examples/three_shapes.rb deleted file mode 100644 index 0be7b06..0000000 --- a/examples/three_shapes.rb +++ /dev/null @@ -1,73 +0,0 @@ -# frozen_string_literal: true - -# Three Shapes: the same six units of work arranged three ways - a -# chain, a star, and staged joins - then measured and critiqued. Design -# is choosing a shape ON PURPOSE, and purpose needs numbers. -# -# bundle exec ruby examples/three_shapes.rb -# -# Runs offline; each unit of work is 40ms of simulated IO. - -require_relative "../lib/agentic" - -UNIT = 0.04 -NAMES = %w[gather_a gather_b gather_c gather_d combine finish].freeze - -def build(shape) - orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 4) - tasks = NAMES.to_h do |name| - [name, Agentic::Task.new(description: name, agent_spec: {"name" => name, "instructions" => "work"})] - end - - deps = case shape - when :chain # one long line: simple to read, nothing overlaps - NAMES.each_cons(2).to_h { |a, b| [b, [a]] } - when :star # everything at once, one god join - {"combine" => %w[gather_a gather_b gather_c gather_d], "finish" => ["combine"]} - when :staged # balanced: pairs join, then join the joins - {"gather_c" => [], "gather_d" => [], - "combine" => %w[gather_a gather_b], - "finish" => %w[combine gather_c gather_d]} - end - - NAMES.each do |name| - orchestrator.add_task(tasks[name], (deps[name] || []).map { |d| tasks.fetch(d) }, - agent: ->(_t) { sleep(UNIT) || :ok }) - end - orchestrator -end - -# Structural facts, read straight off the graph -def shape_facts(graph) - dependencies = graph[:dependencies] - depth = {} - measure = ->(id) { depth[id] ||= 1 + (dependencies[id].map { |d| measure.call(d) }.max || 0) } - { - max_fan_in: dependencies.values.map(&:size).max, - depth: dependencies.keys.map { |id| measure.call(id) }.max - } -end - -puts "THREE SHAPES: six tasks x #{(UNIT * 1000).round}ms, concurrency 4" -puts -puts format(" %-8s %-10s %-8s %-9s %s", "shape", "wall", "depth", "max fan-in", "the trade") - -TRADES = { - chain: "trivially debuggable; pays full serial price", - star: "fastest; one join owns every failure mode", - staged: "nearly as fast; each join has one reason to wait" -}.freeze - -%i[chain star staged].each do |shape| - orchestrator = build(shape) - facts = shape_facts(orchestrator.graph) - result = orchestrator.execute_plan - puts format(" %-8s %4dms %-8d %-9d %s", - shape, result.execution_time * 1000, facts[:depth], facts[:max_fan_in], TRADES[shape]) -end - -puts -puts "none of these is wrong. the chain is right when the work is truly" -puts "sequential; the star when the join is trivial; the staged shape when" -puts "the join has judgment in it. what's wrong is not knowing which one" -puts "you built - and now the graph will tell you, in two numbers." diff --git a/examples/throughput_knee.rb b/examples/throughput_knee.rb deleted file mode 100644 index 2b095a5..0000000 --- a/examples/throughput_knee.rb +++ /dev/null @@ -1,83 +0,0 @@ -# frozen_string_literal: true - -# The Throughput Knee: sweep one limiter's ceiling from 1 to 8 against -# an upstream that quietly serializes above 4, and measure TWO clocks - -# service time (inside the upstream) and total time (including the wait -# for a slot). The knee is where they diverge: past it, you're not -# going faster, you're just queueing somewhere you can't see. -# -# bundle exec ruby examples/throughput_knee.rb -# -# Runs offline; the upstream's true parallelism is 4. - -require_relative "../lib/agentic" -require "async" - -TRUE_PARALLELISM = 4 -SERVICE_TIME = 0.02 -JOBS = 24 - -# The upstream: work beyond its parallelism doesn't fail, it queues - -# invisibly, on the server's side of the wire -server_in_flight = 0 -upstream = lambda do - server_in_flight += 1 - queued = [server_in_flight - TRUE_PARALLELISM, 0].max - sleep(SERVICE_TIME * (1 + queued)) - server_in_flight -= 1 -end - -limiter = Agentic::RateLimit.new(1) -rows = [] - -Sync do - (1..8).each do |ceiling| - limiter.resize(ceiling) - batch_started = Process.clock_gettime(Process::CLOCK_MONOTONIC) - service_times = [] - total_times = [] - - JOBS.times.map { - Async do - submitted = Process.clock_gettime(Process::CLOCK_MONOTONIC) - limiter.acquire do - admitted = Process.clock_gettime(Process::CLOCK_MONOTONIC) - upstream.call - finished = Process.clock_gettime(Process::CLOCK_MONOTONIC) - service_times << finished - admitted - total_times << finished - submitted - end - end - }.each(&:wait) - - elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - batch_started - rows << { - ceiling: ceiling, - throughput: JOBS / elapsed, - service_p50: service_times.sort[service_times.size / 2], - total_p50: total_times.sort[total_times.size / 2] - } - end -end - -puts "THROUGHPUT KNEE (#{JOBS} jobs per ceiling; upstream parallelism undisclosed)" -puts -puts format(" %-9s %-12s %-14s %-14s %s", "ceiling", "jobs/sec", "service p50", "total p50", "") -rows.each do |row| - bar = "#" * (row[:throughput] / 10).round - puts format(" %-9d %8.1f %8.1fms %8.1fms %s", - row[:ceiling], row[:throughput], row[:service_p50] * 1000, row[:total_p50] * 1000, bar) -end - -# The knee: the last ceiling where throughput still grew meaningfully -knee = rows.each_cons(2).find { |a, b| b[:throughput] < a[:throughput] * 1.08 }&.first || rows.last -puts -puts " the knee is at ceiling #{knee[:ceiling]}. below it, more lanes bought more" -puts " jobs/sec. above it, throughput didn't plateau - it FELL, because" -puts " overload slows everyone, not just the excess. and SERVICE time rose:" -puts " the upstream only runs #{TRUE_PARALLELISM} at once, so lanes 5-8 didn't add" -puts " parallelism, they just moved the queue from your limiter (where" -puts " total p50 measures it) onto the server (where service p50 hides" -puts " it, and where you usually can't see it at all). watch both clocks:" -puts " when raising your ceiling raises the SERVER's latency, you've" -puts " found their ceiling, and the polite move is to stop pushing." diff --git a/examples/tty_status.rb b/examples/tty_status.rb deleted file mode 100644 index 1cf8caf..0000000 --- a/examples/tty_status.rb +++ /dev/null @@ -1,108 +0,0 @@ -# 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/typed_pipeline.rb b/examples/typed_pipeline.rb deleted file mode 100644 index 8d2fc81..0000000 --- a/examples/typed_pipeline.rb +++ /dev/null @@ -1,107 +0,0 @@ -# frozen_string_literal: true - -# A typed ETL pipeline: extract -> transform -> load, each stage a -# capability with a declared contract, composed into one capability via -# the registry. Bad data doesn't flow downstream - it's stopped at the -# boundary that first notices, with every violation named. -# -# bundle exec ruby examples/typed_pipeline.rb -# -# Runs offline. The interesting output is the FAILED record: watch -# where it stops and what the error says. - -require_relative "../lib/agentic" - -registry = Agentic::AgentCapabilityRegistry.instance - -RAW_EVENTS = [ - %(id=ev-1|user=ada@example.com|amount_cents=4200|currency=USD), - %(id=ev-2|user=grace@example.com|amount_cents=1850|currency=EUR), - %(id=ev-3|user=|amount_cents=not-a-number|currency=USD), - %(id=ev-4|user=joan@example.com|amount_cents=99900|currency=USD) -].freeze - -def capability(name, inputs:, outputs:, &impl) - spec = Agentic::CapabilitySpecification.new( - name: name, description: name, version: "1.0.0", - inputs: inputs, outputs: outputs - ) - Agentic.register_capability( - spec, Agentic::CapabilityProvider.new(capability: spec, implementation: impl) - ) -end - -# Extract: raw line in, loosely-typed fields out. Extraction is -# forgiving - its job is parsing, not judgment. -capability("extract", - inputs: {raw: {type: "string", required: true}}, - outputs: {fields: {type: "object", required: true}}) do |input| - fields = input[:raw].split("|").to_h { |pair| pair.split("=", 2) } - {fields: fields} -end - -# Transform: loose fields in, STRICT record out. This is the boundary -# where "data" becomes "facts" - the contract insists amount is a -# number and user is present. -capability("transform", - inputs: {fields: {type: "object", required: true}}, - outputs: { - id: {type: "string", required: true}, - user: {type: "string", required: true}, - amount_cents: {type: "number", required: true}, - currency: {type: "string", required: true} - }) do |input| - fields = input[:fields] - amount = begin - Integer(fields["amount_cents"]) - rescue ArgumentError, TypeError - fields["amount_cents"] # let the output contract catch it, by name - end - user = fields["user"].to_s.empty? ? nil : fields["user"] - {id: fields["id"], user: user, amount_cents: amount, currency: fields["currency"]}.compact -end - -# Load: strict record in, ledger entry out -LEDGER = Hash.new(0) -capability("load", - inputs: { - id: {type: "string", required: true}, - user: {type: "string", required: true}, - amount_cents: {type: "number", required: true}, - currency: {type: "string", required: true} - }, - outputs: {posted: {type: "string", required: true}}) do |record| - LEDGER[record[:currency]] += record[:amount_cents] - {posted: record[:id]} -end - -# Compose the three stages into one pipeline capability -registry.compose( - "etl_pipeline", - "Extract, transform, and load one raw event", - "1.0.0", - [{name: "extract", version: "1.0.0"}, - {name: "transform", version: "1.0.0"}, - {name: "load", version: "1.0.0"}], - lambda do |providers, inputs| - extract, transform, load = providers - record = transform.execute(extract.execute(raw: inputs[:raw])) - load.execute(record) - end -) - -pipeline = registry.get_provider("etl_pipeline") - -puts "PIPELINE RUN (#{RAW_EVENTS.size} raw events)" -puts -RAW_EVENTS.each do |raw| - posted = pipeline.execute(raw: raw) - puts " POSTED #{posted[:posted]}" -rescue Agentic::Errors::ValidationError => e - puts " REJECTED #{raw[/id=([^|]+)/, 1]} at the '#{e.capability}' #{e.kind} boundary:" - e.violations.each { |key, messages| puts " #{key}: #{Array(messages).join(", ")}" } -end - -puts -puts "LEDGER (only facts made it this far):" -LEDGER.each { |currency, cents| puts format(" %s %10.2f", currency, cents / 100.0) } diff --git a/examples/unix_workers.rb b/examples/unix_workers.rb deleted file mode 100644 index b19bd5b..0000000 --- a/examples/unix_workers.rb +++ /dev/null @@ -1,103 +0,0 @@ -# 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/variance_detective.rb b/examples/variance_detective.rb deleted file mode 100644 index 0bed84d..0000000 --- a/examples/variance_detective.rb +++ /dev/null @@ -1,73 +0,0 @@ -# frozen_string_literal: true - -# The Variance Detective: ten journaled runs of the same plan, then a -# hunt for the task whose p90/p50 ratio betrays it. Averages hide -# flakiness; percentile spreads name it. Uses duration_percentile -# (new this round) over the journal's accumulated samples. -# -# bundle exec ruby examples/variance_detective.rb [seed] -# -# Runs offline; one task is scripted-flaky, the others honest. - -require_relative "../lib/agentic" -require "tmpdir" - -seed = (ARGV.first || 20260707).to_i -rng = Random.new(seed) - -JOURNAL = File.join(Dir.tmpdir, "agentic_variance.journal.jsonl") -File.delete(JOURNAL) if File.exist?(JOURNAL) -journal = Agentic::ExecutionJournal.new(path: JOURNAL) - -# Steady tasks jitter a little; the flaky one occasionally stalls -PROFILE = { - "fetch:profile" => ->(rng) { 0.020 + rng.rand * 0.004 }, - "fetch:permissions" => ->(rng) { 0.015 + rng.rand * 0.003 }, - "render:dashboard" => ->(rng) { 0.030 + rng.rand * 0.005 }, - "fetch:recommendations" => ->(rng) { - (rng.rand < 0.3) ? 0.080 + rng.rand * 0.02 : 0.018 + rng.rand * 0.004 - } -}.freeze - -RUNS = 20 -RUNS.times do - orchestrator = Agentic::PlanOrchestrator.new( - concurrency_limit: 4, lifecycle_hooks: journal.lifecycle_hooks - ) - PROFILE.each do |name, latency| - orchestrator.add_task(Agentic::Task.new( - description: name, agent_spec: {"name" => name, "instructions" => "serve"}, - payload: latency - ), agent: ->(t) { sleep(t.payload.call(rng)) || :ok }) - end - orchestrator.execute_plan -end - -# --- the investigation -------------------------------------------------------- -state = Agentic::ExecutionJournal.replay(path: JOURNAL) - -puts "VARIANCE DETECTIVE (#{RUNS} journaled runs, seed #{seed})" -puts -puts format(" %-24s %7s %7s %7s %9s", "task", "p50", "p90", "worst", "p90/p50") - -suspects = [] -PROFILE.each_key do |name| - p50 = state.duration_percentile(name, 50) - p90 = state.duration_percentile(name, 90) - worst = state.duration_percentile(name, 100) - ratio = p90 / p50 - flaky = ratio > 2.0 - suspects << name if flaky - puts format(" %-24s %5.0fms %5.0fms %5.0fms %8.1fx %s", - name, p50 * 1000, p90 * 1000, worst * 1000, ratio, flaky ? "<- SUSPECT" : "") -end - -puts -if suspects.any? - puts " verdict: #{suspects.join(", ")} runs fine at the median and" - puts " terribly at the tail - the signature of flakiness (cold caches," - puts " lock contention, a retried upstream). an AVERAGE would have" - puts " reported ~#{(state.duration_percentile(suspects.first, 50) * 1000 * 1.4).round}ms and told you nothing was wrong." -else - puts " no suspects. suspiciously well-behaved - check the seed." -end diff --git a/examples/write_path_profile.rb b/examples/write_path_profile.rb deleted file mode 100644 index b06eac6..0000000 --- a/examples/write_path_profile.rb +++ /dev/null @@ -1,80 +0,0 @@ -# frozen_string_literal: true - -# The Write Path Profile: everyone's first instinct about a slow -# journal is "switch JSON libraries". Before holding that opinion, -# weigh each layer of the write separately - serialize, write, flush, -# fsync - because optimization budgets get spent where the profiler -# points or they get wasted. Spoiler: the disk's honesty is the -# product, and it is also the bill. -# -# bundle exec ruby examples/write_path_profile.rb -# -# Runs offline; timings are real syscalls on this machine. - -require_relative "../lib/agentic" -require "tmpdir" -require "json" - -EVENTS = 300 -PAYLOAD = {event: "task_succeeded", task_id: "t-123", description: "sync:orders", - duration: 0.412, output: "x" * 200}.freeze - -def bench(events = EVENTS) - t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) - events.times { |i| yield(i) } - (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) / events * 1_000_000 # us/event -end - -dir = Dir.mktmpdir("agentic_write_path") - -# Layer 1: serialization only -serialize = bench { JSON.generate(PAYLOAD) } - -# Layer 2: + buffered write (kernel may keep it in page cache forever) -buffered_file = File.open(File.join(dir, "buffered.jsonl"), "a") -buffered = bench { buffered_file.puts(JSON.generate(PAYLOAD)) } - -# Layer 3: + flush (userland buffer to kernel, still not durable) -flushed_file = File.open(File.join(dir, "flushed.jsonl"), "a") -flushed = bench { |i| - flushed_file.puts(JSON.generate(PAYLOAD)) - flushed_file.flush -} - -# Layer 4: the real thing - open, flock, puts, flush, FSYNC per event -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 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 -rows = { - "JSON.generate only" => serialize, - "+ buffered write" => buffered, - "+ flush to kernel" => flushed, - "journal.record (flock+fsync)" => real, - "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) -end - -puts -json_share = serialize / real * 100 -puts format(" the ledger: serialization is %.1f%% of the real write. swapping", json_share) -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 - 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."