actors

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: 33 Imported by: 0

Documentation

Overview

Package actors executes leads.

An Actor is the only component that touches raw source material. It takes one lead, does the work, and returns a summary plus claims — never the pages, never the raw text. That boundary is what keeps planner cost from compounding as research deepens (§2), and it is also where fabricated citations die (§11.5).

Index

Constants

View Source
const DefaultEscalationSections = 3

DefaultEscalationSections is how many passages of full text one paper may contribute after its abstract was judged insufficient.

Small on purpose. The whole argument for the ladder is that reading a whole paper to answer one sub-question is the expensive default; escalating from "one abstract" to "one whole paper" would give the saving straight back.

Variables

This section is empty.

Functions

func CoerceNumberForTest

func CoerceNumberForTest(v string) (string, bool)

CoerceNumberForTest exposes the number coercion.

Exported for the tests that pin scaled figures exactly. The rule they check — "£32.7 billion" is 32700000000 and not 32700000000.000004 — is arithmetic that a live run got wrong, and testing it through Mine would need a model reply per case for no extra coverage.

func ShouldEscalateForTest

func ShouldEscalateForTest(query string, claims []core.Claim) bool

ShouldEscalateForTest exposes the escalation gate to the package's tests.

Exported for tests only, and named so. The gate is the one decision in this actor that spends money, so it is worth testing directly rather than through a run whose result could satisfy the assertion for other reasons.

func TruncateQuote

func TruncateQuote(s string) string

TruncateQuote bounds a quote to maxQuoteLen on a rune boundary.

func WithSubBudget

func WithSubBudget(ctx context.Context, b Budget) context.Context

WithSubBudget attaches a per-lead ceiling to ctx.

Types

type AcademicActor

type AcademicActor struct {
	// Providers are searched in order. arXiv and PubMed cover different
	// literatures and overlap little, so both run rather than one being chosen.
	Providers []academic.Provider

	LLM     llm.Provider
	Pricing *pricing.Table
	Store   store.Store
	Log     *slog.Logger
	Budget  Budget

	// Fetch and Extract read tier-1 full text. Nil disables escalation
	// entirely, which is a supported configuration: the abstracts still yield
	// claims, and §10.2's ladder is about reading LESS, so refusing to read
	// more is never wrong, only less complete.
	Fetch   fetch.Fetcher
	Extract extract.Extractor

	// Resolve places a paper the search providers could not: given a DOI it
	// finds where the paper can legally be read. Nil disables it.
	//
	// Only ever consulted at escalation, and only for a paper with a DOI that
	// neither provider gave a readable location for.
	Resolve academic.Resolver

	// Rank orders passages by relevance to a sub-question. See rankChunks for
	// why it is injected rather than imported.
	Rank func(question string, passages []string, n int) []int

	SessionID string
}

AcademicActor researches a lead against scholarly sources (§10.2).

The shape differs from WebActor in one way that matters: tier 0 of the escalation ladder costs NO fetches at all. arXiv and PubMed return the abstract in the search response, so a paper's most information-dense passage arrives for the price of the search — where a web lead pays a search, a fetch and an extraction to reach less.

It also supplies something no web source reliably does: exact publication dates. §11.2's staleness rule compares when claims were made and has been reading zero for want of dates on both sides of a contradiction.

func (*AcademicActor) Run

func (a *AcademicActor) Run(ctx context.Context, lead core.Lead) (*Result, error)

func (*AcademicActor) Type

func (a *AcademicActor) Type() core.ActorType

type AcceptedRow

type AcceptedRow struct {
	Row dataset.Row
	// Coerced names the fields whose value the declared type could not hold.
	// Dropped, and named rather than counted, because the caller that can act on
	// it is the one that proposed the value.
	Coerced []string
	// Reason is empty when the row was accepted, and otherwise says why it was
	// not, in terms whoever proposed the row can act on.
	Reason string
}

AcceptedRow is the outcome of applying §11.5 and the schema to a proposed row.

func AcceptRow

func AcceptRow(schema dataset.Schema, src RowSource, values map[string]string, quote string) AcceptedRow

AcceptRow applies §11.5 and the schema to one proposed row.

Extracted from the miner when toolkit mode needed the same check for rows an agent's model proposed. Two copies would have been two places for the rule to drift, and the rule is the product: a row whose quote is not in the source text dies exactly as a fabricated claim does, and a row filling no key field identifies nothing and can be neither merged nor reported.

func (AcceptedRow) OK

func (a AcceptedRow) OK() bool

OK reports whether the row survived.

type Actor

type Actor interface {
	Run(ctx context.Context, lead core.Lead) (*Result, error)
	Type() core.ActorType
}

Actor executes a lead.

type Budget

type Budget struct {
	// MaxInputTokens caps the text fed to the model across the whole run.
	MaxInputTokens int64
	// MaxSources caps how many search results are read.
	MaxSources int
	// MaxClaimsPerSource bounds a single page's contribution, so one verbose
	// document cannot dominate the graph.
	MaxClaimsPerSource int

	// AlwaysFetch ignores content the search provider supplied and fetches the
	// page itself.
	//
	// Slower and costlier, and off by default for exactly that reason. But
	// provider-supplied text is a measurement blind spot: nothing was fetched,
	// so §10.4 has no denominator and §17.1's gate reads "no data", and
	// citation accuracy cannot be checked because re-reading the page runs a
	// different extractor than the one that produced the text. An eval corpus
	// run on a content-supplying provider silently collects none of it.
	AlwaysFetch bool

	// MaxChunkTokens caps a SINGLE request, which is a different limit from
	// MaxInputTokens and binds first.
	//
	// The default chunk targets ~8k tokens, which fits any current context
	// window — but a context window is not the only ceiling. A provider's
	// per-minute token allowance can be far smaller (Groq's free tier is 6k
	// TPM), and a request over it fails with 413 no matter how much budget is
	// left. Sized wrong, every chunk of every source fails and the run reports
	// success having read nothing.
	MaxChunkTokens int64
}

Budget is what an actor may spend on one lead.

The actor does not reserve or settle — the executor owns that. It receives a ceiling and reports what it used, so the reservation and the actual cost stay in one place.

func SubBudgetFrom

func SubBudgetFrom(ctx context.Context) (Budget, bool)

SubBudgetFrom reads the ceiling, reporting false when none was set.

func (Budget) ChunkOptions

func (b Budget) ChunkOptions() llm.ChunkOptions

ChunkOptions derives the split from the per-request cap.

type ConnectorSource

type ConnectorSource interface {
	List() []connector.Connector
}

ConnectorSource is the registry, narrowed to what the actor needs.

type LocalComputeActor

type LocalComputeActor struct {
	// Connectors is the registered set. The model chooses among them by name,
	// so a run with none configured is a no-op rather than an error — the same
	// way a web run with no search provider is a configuration problem and not
	// a crash.
	Connectors ConnectorSource

	LLM     llm.Provider
	Pricing *pricing.Table
	Log     *slog.Logger
	Budget  Budget

	// Gate tunes the aggregation gate. The zero value is §12.1's defaults, and
	// raising KFloor is the only knob a privacy-conscious user needs.
	Gate gate.Options

	// Store persists the §12.1 audit trail. Nil disables the durable record and
	// nothing else — the log line still happens, and a run without a store (a
	// test, a dry probe) should not lose its evidence over an audit table.
	//
	// No SessionID field beside it: a crossing is filed under the lead's session,
	// which this actor already has on every call.
	Store store.Store

	// Code runs model-authored analysis in the sandbox (§12.1). Nil disables it,
	// which is the state of any machine without a container runtime — and a
	// supported one: the SQL path needs no sandbox, so a nil Code costs the
	// hypotheses SQL cannot express and nothing else.
	Code coderunner.Runner
}

LocalComputeActor answers a lead from the user's own data (M8, §12).

The shape is the same as the other two actors — take a lead, do the work, return summary plus claims plus costs — and the evidence is different in one way that decides the whole implementation: it never leaves the machine.

The run is four steps, and the model is only in the first and the last:

profile  →  the model picks a template and columns   (§12.3: it cannot author SQL)
         →  hypothesis.Render turns that into SQL     (identifiers from the profile)
         →  sqlguard + the aggregation gate run it    (§12.2, §12.1)
         →  the model mines claims from the envelope  (§11.5 quote check, unchanged)

The middle two steps are deterministic. What the model influences is which question gets asked; what it never touches is the data, the statement, or whether the answer may cross.

func (*LocalComputeActor) Run

func (a *LocalComputeActor) Run(ctx context.Context, lead core.Lead) (*Result, error)

func (*LocalComputeActor) Type

func (a *LocalComputeActor) Type() core.ActorType

type MineInput

type MineInput struct {
	Lead core.Lead
	// SourceURL is what the claim will cite. For a paper it is the landing page
	// or the DOI, not the API endpoint the text arrived through — a citation has
	// to be something a reader can open.
	SourceURL string
	Title     string
	// PublishedAt is carried onto every claim. Academic sources report it
	// exactly, which is what §11.2's staleness rule has been missing.
	PublishedAt *time.Time

	// Text is the passage. Offset is where it starts within the whole document,
	// so a quote offset indexes the document rather than the chunk — the chunk
	// does not outlive this call and a re-verification has to find the span.
	Text   string
	Offset int

	MaxClaims int
}

MineInput is one passage to mine.

type MineOutput

type MineOutput struct {
	Claims []core.Claim
	Usage  llm.Usage
	// Call is the ledger row for the model call, recorded whether or not the
	// call succeeded — the tokens were spent either way.
	Call     core.ToolCall
	HasCall  bool
	Proposed int
	Rejected int
}

MineOutput is what one passage yielded.

type Miner

type Miner struct {
	LLM     llm.Provider
	Pricing *pricing.Table
	Log     *slog.Logger

	// SessionID scopes the claims this miner records.
	SessionID string
}

Miner turns a passage of source text into quote-verified claims.

Extracted from WebActor so the AcademicActor does not carry a second copy. The M5 review found the same defect fixed twice in two copies of the response handler, twice in one milestone; this is the same shape of code — a model call, a parse, a verbatim quote check, and the §11.4 lineage bookkeeping — and two copies would be two places for the quote check to drift.

It deliberately does NOT know where the text came from. A web page, an abstract, and a full-text section are the same problem once the text is in hand, and the difference between them belongs to the actor that fetched it.

func (*Miner) Mine

func (m *Miner) Mine(ctx context.Context, in MineInput) (MineOutput, error)

Mine extracts claims from one passage, keeping only those whose quote is genuinely present in it (§11.5).

The quote check is the whole point and is not negotiable: a claim whose quote cannot be found in the text it was mined from was not copied from that text, and citing it would put mole's name behind something a model composed.

type QuoteMatch

type QuoteMatch struct {
	// Offset is the byte offset into the SOURCE text, not the chunk. Claims
	// must be locatable in the document as a whole, since the chunk that
	// produced them does not survive the actor run.
	Offset int
	// Text is the span exactly as it appears in the source, which may differ
	// from what the model returned in whitespace only.
	Text string
	// Exact is false when the match required whitespace normalization.
	Exact bool
}

QuoteMatch is where a quote was found.

func FindQuote

func FindQuote(source, quote string) (QuoteMatch, bool)

FindQuote locates quote within source.

Tries an exact match first. Falls back to a whitespace-insensitive search, because a model re-wrapping a line is a formatting difference rather than a fabrication — but the returned span is always the real text from the source, never the model's rendering of it.

type Result

type Result struct {
	// Summary is a few paragraphs. The planner sees this; it never sees the
	// pages behind it.
	Summary string

	// Claims are atomic facts, each carrying a source and a verbatim quote
	// that has already been checked against the text it came from.
	Claims []core.Claim

	// Rows are schema-shaped extractions, in dataset mode (M9, §13).
	//
	// Instead of Claims, not alongside them. The extraction is one model call per
	// chunk either way, and asking for both would double the cost of every chunk
	// to produce a report nobody asked for — dataset mode is an output MODE, so
	// the output it produces is the dataset.
	Rows []dataset.Row

	// Costs are the tool calls this run made, for the ledger to settle. Every
	// call is recorded whether or not it succeeded — the money was spent
	// either way, and a ledger of successes only cannot enforce a ceiling.
	Costs []core.ToolCall

	// Truncated reports that the sub-budget could not cover the whole
	// document. Surfaced rather than silently dropping content (§4.1).
	Truncated bool

	// Stats describe what happened, for the trace view and for M2's metrics.
	Stats RunStats
}

Result is what one lead produced.

type RowInput

type RowInput struct {
	Lead      core.Lead
	SourceURL string
	Title     string
	Text      string
	Offset    int
	MaxRows   int
}

RowInput is one passage to extract from.

type RowMiner

type RowMiner struct {
	LLM     llm.Provider
	Pricing *pricing.Table
	Log     *slog.Logger

	SessionID string
	Schema    dataset.Schema
}

RowMiner extracts schema-shaped rows from a passage (M9, §13).

It is Miner with a different output shape, and that is the whole design. §13's dataset mode is "per-lead row extraction", which could have been a second pass over the same documents — one call for claims and another for rows. It is one call instead: the same fetch, the same chunking, the same model tier, the same ledger row, and the same §11.5 quote check.

The quote check is why this is not a shortcut. A row is a claim with columns, so a row whose quote is not in the text it came from was not read there, and it dies exactly as a fabricated claim does. A CSV is more, not less, likely to be believed without checking — nobody reads a spreadsheet sceptically — so the standard cannot drop just because the output has a header line.

func (*RowMiner) Mine

func (m *RowMiner) Mine(ctx context.Context, in RowInput) (RowOutput, error)

Mine extracts rows, keeping only those the passage supports.

type RowOutput

type RowOutput struct {
	Rows  []dataset.Row
	Usage llm.Usage
	// Call is the ledger row, recorded whether or not the call succeeded — the
	// tokens were spent either way.
	Call    core.ToolCall
	HasCall bool

	Proposed int
	// Rejected counts rows dropped because their quote was not in the passage,
	// or because they filled no key field. A rising rate is the signal that a
	// model has started inventing table contents, which is the failure mode this
	// output mode makes hardest to notice.
	Rejected int
	// Coerced counts VALUES dropped because the field's declared type could not
	// hold them — a `number` field given "roughly $1.2m".
	//
	// Counted because it was not: a row could arrive with four fields, lose three
	// to coercion, keep its key, and be reported as an accepted row. The dataset
	// then had empty cells with no number anywhere saying why, and the honest
	// reading — the schema's types do not match what the sources write — was
	// invisible. Rejected rows had a counter; silently emptied ones did not.
	Coerced int
}

RowOutput is what one passage yielded.

type RowSource

type RowSource struct {
	// Text is what the quote is checked against. mole's own copy of the source,
	// never text a caller supplied — the whole rule collapses otherwise.
	Text string
	URL  string
	// Offset is where Text starts inside the full document, so a stored quote
	// offset points into the document rather than into the chunk.
	Offset int
	LeadID string
}

RowSource is the passage a proposed row must be supported by.

type RunStats

type RunStats struct {
	SearchResults int
	Fetched       int
	// SkippedFetch counts results whose content the search provider already
	// supplied. The whole reason for preferring a provider that returns page
	// text (§10.4), and worth measuring rather than assuming.
	SkippedFetch  int
	Chunks        int
	ChunksSkipped int

	// ChunksFailed counts chunks whose model call errored. A run where every
	// chunk failed still returns whatever it scraped together, so without this
	// the result looks thin rather than broken.
	ChunksFailed int

	// CacheHits counts sources served from the session cache instead of the
	// network (§9.3).
	CacheHits int

	ClaimsProposed int
	// ClaimsRejected counts claims discarded because their quote did not
	// appear in the source. A rising rate here is the signal that a model or
	// prompt has started fabricating.
	ClaimsRejected int

	// ValuesCoerced counts dataset values dropped because the schema's declared
	// type could not hold them (M9): a `number` field given "roughly $1.2m".
	//
	// Its own counter rather than folded into ClaimsRejected, because it means
	// something different and has a different fix. A rejected row means the model
	// invented; a coerced-away value usually means the SCHEMA is wrong for what
	// the sources write, and the user is the only one who can change that. Rows
	// that lost every non-key value still counted as accepted, so without this
	// the run reported a clean extraction and delivered empty cells.
	ValuesCoerced int
}

RunStats is per-run accounting that is not money.

type WebActor

type WebActor struct {
	// Rows, when set, switches this actor to dataset mode: every chunk is
	// extracted into schema-shaped rows instead of claims (M9, §13). Nil is
	// report mode, which is every session that did not ask for a dataset.
	Rows *RowMiner

	Search  search.Provider
	Fetch   fetch.Fetcher
	Extract extract.Extractor
	LLM     llm.Provider
	Pricing *pricing.Table
	Store   store.Store
	Log     *slog.Logger
	Budget  Budget

	// SessionID scopes the claims and fetch outcomes this actor records.
	SessionID string

	// Cache, when set, holds documents already fetched in this session. The
	// nested check §9.3 describes: two distinct queries converging on one page
	// pay for one fetch. Nil disables it.
	Cache *cache.Cache
}

WebActor runs a web research lead:

search → (fetch → extract)? → chunk → mine claims → verify quotes → reduce

The parenthesized step is conditional: a search provider that returned usable page content skips it entirely (§10.4). Everything raw dies when Run returns.

func (*WebActor) Run

func (a *WebActor) Run(ctx context.Context, lead core.Lead) (*Result, error)

Run executes one lead.

func (*WebActor) Type

func (a *WebActor) Type() core.ActorType

Jump to

Keyboard shortcuts

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