The Borrow Checker
The Borrow Checker: I brought Rust's ownership model to a language that never asked for it, and I regret nothing. Task outputs get ownership semantics: a value can be MOVED to exactly one consumer (who may then mutate it - ownership is mutation rights), or BORROWED by any number of readers (who receive it deep-frozen, because a shared reference you can mutate is just a bug with extra steps). Double moves are rejected at ASSEMBLY time with a proper error[E0382], because the …
Testing & Verification
Round 19
Steve Klabnik
exit 0
bundle exec ruby examples/borrow_checker.rb
a real captured run
THE BORROW CHECKER (fighting for memory safety in a language with no memory)
scene 1 - one move (enrich) + one borrow (audit): borrow check PASSES
the owner mutated freely: records grew to 3 (ownership is mutation rights)
auditor's mutation attempt stopped by FrozenError (the borrow held)
scene 2 - both consumers demand a move. the compiler(ish) speaks:
error[E0382]: use of moved value: `fetch.output`
--> plan assembly
note: value moved to `enrich` here
note: value used again by `audit` after move
help: consider borrowing instead: mode: :borrow
nothing executed. the crime was PREVENTED, not avenged - that's the
entire difference between a checker and a postmortem.
what ports and what doesn't: the MODEL ports beautifully - move
semantics are just 'exactly one consumer may treat this as its
own', borrows are 'everyone else gets it deep-frozen', and the
aliasing-XOR-mutation rule is enforceable with a Struct, a Hash,
and Marshal. what doesn't port is the TIMING: Rust rejects at
compile time; here 'assembly time' plays the part, which is
still before anything RUNS, which is the part that matters.
the deep freeze on borrows is the honest cost - in Ruby, the
only reference nobody can mutate is a frozen copy. fearless
concurrency starts with knowing whose data it is; turns out you
can know that in any language, if you're willing to write it
down at the seam.
source
# frozen_string_literal: true # The Borrow Checker: I brought Rust's ownership model to a language # that never asked for it, and I regret nothing. Task outputs get # ownership semantics: a value can be MOVED to exactly one consumer # (who may then mutate it - ownership is mutation rights), or # BORROWED by any number of readers (who receive it deep-frozen, # because a shared reference you can mutate is just a bug with # extra steps). Double moves are rejected at ASSEMBLY time with a # proper error[E0382], because the whole point of a borrow checker # is that the crime is prevented, not avenged. # # bundle exec ruby examples/borrow_checker.rb # # Runs offline; exits 1 unless ownership is actually enforced. require class="s">"bundler/setup" require class="s">"agentic" Agentic.logger.level = class="y">:fatal class BorrowChecker Claim = Struct.new(class="y">:consumer, class="y">:mode) def initialize @claims = Hash.new { |h, k| h[k] = [] } end def deep_freeze(obj) case obj when Hash then obj.each { |k, v| deep_freeze(k) deep_freeze(v) }.freeze when Array then obj.each { |v| deep_freeze(v) }.freeze else obj.freeze end end # Declare intent at assembly time - this is the "compile" phase def claim(producer, consumer, mode) @claims[producer] << Claim.new(consumer, mode) end # Rust rule, one graph up: any number of borrows, at most one move def check! errors = [] @claims.each do |producer, claims| moves = claims.select { |c| c.mode == class="y">:move } next unless moves.size > 1 errors << <<~ERR error[E0382]: use of moved value: `#{producer}.output` --> plan assembly note: value moved to `#{moves[0].consumer}` here note: value used again by `#{moves[1].consumer}` after move help: consider borrowing instead: mode: class="y">:borrow ERR end errors end def deliver(value, mode) (mode == class="y">:borrow) ? deep_freeze(Marshal.load(Marshal.dump(value))) : value end end def build_pipeline(second_consumer_mode:) checker = BorrowChecker.new orchestrator = Agentic:class="y">:PlanOrchestrator.new(concurrency_limit: 3) incidents = [] fetch = Agentic:class="y">:Task.new(description: class="s">"fetch", agent_spec: {class="s">"name" => class="s">"fetch", class="s">"instructions" => class="s">"w"}) orchestrator.add_task(fetch, agent: ->(_t) { {records: [class="s">"alpha", class="s">"beta"], count: 2} }) enrich = Agentic:class="y">:Task.new(description: class="s">"enrich", agent_spec: {class="s">"name" => class="s">"enrich", class="s">"instructions" => class="s">"w"}) checker.claim(class="s">"fetch", class="s">"enrich", class="y">:move) orchestrator.add_task(enrich, [fetch], agent: ->(t) { owned = checker.deliver(t.previous_output, class="y">:move) owned[class="y">:records] << class="s">"gamma" # the owner may mutate; that's what owning MEANS owned.merge(enriched: true) }) audit = Agentic:class="y">:Task.new(description: class="s">"audit", agent_spec: {class="s">"name" => class="s">"audit", class="s">"instructions" => class="s">"w"}) checker.claim(class="s">"fetch", class="s">"audit", second_consumer_mode) orchestrator.add_task(audit, [fetch], agent: ->(t) { borrowed = checker.deliver(t.previous_output, second_consumer_mode) begin borrowed[class="y">:records] << class="s">"CORRUPTION" # the auditor turns to crime incidents << class="s">"mutation of a borrow SUCCEEDED (checker asleep)" rescue FrozenError incidents << class="s">"auditor's mutation attempt stopped by FrozenError (the borrow held)" end {records_seen: borrowed[class="y">:records].size} }) [checker, orchestrator, incidents, {fetch: fetch, enrich: enrich, audit: audit}] end puts class="s">"THE BORROW CHECKER (fighting for memory safety in a language with no memory)" puts # --- scene 1: a well-typed plan - one move, one borrow, one attempted crime --------- checker, orchestrator, incidents, tasks = build_pipeline(second_consumer_mode: class="y">:borrow) compile_errors = checker.check! result = orchestrator.execute_plan enriched = result.task_result(tasks[class="y">:enrich].id).output puts class="s">" scene 1 - one move (enrich) + one borrow (audit): #{compile_errors.empty? ? "borrow check PASSESclass="s">" : "rejected?!class="s">"}" puts class="s">" the owner mutated freely: records grew to #{enriched[class="y">:records].size} (ownership is mutation rights)" puts class="s">" #{incidents.first}" puts # --- scene 2: two moves of the same value - rejected before anything runs ----------- checker2, _orchestrator2, _incidents2, _tasks2 = build_pipeline(second_consumer_mode: class="y">:move) errors = checker2.check! puts class="s">" scene 2 - both consumers demand a move. the compiler(ish) speaks:" errors.each { |e| e.lines.each { |l| puts class="s">" #{l.rstrip}" } } puts class="s">" nothing executed. the crime was PREVENTED, not avenged - that's the" puts class="s">" entire difference between a checker and a postmortem." puts failures = [] failures << class="s">"clean plan failed" unless result.status == class="y">:completed && enriched[class="y">:records].size == 3 failures << class="s">"borrow was mutable" unless incidents.first&.include?(class="s">"FrozenError") failures << class="s">"double move not rejected" unless errors.size == 1 && errors.first.include?(class="s">"E0382") puts class="s">" what ports and what doesn't: the MODEL ports beautifully - move" puts class="s">" semantics are just 'exactly one consumer may treat this as its" puts class="s">" own', borrows are 'everyone else gets it deep-frozen', and the" puts class="s">" aliasing-XOR-mutation rule is enforceable with a Struct, a Hash," puts class="s">" and Marshal. what doesn't port is the TIMING: Rust rejects at" puts class="s">" compile time; here 'assembly time' plays the part, which is" puts class="s">" still before anything RUNS, which is the part that matters." puts class="s">" the deep freeze on borrows is the honest cost - in Ruby, the" puts class="s">" only reference nobody can mutate is a frozen copy. fearless" puts class="s">" concurrency starts with knowing whose data it is; turns out you" puts class="s">" can know that in any language, if you're willing to write it" puts class="s">" down at the seam." exit(failures.empty? ? 0 : 1)