PPysolate SpineREAD THE CODE · UNDERSTAND THE SYSTEM
课程与章节
CODE READING / 10

交易演示:从读取到条件下单

交易程序、Host 状态与对应测试

本章目录

这章回答一个具体问题:当 Python 先读价格和现金,再决定是否调用 buy 时,PLM、Prefix 和 Host 审批分别改变了什么?

这是一个全本地、内存中的模拟。默认局面是:ACME 的冻结价格为 100,初始现金为 2000,Python 的限价为 110,数量为 10。程序最后交付一个 JSON 报告,其中包含 Python 的 result、各工具调用次数、买入尝试、模拟订单和剩余现金。它不连接经纪商,不发送外部订单,也不做绩效实验。

先沿一笔具体决策走完

交易源文件先调用 quote 和 cash:

examples/trading.py · L1–L14 · 打开完整源码

price = quote(symbol=inputs["symbol"])
cash_balance = cash()
if price <= inputs["limit"]:
    cost = price * inputs["quantity"]
    if cash_balance >= cost:
        try:
            order = buy(symbol=inputs["symbol"], quantity=inputs["quantity"])
            result = {"decision": "bought", "price": price, "cash_snapshot": cash_balance, "order": order}
        except RuntimeError:
            result = {"decision": "blocked", "price": price, "cash_snapshot": cash_balance, "reason": "Host refused the simulated order"}
    else:
        result = {"decision": "hold", "price": price, "cash_snapshot": cash_balance, "reason": "insufficient cash"}
else:
    result = {"decision": "hold", "price": price, "cash_snapshot": cash_balance, "reason": "price above limit"}

默认情况下,Python 得到 price=100 和 cash_balance=2000。价格不高于 limit=110,所以进入第一层分支;成本是 100 × 10 = 1000,现金快照足够,于是进入 try 调用 buy。

如果 Host 允许模拟订单,buy 返回一个订单对象,Python 交付 decision: "bought",并把此前的现金快照 2000 放进结果。如果 Host 拒绝,buy 抛出 RuntimeError,Python 捕获它,交付 decision: "blocked";价格和现金快照仍然保留。两种结果都来自同一段控制流。

其他局面在进入 buy 前就结束:

  • limit=90:100 高于限价,交付 hold,没有买入尝试。
  • cash=500:价格通过,但 500 小于 1000,交付 hold,没有买入尝试。
  • Host 没有 -allow-order:即使 inputs 中有 approved: true,也只能得到 blocked。Python 不读取这个字段;是否批准由 Host 配置独立决定。
  • 数量为负:Python 的现金比较可能放行,但 Host 在真正的 buy 边界拒绝它。

因此要分开三个状态:Python 的 cash_balance 是读取时的快照,market 的 remainingCash 是提交成功后才减少的 Host 状态,allowOrder 是 Host 授权门。模拟订单成功后,默认剩余现金为 1000;快照字段仍可显示 2000。

从 CLI 入口到执行结果

main 只负责声明命令行参数并把配置交给 run。参数分为四组:Guest/source,执行模式与内存模式,模拟市场局面,以及 Host 审批、读延迟和超时。

cmd/trade-demo/main.go · L1–L45 · 打开完整源码

package main

import (
	"context"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"os"
	"sort"
	"strings"
	"time"

	spine "pysolate-spine"
)

type config struct {
	wasm, source, mode, memory, symbol, inputs string
	price, cash, limit, quantity               int
	allowOrder, showTransformed                bool
	readDelay, timeLimit                       time.Duration
}

func main() {
	var c config
	flag.StringVar(&c.wasm, "wasm", "dist/spine.wasm", "existing Guest artifact")
	flag.StringVar(&c.source, "source", "examples/trading.py", "Python trading example")
	flag.StringVar(&c.mode, "mode", "plm", "normal, plm, or prefix")
	flag.StringVar(&c.memory, "memory", "fresh", "fresh, copy, or cow (Linux)")
	flag.StringVar(&c.symbol, "symbol", "ACME", "fictional symbol used by the local market")
	flag.IntVar(&c.price, "price", 100, "frozen simulated price, in whole demo units")
	flag.IntVar(&c.cash, "cash", 2000, "initial simulated cash")
	flag.IntVar(&c.limit, "limit", 110, "Python buy-price threshold")
	flag.IntVar(&c.quantity, "quantity", 10, "Python requested quantity")
	flag.StringVar(&c.inputs, "inputs", "", "JSON overrides for Python inputs; never grants Host approval")
	flag.BoolVar(&c.allowOrder, "allow-order", false, "allow in-memory simulated orders; no broker is contacted")
	flag.BoolVar(&c.showTransformed, "show-transformed", false, "print actual Guest-transformed source")
	flag.DurationVar(&c.readDelay, "read-delay", 0, "optional simulated latency of each snapshot read, e.g. 300ms")
	flag.DurationVar(&c.timeLimit, "timeout", 30*time.Second, "whole demo timeout")
	flag.Parse()
	if err := run(c, os.Stdout, os.Stderr); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

run 先拒绝未知模式、未知内存方式和非法市场数值,再建立给 Python 的默认 inputs。-inputs 可以覆盖这些 Python 输入,但代码明确没有把它当成 Host 授权。接着读取既有 Wasm artifact 和交易源文件,创建带时间序列的 trace 与 market。

cmd/trade-demo/main.go · L46–L98 · 打开完整源码

func run(c config, stdout, stderr io.Writer) error {
	if c.mode != "normal" && c.mode != "plm" && c.mode != "prefix" {
		return fmt.Errorf("unknown mode %q", c.mode)
	}
	if c.memory != "fresh" && c.memory != "copy" && c.memory != "cow" {
		return fmt.Errorf("unknown memory mode %q", c.memory)
	}
	if c.price <= 0 || c.cash < 0 || c.quantity <= 0 || c.limit < 0 || c.readDelay < 0 || c.timeLimit <= 0 {
		return fmt.Errorf("invalid simulated market settings")
	}
	inputs := map[string]any{"symbol": c.symbol, "limit": c.limit, "quantity": c.quantity}
	if c.inputs != "" {
		if err := json.Unmarshal([]byte(c.inputs), &inputs); err != nil {
			return err
		}
		if inputs == nil {
			return fmt.Errorf("inputs must be a JSON object")
		}
	}
	wasm, err := os.ReadFile(c.wasm)
	if err != nil {
		return err
	}
	source, err := os.ReadFile(c.source)
	if err != nil {
		return err
	}
	t := newTrace(stderr)
	m := newMarket(c.symbol, c.price, c.cash, c.allowOrder, c.readDelay, t)
	t.log("demo.config", "", map[string]any{"simulation": true, "mode": c.mode, "memory": c.memory, "price": c.price, "cash": c.cash, "allow_order": c.allowOrder, "read_delay": c.readDelay.String()})
	ctx, cancel := context.WithTimeout(context.Background(), c.timeLimit)
	defer cancel()
	create := spine.New
	if c.memory == "copy" {
		create = spine.NewPrepared
	} else if c.memory == "cow" {
		create = spine.NewPreparedCOW
	}
	manifest := m.tools()
	earlyReads := make([]string, 0, len(manifest))
	for name, spec := range manifest {
		if spec.AllowEarlyRead {
			earlyReads = append(earlyReads, name)
		}
	}
	sort.Strings(earlyReads)
	t.log("runner.create", "", nil)
	runner, err := create(ctx, wasm, manifest)
	if err != nil {
		return err
	}
	defer runner.Close(context.Background())
	t.log("runner.ready", "", map[string]any{"early_reads": earlyReads, "buy_early": manifest["buy"].AllowEarlyRead})

market 的 tools() 产生当前 Manifest。earlyReads 从 ToolSpec.AllowEarlyRead 收集并排序,只用于日志;buy_early 也被写进 runner.ready 日志,方便观察授权清单,而不改变 Runner 的决定。内存选项分别调用 spine.New、spine.NewPrepared 或 spine.NewPreparedCOW。

普通和 PLM 模式把完整源文本交给对应的 Run 方法。Prefix 模式则启动 producer,把源文件按换行切成 append-only 片段送入 channel;producer 监听取消,完成后由 closeSource 关闭 channel。RunPrefix 返回后,调用 stopProducer 并等待 producerDone,这才完成该 producer 的收尾。

cmd/trade-demo/main.go · L99–L162 · 打开完整源码

	var output spine.Output
	if c.mode == "prefix" {
		chunks := make(chan string)
		producerDone := make(chan struct{})
		producerCtx, stopProducer := context.WithCancel(ctx)
		go func() {
			defer close(producerDone)
			defer t.closeSource(chunks)
			for i, chunk := range strings.SplitAfter(string(source), "\n") {
				if chunk == "" {
					continue
				}
				t.log("source.send", "", map[string]any{"chunk": i + 1, "text": chunk})
				select {
				case chunks <- chunk:
				case <-producerCtx.Done():
					t.log("source.cancelled", "", nil)
					return
				}
			}
		}()
		output, err = runner.RunPrefix(ctx, chunks, inputs)
		stopProducer()
		<-producerDone
	} else {
		t.log("source.complete", "", map[string]any{"bytes": len(source)})
		execute := runner.Run
		if c.mode == "plm" {
			execute = runner.RunPLM
		}
		output, err = execute(ctx, string(source), inputs)
	}
	t.log("run.finished", "", map[string]any{"transformed": output.Transformed != "", "error": errorText(err)})
	if c.showTransformed {
		fmt.Fprintln(stderr, "--- actual transformed source ---")
		if output.Transformed == "" {
			fmt.Fprintln(stderr, "(no AST rewrite)")
		} else {
			fmt.Fprintln(stderr, output.Transformed)
		}
	}
	if output.Stdout != "" {
		t.log("guest.stdout", "", output.Stdout)
	}
	if err != nil {
		return err
	}
	report := struct {
		Simulation bool            `json:"simulation"`
		Mode       string          `json:"mode"`
		Result     json.RawMessage `json:"result"`
		Calls      map[string]int  `json:"tool_calls"`
		Attempts   int             `json:"buy_attempts"`
		Orders     []order         `json:"orders"`
		Remaining  int             `json:"remaining_cash"`
	}{true, c.mode, output.Value, m.calls, m.attempts, append([]order{}, m.orders...), m.remainingCash}
	return json.NewEncoder(stdout).Encode(report)
}
func errorText(err error) string {
	if err == nil {
		return ""
	}
	return err.Error()
}

运行结束时 trace 记录是否改写、是否出错;可选的 -show-transformed 输出真实 transformed source。成功时 stdout 先输出 Guest 的 stdout,再输出报告。报告中的 Orders 使用 market 当前订单切片的副本,避免编码期间直接暴露内部切片;Calls、Attempts 和 Remaining 则记录这次模拟留下的 Host 观察值。

trace 只观察边界,不参与决策

trace 为每条事件分配递增序号,记录 UTC 时间和相对耗时,并用 mutex 保护写入。日志写到 stderr,所以交易报告仍走 stdout;注释也明确说明 trace 不改变运行决定。

cmd/trade-demo/market.go · L1–L42 · 打开完整源码

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"sync"
	"time"

	spine "pysolate-spine"
)

type event struct {
	Time      string  `json:"time"`
	ElapsedMS float64 `json:"elapsed_ms"`
	Sequence  int     `json:"seq"`
	Event     string  `json:"event"`
	Tool      string  `json:"tool,omitempty"`
	Data      any     `json:"data,omitempty"`
}

type trace struct {
	mu       sync.Mutex
	start    time.Time
	sequence int
	writer   io.Writer
}

func newTrace(w io.Writer) *trace { return &trace{start: time.Now(), writer: w} }
func (t *trace) log(kind, tool string, data any) {
	t.mu.Lock()
	defer t.mu.Unlock()
	t.write(kind, tool, data)
}
func (t *trace) write(kind, tool string, data any) {
	t.sequence++
	now := time.Now()
	e := event{Time: now.UTC().Format(time.RFC3339Nano), ElapsedMS: float64(now.Sub(t.start).Microseconds()) / 1000, Sequence: t.sequence, Event: kind, Tool: tool, Data: data}
	// This CLI's log writer is stderr; traces never alter runtime decisions.
	_ = json.NewEncoder(t.writer).Encode(e)
}

closeSource 在同一把锁下关闭 channel 并记录 source.closed。这使 Prefix 的“channel 已关闭”与日志事件保持在同一段收尾动作里;它不等于宣称任意未退出 worker 都已经释放。

market 怎样包装工具并管理订单

order 保存 ID、symbol、quantity、成交价和 cost;market 保存冻结价格、初始现金、剩余现金、授权开关、读延迟、订单、调用计数和 trace。newMarket 只建立内存状态,没有任何外部连接。

cmd/trade-demo/market.go · L43–L71 · 打开完整源码

func (t *trace) closeSource(ch chan string) {
	t.mu.Lock()
	defer t.mu.Unlock()
	close(ch)
	t.write("source.closed", "", nil)
}

type order struct {
	ID       int    `json:"id"`
	Symbol   string `json:"symbol"`
	Quantity int    `json:"quantity"`
	Price    int    `json:"price"`
	Cost     int    `json:"cost"`
}
type market struct {
	mu                                sync.Mutex
	symbol                            string
	price, initialCash, remainingCash int
	allowOrder                        bool
	delay                             time.Duration
	orders                            []order
	attempts                          int
	calls                             map[string]int
	trace                             *trace
}

func newMarket(symbol string, price, cash int, allow bool, delay time.Duration, t *trace) *market {
	return &market{symbol: symbol, price: price, initialCash: cash, remainingCash: cash, allowOrder: allow, delay: delay, calls: map[string]int{}, trace: t}
}

wrap 在真实工具函数前后记录调用次数、tool.start、tool.end 或 tool.error。它不吞掉错误,也不把日志结果变成新的返回值。waitRead 用 timer 模拟每次快照读取的延迟,同时监听 context;延迟为 0 时直接返回 context 状态。

cmd/trade-demo/market.go · L72–L99 · 打开完整源码

func (m *market) wrap(name string, fn spine.Tool) spine.Tool {
	return func(ctx context.Context, args json.RawMessage) (any, error) {
		m.mu.Lock()
		m.calls[name]++
		m.mu.Unlock()
		m.trace.log("tool.start", name, args)
		value, err := fn(ctx, args)
		if err != nil {
			m.trace.log("tool.error", name, map[string]any{"error": err.Error()})
		} else {
			m.trace.log("tool.end", name, map[string]any{"result": value})
		}
		return value, err
	}
}
func (m *market) waitRead(ctx context.Context) error {
	if m.delay == 0 {
		return ctx.Err()
	}
	timer := time.NewTimer(m.delay)
	defer timer.Stop()
	select {
	case <-timer.C:
		return ctx.Err()
	case <-ctx.Done():
		return ctx.Err()
	}
}

quote 解析 symbol,拒绝未知模拟标的,等待可取消的读取后返回冻结价格。cash 同样等待读取,但返回 initialCash:这是允许提前读取的冻结快照,即使稍后有成功买入也不回写这个值。

buy 是唯一会改变 market 状态的工具。它先在锁内增加尝试次数、检查 context,再检查 allowOrder。未获 Host 许可时立即写入 order.denied,明确 account_changed: false,然后返回错误。通过授权后才解析 symbol 与 quantity,拒绝错误标的和非正数量。

cmd/trade-demo/market.go · L100–L158 · 打开完整源码

func (m *market) tools() spine.Manifest {
	return spine.Manifest{
		"quote": {Call: m.wrap("quote", func(ctx context.Context, args json.RawMessage) (any, error) {
			var a struct {
				Symbol string `json:"symbol"`
			}
			if err := json.Unmarshal(args, &a); err != nil {
				return nil, err
			}
			if a.Symbol != m.symbol {
				return nil, fmt.Errorf("unknown simulated symbol: %s", a.Symbol)
			}
			if err := m.waitRead(ctx); err != nil {
				return nil, err
			}
			return m.price, nil
		}), AllowEarlyRead: true},
		"cash": {Call: m.wrap("cash", func(ctx context.Context, _ json.RawMessage) (any, error) {
			if err := m.waitRead(ctx); err != nil {
				return nil, err
			}
			// A frozen snapshot is eligible for early reads even if a later buy changes the account.
			return m.initialCash, nil
		}), AllowEarlyRead: true},
		"buy": {Call: m.wrap("buy", func(ctx context.Context, args json.RawMessage) (any, error) {
			m.mu.Lock()
			defer m.mu.Unlock()
			m.attempts++
			if err := ctx.Err(); err != nil {
				return nil, err
			}
			if !m.allowOrder {
				m.trace.log("order.denied", "buy", map[string]any{"reason": "Host approval missing", "account_changed": false})
				return nil, fmt.Errorf("simulated order requires Host -allow-order")
			}
			var a struct {
				Symbol   string `json:"symbol"`
				Quantity int    `json:"quantity"`
			}
			if err := json.Unmarshal(args, &a); err != nil {
				return nil, err
			}
			if a.Symbol != m.symbol || a.Quantity <= 0 {
				return nil, fmt.Errorf("invalid simulated order")
			}
			// Recheck the current account, independently of Python's control flow. Division avoids overflow.
			if a.Quantity > m.remainingCash/m.price {
				m.trace.log("order.denied", "buy", map[string]any{"reason": "insufficient current cash", "account_changed": false})
				return nil, fmt.Errorf("insufficient current cash")
			}
			m.trace.log("order.authorized", "buy", map[string]any{"simulation": true})
			o := order{ID: len(m.orders) + 1, Symbol: a.Symbol, Quantity: a.Quantity, Price: m.price, Cost: a.Quantity * m.price}
			m.remainingCash -= o.Cost
			m.orders = append(m.orders, o)
			m.trace.log("order.committed", "buy", map[string]any{"simulation": true, "order": o, "remaining_cash": m.remainingCash})
			return o, nil
		})},
	}
}

最后一次现金检查针对当前的 remainingCash,用 quantity > remainingCash/price 避免先乘法造成溢出。检查通过后才记录 order.authorized,创建订单、扣减现金、追加订单,并记录 order.committed。因此 Python 控制流即使判断过现金,也不能绕过 Host 的当前账户检查;失败路径不会产生订单或扣款。

三种模式怎样影响观察时机

  • normal:完整源代码执行,所有工具调用在 Python 原调用处发生。
  • plm:quote 和 cash 的 AllowEarlyRead 允许 PLM 提前准备快照;buy 没有这个标记,不能因它会返回订单就提前提交。最终控制流仍在原位置交付值或错误。
  • prefix:源码先以片段到达。只有完整输入结束后,程序才走到 buy 的调用位置。

这些区别是观察时机和准备资格的区别,不是把模拟订单变成真实交易的区别。

测试怎样证明这些说法

测试复用真实 artifact,并在 normal、plm、prefix 三种模式下重复同一组 price/cash/Host 局面。每个 case 开始前重置 market 的现金、授权、尝试次数、订单和调用计数;运行返回后再检查结果和 Host 状态。

cmd/trade-demo/main_test.go · L1–L38 · 打开完整源码

package main

import (
	"bytes"
	"context"
	"encoding/json"
	"io"
	"os"
	"strings"
	"testing"
	"time"

	spine "pysolate-spine"
)

func testRunner(t *testing.T, m *market, manifest spine.Manifest) *spine.Runner {
	t.Helper()
	wasm, err := os.ReadFile("../../dist/spine.wasm")
	if err != nil {
		t.Fatal(err)
	}
	if manifest == nil {
		manifest = m.tools()
	}
	r, err := spine.New(context.Background(), wasm, manifest)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() {
		if err := r.Close(context.Background()); err != nil {
			t.Error(err)
		}
	})
	return r
}
func inputs(limit, quantity int) map[string]any {
	return map[string]any{"symbol": "ACME", "limit": limit, "quantity": quantity, "approved": true}
}

cmd/trade-demo/main_test.go · L39–L105 · 打开完整源码

func TestTradingControlFlowAndHostGate(t *testing.T) {
	code, err := os.ReadFile("../../examples/trading.py")
	if err != nil {
		t.Fatal(err)
	}
	m := newMarket("ACME", 100, 2000, false, 0, newTrace(io.Discard))
	r := testRunner(t, m, nil)
	cases := []struct {
		name                        string
		limit, cash, quantity       int
		allow                       bool
		decision                    string
		attempts, orders, remaining int
	}{
		{"price skip", 90, 2000, 10, true, "hold", 0, 0, 2000},
		{"cash skip", 110, 500, 10, true, "hold", 0, 0, 500},
		{"host denial despite approved input", 110, 2000, 10, false, "blocked", 1, 0, 2000},
		{"approved simulation", 110, 2000, 10, true, "bought", 1, 1, 1000},
		{"host validates bad quantity", 110, 2000, -1, true, "blocked", 1, 0, 2000},
	}
	for _, mode := range []string{"normal", "plm", "prefix"} {
		for _, tc := range cases {
			t.Run(mode+"/"+tc.name, func(t *testing.T) {
				m.mu.Lock()
				m.initialCash = tc.cash
				m.remainingCash = tc.cash
				m.allowOrder = tc.allow
				m.attempts = 0
				m.orders = nil
				m.calls = map[string]int{}
				m.mu.Unlock()
				ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
				defer cancel()
				var out spine.Output
				var err error
				if mode == "prefix" {
					ch := make(chan string, 1)
					ch <- string(code)
					close(ch)
					out, err = r.RunPrefix(ctx, ch, inputs(tc.limit, tc.quantity))
				} else if mode == "plm" {
					out, err = r.RunPLM(ctx, string(code), inputs(tc.limit, tc.quantity))
				} else {
					out, err = r.Run(ctx, string(code), inputs(tc.limit, tc.quantity))
				}
				if err != nil {
					t.Fatal(err)
				}
				var got struct {
					Decision string `json:"decision"`
				}
				if err = json.Unmarshal(out.Value, &got); err != nil {
					t.Fatal(err)
				}
				if got.Decision != tc.decision || m.attempts != tc.attempts || len(m.orders) != tc.orders || m.remainingCash != tc.remaining {
					t.Fatalf("result=%s attempts=%d orders=%v remaining=%d", out.Value, m.attempts, m.orders, m.remainingCash)
				}
				if m.calls["quote"] != 1 || m.calls["cash"] != 1 {
					t.Fatalf("duplicated snapshot reads: %v", m.calls)
				}
				if mode != "normal" && out.Transformed == "" {
					t.Fatal("expected actual AST rewrite")
				}
			})
		}
	}
}

表格断言 decision、买入尝试数、订单数和剩余现金,并要求 quote、cash 各只读一次。PLM 和 Prefix 还必须留下实际 transformed source。测试没有断言事件的绝对时间或具体 AST 文本,只锁定可观察语义。

Prefix 的同步测试把完整代码放入 channel 后启动 RunPrefix,通过两个包装工具的 started channel 等待 quote 和 cash 已开始。观察到两个启动信号后、关闭 source channel 之前,检查 market 中仍然没有 buy。随后显式关闭 source channel,等待 Run 完成,再确认只产生一个订单。这里用 channel 和完成通知同步,不用 sleep 猜测调度。

cmd/trade-demo/main_test.go · L106–L150 · 打开完整源码

func TestTradingPrefixReadsBeforeEOFWithoutBuying(t *testing.T) {
	code, err := os.ReadFile("../../examples/trading.py")
	if err != nil {
		t.Fatal(err)
	}
	m := newMarket("ACME", 100, 2000, true, 0, newTrace(io.Discard))
	started := make(chan string, 2)
	manifest := m.tools()
	for _, name := range []string{"quote", "cash"} {
		name, spec := name, manifest[name]
		original := spec.Call
		spec.Call = func(ctx context.Context, args json.RawMessage) (any, error) {
			started <- name
			return original(ctx, args)
		}
		manifest[name] = spec
	}
	r := testRunner(t, m, manifest)
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	ch := make(chan string, 1)
	ch <- string(code)
	done := make(chan error, 1)
	go func() { _, err := r.RunPrefix(ctx, ch, inputs(110, 10)); done <- err }()
	for i := 0; i < 2; i++ {
		select {
		case <-started:
		case <-ctx.Done():
			t.Fatal(ctx.Err())
		}
	}
	m.mu.Lock()
	attempts, orders := m.attempts, len(m.orders)
	m.mu.Unlock()
	if attempts != 0 || orders != 0 {
		t.Fatal("buy occurred before source EOF")
	}
	close(ch)
	if err = <-done; err != nil {
		t.Fatal(err)
	}
	if m.attempts != 1 || len(m.orders) != 1 {
		t.Fatalf("expected one post-EOF order: %d %v", m.attempts, m.orders)
	}
}

另一个测试直接尝试 prepare buy,要求返回 0 且没有尝试次数;随后即使 inputs 带有 approved,关闭 Host allowOrder 后的直接 buy 也必须失败且没有订单。这固定了“不能准备副作用工具”和“Python 不能自我授权”的边界。

cmd/trade-demo/main_test.go · L151–L167 · 打开完整源码

func TestTradingBuyCannotBePreparedOrSelfAuthorized(t *testing.T) {
	m := newMarket("ACME", 100, 2000, true, 0, newTrace(io.Discard))
	r := testRunner(t, m, nil)
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	source := `import _spine, json
result = _spine.prepare(json.dumps({"tool":"buy","args":{"symbol":"ACME","quantity":1}}))`
	out, err := r.RunPLM(ctx, source, nil)
	if err != nil || string(out.Value) != "0" || m.attempts != 0 {
		t.Fatalf("buy prepare: %s %v %d", out.Value, err, m.attempts)
	}
	m.allowOrder = false
	_, err = r.RunPLM(ctx, `result=buy(symbol="ACME",quantity=1)`, map[string]any{"approved": true})
	if err == nil || !strings.Contains(err.Error(), "Host -allow-order") || len(m.orders) != 0 {
		t.Fatalf("unauthorized direct order: %v %v", err, m.orders)
	}
}

trace 测试直接调用被拒绝的 buy,按顺序读取 tool.start → order.denied → tool.error,检查序号连续、elapsed 不倒退、时间可解析。它只证明真实工具边界被记录,没有把日志时间当作交易语义。

cmd/trade-demo/main_test.go · L168–L191 · 打开完整源码

func TestTraceRecordsRealToolBoundaries(t *testing.T) {
	var logs bytes.Buffer
	m := newMarket("ACME", 100, 2000, false, 0, newTrace(&logs))
	_, err := m.tools()["buy"].Call(context.Background(), json.RawMessage(`{"symbol":"ACME","quantity":1}`))
	if err == nil {
		t.Fatal("expected denial")
	}
	dec := json.NewDecoder(&logs)
	expected := []string{"tool.start", "order.denied", "tool.error"}
	previous := 0.0
	for i, kind := range expected {
		var e event
		if err := dec.Decode(&e); err != nil {
			t.Fatal(err)
		}
		if e.Event != kind || e.Sequence != i+1 || e.ElapsedMS < previous {
			t.Fatalf("event: %+v", e)
		}
		if _, err := time.Parse(time.RFC3339Nano, e.Time); err != nil {
			t.Fatal(err)
		}
		previous = e.ElapsedMS
	}
}

market 单元测试再把账户检查单独拉出来:第一次买 15 股后余额为 500,第二次相同请求被当前现金拒绝,订单仍只有一笔;cash 读取仍返回初始快照 2000。

cmd/trade-demo/market_test.go · L1–L27 · 打开完整源码

package main

import (
	"context"
	"encoding/json"
	"io"
	"testing"
)

func TestMarketRechecksCashAtEveryOrder(t *testing.T) {
	m := newMarket("ACME", 100, 2000, true, 0, newTrace(io.Discard))
	manifest := m.tools()
	args := json.RawMessage(`{"symbol":"ACME","quantity":15}`)
	if _, err := manifest["buy"].Call(context.Background(), args); err != nil {
		t.Fatal(err)
	}
	if _, err := manifest["buy"].Call(context.Background(), args); err == nil {
		t.Fatal("overspending accepted")
	}
	if len(m.orders) != 1 || m.remainingCash != 500 {
		t.Fatalf("orders=%v cash=%d", m.orders, m.remainingCash)
	}
	snapshot, err := manifest["cash"].Call(context.Background(), json.RawMessage(`{}`))
	if err != nil || snapshot != 2000 {
		t.Fatalf("snapshot changed: %v %v", snapshot, err)
	}
}

下一步

想看可复制的命令入口,读快捷脚本;想看普通 lookup 程序如何进入 Host,读 Host 工具边界 和 函数流程入口。读完本章应能回答:价格和现金什么时候被读取,什么条件才进入 buy,以及成功/拒绝分别会在 Host 留下什么状态。