planner

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 13, 2026 License: Apache-2.0 Imports: 9 Imported by: 0

Documentation

Overview

Package planner decomposes a question into leads and replans as evidence arrives.

The digest is what the planner sees between rounds: a rolling summary of which sub-questions have evidence and which do not. It is bounded on purpose — the planner reads it every replan, so an unbounded digest makes planning cost grow with the length of the run.

Index

Constants

View Source
const (
	DefaultMaxInitialLeads      = 4
	DefaultMaxNewLeadsPerReplan = 3
	DefaultReplanEvery          = 3
	DefaultMaxDepth             = 2
)

Defaults chosen to spend a small budget usefully rather than exhaust it on the first fan-out.

View Source
const DefaultDigestChars = 4000

DefaultDigestChars bounds the serialized digest.

Characters rather than tokens for the same reason as chunking (§4.1): the tokenizer differs per provider, and an estimate that runs long produces a rejected request rather than a slightly larger bill. Roughly 1k tokens.

View Source
const DepthNone = -1

DepthNone disables follow-up rounds: the initial decomposition and nothing more. Distinct from the zero value, which means "unset".

Variables

This section is empty.

Functions

func LeadsAcross

func LeadsAcross(sessionID string, actors []core.ActorType, qs []SubQuestion, depth int, parent *string) []core.Lead

LeadsAcross distributes sub-questions across the session's actors, round-robin.

Round-robin, and not "every actor researches every question": that would double the cost of a two-actor session for evidence largely about the same thing. And not "one actor for the whole session", which is what this did — a session asking for web AND academic got web for every lead, because the actor was chosen once from the session rather than per question. The academic actor was built, registered, and never handed a lead, and `--actors web,academic` silently researched nothing academically.

Deterministic by position, so a replay assigns the same actor to the same question. Not model-chosen: the planner deciding per question is a separate change with its own prompt and its own failure mode, and this is the mechanical version that makes the flag mean what it says.

func LeadsFor

func LeadsFor(sessionID string, actor core.ActorType, qs []SubQuestion, depth int, parent *string) []core.Lead

LeadsFor turns open sub-questions into dispatchable leads.

Types

type DeadEnd

type DeadEnd struct {
	Cause string
	Count int
	// Example is one query that hit this cause, so the planner can tell a
	// blocked domain from a badly phrased search.
	Example string
}

DeadEnd records a lead that produced nothing, collapsed by cause.

Collapsed rather than listed: twenty bot_block failures are one fact for the planner ("this route is blocked"), and listing each would spend the digest's whole budget on the least informative part of it.

type Digest

type Digest struct {

	// Question is the original prompt. Never dropped by compaction: without it
	// the planner does not know what it is researching.
	Question string

	Questions []SubQuestion
	DeadEnds  []DeadEnd

	// Contradictions is how many live disagreements the Verifier has found (§11).
	//
	// A count, so §9.1's rule holds: no page-derived text reaches the planner. It
	// still changes the decision, and it is the one signal that separates "this
	// sub-question has evidence" from "this sub-question has an argument" — a
	// planner marking a disputed question answered is the failure this prevents.
	Contradictions int

	// LeadsRun and ClaimsFound are session totals, kept even when the
	// per-question detail is compacted away.
	LeadsRun    int
	ClaimsFound int

	// BudgetRemaining is the fraction of the session's allowance still
	// available, from core.Session.RemainingFraction.
	//
	// Set fresh before each replan rather than accumulated: it is current state,
	// not history, and a stale figure is worse than none. Zero means "not
	// reported" and is omitted from the serialized form — unambiguous in
	// practice because a session with genuinely nothing left has already
	// stopped and will not replan.
	BudgetRemaining float64

	// MaxChars bounds the serialized form. Zero uses DefaultDigestChars.
	MaxChars int
	// contains filtered or unexported fields
}

Digest is the planner's entire view of a session.

Its METHODS are safe for concurrent use. That matters from M5: a worker pool records leads, claims and dead ends as they complete, and without this the counters lose updates — measured at 3 lost out of 400 across 8 goroutines, with the race detector reporting 36 races on the same run.

Its exported FIELDS are not protected and must not be touched by a worker. BudgetRemaining and Contradictions are written between batches, by the coordinator, at the replan and verify points — which is where they belong anyway: both are current state read fresh for a planner call, not something a lead produces.

func NewDigest

func NewDigest(question string, maxChars int) *Digest

NewDigest starts a digest for a session.

func (*Digest) AddQuestions

func (d *Digest) AddQuestions(qs []SubQuestion) []SubQuestion

AddQuestions registers sub-questions the planner proposed, and returns them with the IDs the digest assigned.

IDs are assigned here, never taken from the model. Model-authored IDs are numbered per batch, so every replan that omits them restarts at q1 and collides with the initial decomposition — and the old dedupe-by-ID then DROPPED the new question while the executor still queued its lead and credited its claims to the question it collided with. Confirmed: two rounds both using q1 left one question holding both rounds' leads, and after MarkAnswered the digest read "No open sub-questions" while a new question was running. That is §9.3's livelock arriving from the other direction.

Returning the assigned IDs is what lets the caller map leads correctly; positional correspondence with the input is not enough once duplicates are dropped.

Dedupe is by normalized TEXT, because that is what actually identifies a research thread. A replan re-proposing the same wording should not open a second one.

func (*Digest) Complete

func (d *Digest) Complete() bool

Complete reports whether every sub-question has been answered. A digest with no questions at all is not complete — nothing has been planned yet.

func (*Digest) MarkAnswered

func (d *Digest) MarkAnswered(questionID string)

MarkAnswered closes a sub-question.

func (*Digest) Open

func (d *Digest) Open() []SubQuestion

Open returns the sub-questions still without an answer.

func (*Digest) RecordClaims

func (d *Digest) RecordClaims(questionID string, n int)

RecordClaims notes evidence found for a sub-question.

func (*Digest) RecordDeadEnd

func (d *Digest) RecordDeadEnd(cause, exampleQuery string)

RecordDeadEnd notes a lead that produced no evidence (§9.5 degraded).

func (*Digest) RecordLead

func (d *Digest) RecordLead(questionID string)

RecordLead notes a dispatch against a sub-question.

func (*Digest) String

func (d *Digest) String() string

String renders the digest for a prompt.

Stable ordering: two identical states must produce identical text, or the cassette key changes between runs and every replay misses.

type Plan

type Plan struct {
	Questions []SubQuestion
	Leads     []core.Lead
	Usage     llm.Usage
	Model     string
	// Done is set when the planner judges the research complete. Only Replan
	// can set it.
	Done bool

	// Answered names open sub-questions the planner now considers closed.
	// Returned rather than applied: the caller owns the digest, and a planner
	// that mutated it would make a replan unrepeatable against a cassette.
	Answered []string
	// Rationale is one line for the trace, not for the model.
	Rationale string
}

Plan is what a planning call produced.

type Planner

type Planner struct {
	LLM llm.Provider

	// MaxInitialLeads bounds the first fan-out. A model asked to decompose
	// without a bound will happily produce thirty sub-questions and spend the
	// whole budget before the first replan can react.
	MaxInitialLeads int

	// MaxNewLeadsPerReplan bounds each subsequent fan-out.
	MaxNewLeadsPerReplan int

	// ReplanEvery is §9.1's batching: replan after this many completed leads
	// rather than after every one, which cuts planner calls by roughly k.
	ReplanEvery int

	// MaxDepth caps the lead tree independently of budget (§9.1). A follow-up
	// of a follow-up of a follow-up is usually drift, not depth.
	//
	// Zero means unset, per Go convention, and takes DefaultMaxDepth. Use
	// DepthNone to mean "no follow-up rounds at all" — the two have to be
	// distinguishable, because the obvious `<= 0` treatment made
	// `--max-depth 0` silently plan two extra rounds against ceilings that had
	// been sized for none.
	MaxDepth int
}

Planner decomposes a question and decides what to research next.

Two model calls exist here and nowhere else in the loop: an initial decomposition, and a replan against the digest. Everything between them is mechanical bookkeeping, which is what keeps planner cost proportional to the number of REPLANS rather than the number of leads (§9.1).

func (*Planner) DepthCapReason

func (p *Planner) DepthCapReason() string

DepthCapReason describes the cap for a trace, without a model call.

func (*Planner) DepthExhausted

func (p *Planner) DepthExhausted(depth int) bool

DepthExhausted reports whether the lead tree has reached the depth cap.

Exported so the caller can take that decision for free. Replan checks it too, but only after the caller has reserved budget for a model call it turns out not to make — and when a ceiling has just fired, the reservation is refused and the session reports the ceiling as its stop reason instead of the cap that actually ended it. Same outcome, wrong explanation, and the stop reason is an eval metric (§14.3).

func (*Planner) InitialLeads

func (p *Planner) InitialLeads(ctx context.Context, sess *core.Session) (*Plan, error)

InitialLeads decomposes the prompt into the first round of sub-questions.

The prompt is the user's, so it is trusted input in the §3.2 sense — nothing fetched has been seen yet. It is still delimited, because a research question can legitimately quote a web page, and the difference between "the user typed this" and "a page said this" stops being visible once it is concatenated.

func (*Planner) Replan

func (p *Planner) Replan(ctx context.Context, sess *core.Session, d *Digest, depth int) (*Plan, error)

Replan proposes follow-up leads from the digest.

The digest carries counts and the planner's own prior wording — no page text (see the package comment). That closes the §3.2 injection path completely, and costs something real: the planner can see that a sub-question found no evidence, but not that what WAS found suggests a different angle. A content-aware replan needs the summaries fenced back in, and is deferred until there is an eval corpus to show whether it pays for itself.

func (*Planner) ShouldReplan

func (p *Planner) ShouldReplan(completedSinceLastReplan int, queueEmpty bool) bool

ShouldReplan implements §9.1's batching.

Replanning after every lead is what made rev 1 quadratic. Replanning never is a fixed plan, which cannot react to a dead end. Every k, or when the queue drains, is the compromise.

type SubQuestion

type SubQuestion struct {
	// ID is short and stable so the planner can refer to a question across
	// replans without the full text being echoed back each time.
	ID   string
	Text string

	// Leads counts dispatches against this question; Claims counts what came
	// back. The pair is the coverage signal: leads without claims is a
	// question being asked badly, not a question with no answer.
	Leads  int
	Claims int

	// Answered is set by the planner, not inferred from a claim count. A
	// question can accumulate claims that do not answer it.
	Answered bool
}

SubQuestion is one thread of the research.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL