跳转到内容

粮食、生产与军事补给

河谷里的一座粮仓开局存粮 4,300 筐。每个月,管粮人在四种优先顺序中选一种(优先赈济、 优先军粮、平衡分配、为军队征发),粮仓要应付三项需求:民食 320 筐、赈济 120 筐、 驻军调粮 1,200 筐。前两个月河道不通,不发军粮。唯一一次运粮在第 3 个月:渡口中断, 粮船改走陆路送到驻军手里。从第 4 个月起,调粮需求每月仍要求预留粮食,但不再发运; 缺粮的月份里如果它排在最前,就会挤占民食。第 10 个月月末秋收入仓。

这个示例回答的问题是:几个领域共用同一份存量时,怎样让每一筐粮都有账可查、 相互竞争的需求按记录下来的顺序结算,并且整个运行可以精确重放?

所有数量都是为示例选定的合成值,不代表历史上的人口、亩产、军粮或粮价。

  • 资源模拟扩展(canwu-resource):账户与需求、 按优先级进行的一次分配,以及在验收前一直处于托管中的转移。
  • 决策票据(DecisionTicket):每个月的 优先顺序是票据中的一个选项,选项携带一条普通命令。
  • 运输执行(TransportExecution):河运一程失败后, 同一批货通过改道(ItineraryRevision)继续运送。
  • 军事补给参考消费者(canwu-force-supply-reference): 驻军提交消费意图,从自己的账户里取粮;第 5 个月的征发会完整走一遍 saga。
  • 参考内容包(canwu-economy-reference-content): synthetic_grain_fixture() 提供驻军的口粮需求和征发策略,每项都关联一张模型卡。 民食、赈济和收成的数值是 grain.rs 中的常量。
Terminal window
cargo run -p canwu-economy-reference --example grain_loop

程序以 JSON 打印一个 GrainLoopSummary:每月一帧(共 836 行),最后是汇总。 下面的输出做了删减,只保留第 3 个月那一帧的部分字段和汇总:

{
"frames": [
...
{
"economy": "canwu.economy-reference:economy:river-valley",
"month": 3,
...
"force_requested": 1200,
"force_fulfilled": 1200,
...
"force_operation": "canwu.force-supply-reference:operation:287:89280",
...
},
...
],
"final_stock": 2206,
"final_population_wellbeing_per_mille": 840,
"final_force_readiness_per_mille": 800,
"final_cooperation_per_mille": 859,
"total_harvest": 3966,
"transport_executions": 1,
"closed_route_months": [
1,
2
],
"rerouted_months": [
3
],
"conservation_closing": 2206,
"checkpoint_hash": "2e04345348f3d7b30ced46a653833a9e9be7403edf447badfc218fee1c8044f1"
}

同一次运行中十四帧的关键字段:

月份 决策 月初存粮 民食(需 320) 赈济(需 120) 驻军调粮(需 1,200) 收成 民生 ‰ 合作度 ‰
1 balanced 4300 320 120 0 0 1000 903
2 relief_first 3860 320 120 0 0 1000 906
3 balanced 3420 320 120 1200 0 1000 909
4 force_first 1780 320 120 0 0 1000 912
5 requisition_for_force 1340 140 0 0 0 820 832
6 balanced 1200 320 120 0 0 940 835
7 relief_first 760 320 120 0 0 1000 838
8 balanced 320 320 0 0 0 1000 841
9 force_first 0 0 0 0 0 680 844
10 balanced 0 0 0 0 3966 360 847
11 relief_first 3966 320 120 0 0 480 850
12 balanced 3526 320 120 0 0 600 853
13 force_first 3086 320 120 0 0 720 856
14 balanced 2646 320 120 0 0 840 859

月初存粮是本月分配之前粮仓的余额。民食每缺一筐,民生值减 1;赈济每发一筐,民生值加 1, 上限 1000。合作度每月加 3;第 5 个月例外,征发让它减少 80。

粮食循环:票据确定优先级,一次分配切分粮仓存量,第 3 个月运粮给驻军,第 10 个月收成入账. 查看图表源码.
查看图表源码
flowchart TD
  Ticket["每月决策票据"] -->|优先级| Alloc["一次资源分配"]
  Demands["需求:民食、赈济、驻军调粮"] --> Alloc
  Granary["粮仓账户"] --> Alloc
  Alloc --> Consume["消费:民食与赈济"]
  Alloc -->|"仅第 3 个月"| Transfer["转移进入托管"]
  Transfer --> Route["河运一程失败,改走陆路"]
  Route -->|验收| Garrison["驻军账户"]
  Garrison --> Force["军事补给消费意图"]
  Harvest["第 10 个月收成入账"] --> Granary
  Consume --> Close["CloseMonth 月度帧"]
  Force --> Close

示例本身只有 23 行:把十四个 GrainDecision 传给 GrainHarness::run_fourteen_months,再打印汇总。具体工作都在 grain.rs 里完成。

GrainHarness::new 先用 compile_content_pack 编译 synthetic_grain_fixture(), 再建立粮仓账户(4,300 筐,由管粮人保管)和一个空的驻军账户(由军队保管), 然后把资源、经济和军事补给三份状态作为 Scenario 的初始 领域记录。new_canwu 接着注册插件:

let resource = ResourcePlugin::new([economy_kind, force_kind]);
let economy = EconomyReferencePlugin;
let force = ForceSupplyReferencePlugin;
let plugins: [&dyn SimulationPlugin; 3] = [&resource, &economy, &force];
Canwu::new_with_plugins(seed, scenario, &plugins)

ResourcePlugin::new 的参数是资源插件核验适配器证据时要读取的领域记录类型, 这里就是经济和军事补给两个插件的运行时记录。

advance_month 用三张决策票据记录本月的选择。record_decision 开启一张有四个选项的 票据,每个选项携带一条 EconomyOperationV1::SelectDecision 命令,并按你传入的选项结算; 输出中的决策就是它,合作度也随它变化。record_g5_decision 把同一选择记为粮仓的应对姿态, 第 3 个月起 record_force_decision 再把它记为驻军的补给姿态。应对姿态决定每个需求的 优先级:

fn decision_priority(decision: GrainDecision, label: &str) -> i32 {
match (decision, label) {
(GrainDecision::ReliefFirst, "relief")
| (GrainDecision::ForceFirst | GrainDecision::RequisitionForForce, "force") => 120,
(_, "civilian") => 100,
(_, "relief") => 90,
_ => 80,
}
}

票据结算时准入它携带的命令,所属插件在下一个结算边界才执行这条命令。advance_month 先跑完 这两个结算边界,再读取姿态,所以每个月的分配用的就是当月的选择。requisition_for_force 把 驻军的补给姿态设为 requisition_locally,这也会改变驻军取粮的方式(见第 5 步)。

3. 提交三项需求,只做一次分配

Section titled “3. 提交三项需求,只做一次分配”
// All three uses are manager-owned demands for the same exact grain
// revision and become due at the same simulation instant. One
// canonical allocation ingress therefore decides scarcity by persisted
// priority/tie-break data rather than by Rust control flow.
let manager = holder(1);
let civilian = self.submit_demand(
month,
"civilian",
manager.clone(),
CIVILIAN_NEED,
decision_priority(resilience_decision, "civilian"),
None,
)?;

赈济(RELIEF_TARGET,120)和驻军调粮(SHIPMENT_QUANTITY,1,200)也照此提交。 allocate_competing 排入一次 enqueue_resource_allocation,canwu-resource 为能满足的 需求记录资源预留。每项需求在到期一个月后过期,届时释放它 仍占着的预留。

这些需求使用默认的来源策略 ResourceDemandSourcePolicyV1::Pooled,可以按账户 ID 顺序 动用所有粮食账户。第 4 个月调粮需求排在最前,所以它先预留驻军账户 a-field-force 里 剩下的 40 筐,再从 z-granary 预留 1,160 筐。

record_route_availability(month, month >= 3, true) 把前两个月的河道标记为不通, 这两个月的驻军调粮需求随之取消("force-before-readiness")。第 3 个月, deliver_to_force 用 BeginTransfer 把已预留的粮食扣入转移托管,开始河运一程, 然后让这一程失败:

execution
.fail_current_leg("river crossing closed".to_owned(), self.canwu.time())
.map_err(transport_error)?;
let alternate = route_plan(self.canwu.time(), month, true)?;

同一个运输执行以 ItineraryRevisionReason::Disaster 改道,经 ridge-post 走陆路。 承运人在中转点交接,转移先后进入 InTransit 和 ArrivalPending。只有 CompleteTransfer 以 ResourceTransferDispositionV1::Accept 结算之后,驻军账户才会入账。 每个不可逆步骤(发起、验收、消费、入账)都带有完成租约凭证(证明持有人已激活执行该操作的 一次性权利),详见生产、资源与运输的职责划分。

民食和赈济走 consume_local:先由经济插件批准一项消费意图,再经适配器输入发送 ResourceOperationRequestV1::Consume。驻军走 service_force_if_due 这条独立路径: 军事补给插件针对自己的账户提交消费意图,资源结果再经插件输入回传给它。

let operation = self.submit_force(ForceOperationV1::SubmitConsumptionIntent { intent })?;

第 4 到第 14 个月,驻军调粮需求照样参加分配,但 deliver_to_force 只在第 3 个月运行, 所以 force_fulfilled 一直是 0。到第 5 个月,驻军账户只剩 40 筐,缺粮让驻军战备值 下降 100‰(从 900 到 800)。此后账户见底,service_force_if_due 在没有粮可发时直接 返回,战备值停在 800。

驻军姿态为 requisition_locally 时,consume_for_force 会把 synthetic_grain_fixture() 提供的征发策略附在驻军的消费意图上,从而在 canwu-force-supply-reference 中开启一个征发 saga。资源结果和 战备后果落定之后,军队记下一项外部性意图;经济插件把它施加到本地经济上(合作度减少 80‰,下一次收成扣减 60‰;这两个数值来自 synthetic_grain_fixture() 中征发定义的 cooperation_cost_per_mille 和 next_harvest_input_cost_per_mille,grain.rs 经济配置里的 requisition 只做记录,没有代码读取),并发布结果;军队确认这一结果,最后由 FinalizeRequisition 结清 saga。第 5 个月正是用驻军账户里最后 40 筐粮走完这条路径。驻军账户见底之后再选征发, 没有粮可发,也就不会开启 saga。

经济插件只在本地经济的修订号仍与它加入这次征发的完成租约时相同的情况下,才施加这项 外部性。如果其间有别的命令改动了本地经济,结果就是 Rejected,本地经济保持原样; requisition_externality_rejects_a_local_economy_changed_after_its_lock 测试的正是这一点。

第 10 个月,credit_harvest 这样计算收成:

let quantity = HARVEST_BASE
.saturating_mul(u64::from(local.cooperation_per_mille))
.saturating_mul(u64::from(
1_000_u16.saturating_sub(local.pending_harvest_penalty_per_mille),
))
/ 1_000_000;

然后以 ResourceCreditSourceV1::ExternalInflow 计入粮仓。第 9 个月末合作度为 844, 第 5 个月的征发又留下 60‰ 的收成惩罚,因此收成是 5000 × 844 × 940 / 1,000,000 = 3966 (向下取整)。如果没有征发,合作度会是 927,收成是 4,635。每个月都以 EconomyOperationV1::CloseMonth 结束:它核对所引用的需求和结果记录,再追加一帧, 也就是输出里看到的那些帧。summary 在生成汇总之前调用 ResourceState::validate_conservation。

  • 只有 canwu-resource 能改动余额。驻军提交的是消费意图,收成则作为带证据的入账到达。
  • 到达和验收是两个步骤。只有 CompleteTransfer 验收之后,驻军账户才有变化。
  • 相互竞争的需求在一次分配中按保存下来的优先级和平局决胜键结算。第 5 个月, requisition_for_force 把调粮需求排在最前:它从 1,340 筐中预留了 1,200 筐,民食只分到 140 筐,赈济一筐也没有。这批粮不会发运,要等需求过期才释放。第 9、10 个月粮仓见底, 收成要到第 10 个月月末才入账。
  • conservation_closing 等于 final_stock(2,206),因为没有转移还停留在托管中。 运行结束时,2,206 筐全在粮仓,其中 1,200 筐预留给第 14 个月的调粮需求。
  • 征发给驻军的粮并不比 force_first 多,因为驻军只从自己的账户取粮。征发多出来的是 代价:合作度从 912 降到 832,五个月后的收成是 3,966 筐,而不是 4,635 筐。grain_harness 中的 requisition_branch_is_reproducible_and_carries_future_cost 检查这份代价, canwu-force-supply-reference 中的 requisition_keeps_resource_force_externality_and_ack_as_distinct_steps 则逐一测试 saga 的每个步骤。
  • 决策序列。 修改 grain_loop.rs 里的 decisions 数组,观察 civilian_fulfilled 和 population_wellbeing_per_mille 怎样变化。把 requisition_for_force 挪到第 6 个月或 之后,调粮仍会排在最前,那个月民食可能短缺;但驻军账户已经见底,不会开启 saga,合作度 不会减少 80,收成也不受惩罚,只是那个月少加 3 点合作度。
  • 收成。 把 grain.rs 中的 HARVEST_BASE 改为 6_000。第 10 个月的 harvest_output 会变成 6000 × 844 × 940 / 1,000,000 = 4760(向下取整)。
  • 种子底线。 经济配置中记录了 seed_floor: 180,但没有代码读取它;两个粮食账户 也都没有受保护底线(protected_floor_policy: None),所以第 8 个月末粮仓被取空。 在这里加一个受保护存量底线(ProtectedFloorPolicyRevision) 并不只是让粮仓账户指向它:只要需求的 protected_floor_policy 与它所动用账户的不一致, 分配就会以 VersionConflict 失败。这个循环里的需求都使用默认的 Pooled 来源策略 (见第 3 步),会同时动用两个粮食账户,而 grain.rs 中的 submit_demand 创建需求时 总是设为 None。因此两个账户和每项需求都需要使用同一个策略修订。canwu-resource 中的 conservation_partial_minimum_and_protected_floor_are_enforced 给出了一个可以运行的单账户 设置:底线会留住存量,而 protection_override_class 在该策略 override_classes 中的需求 仍可以动用底线以下的存量。
  • 存档与重放。 运行 cargo test -p canwu-economy-reference。 snapshot_checkpoint_journal_and_fork_continue_identically 在第四个月后给循环做快照, 分别从快照恢复、从检查点日志重放、派生分支,再各推进两个月,检查四份副本的 checkpoint_hash 一致。

完整的 API 规则见生产、资源与运输的职责划分。

生产模拟扩展(canwu-production)负责工艺、生产地点、工单和 产出结算,ProductionState::blockers_for 会指出每个仍缺少证据的需求组 (cargo run -p canwu-production --example process_constraints)。 在制品(WorkInProgress)记录一次执行的进度和已消耗的投入, 存量本身仍在 canwu-resource 中;粮食循环没有运行生产工艺。

参考内容用 ResourceCapabilityRevision 描述一处资源,其 资源能力阶段(ResourceCapabilityStage)从 Potential 经 RouteAccessible 到 DeliveredAccepted。即使到了 RouteAccessible,存量也要经过 运行时的路线观察和目的地验收才能进入账户。

record_g5_decision 每个月把管粮人的投影放进应对姿态票据的上下文。 ProjectionProviderRegistryV1::project(canwu, holder, scope) 汇总该持有人的资源、经济、 生产和军队见证,生成这份投影:成功时返回带 EconomyProjectionV1 的 ProjectionQueryResultV1::Available,否则返回带阻塞代码(例如 scope_unconfigured 或 provider_unavailable)的 Unavailable。投影的两部分都是只读模型,生成它们不会改动任何 状态,也不会结算任何交易。

  • 本地稀缺度投影(LocalScarcityProjection)涵盖该持有人能 观察到的存量、未满足需求和缓冲。只有持有人也能到达的存量才计入供给;路线未观察到或 无法通行的远处存量记入 excluded_unreachable_* 字段。scarcity_per_mille 是已知供给 相对已知需求余量的缺口,以该余量的千分比表示,再加上路线和安全惩罚,上限为 2,000。 投影会列出成因。
  • 价格压力投影(PricePressureProjection)只有在经济配置接受 价格证据、并且存在生效中的价格证据时,才带有 pressure_per_mille。价格证据包括已成交的 交换、报价、官定价格或合同价格,每条都带解释规则和来源版本。只有一个因素时状态为 Observed,有多个因素时为 InferredPressure;没有证据时为 ExplicitUnknown;经济配置 排除价格时为 NotApplicable。本例的经济配置设为 price_applicability: NotApplicable, 所以这里是 NotApplicable。

canwu-economy-reference-content 还带有两个有出处、但没有示例运行的 fixture:1450–1644 年 松江与长江下游的明代棉纺织原型,以及 1896–1911 年汉阳铁厂、大冶铁矿和萍乡煤矿的原型; 它们连同出处都在 fixtures.rs 中。

打开可运行示例

阅读粮食循环测试

阅读军事补给测试

浏览模型卡与出处