跳转到内容

本地社区传播与制度回应

一个 1,000 人的集市和一个 2,000 人的村庄相邻。集市里已有 200 人支持一种新观念,还有一个小型本地组织在推广它。两天里,人际接触把这种观念传进村庄。两天之间,地方议事会选定一项公开政策,第二天生效。随后,一名观察者估计村里有多少人公开表态支持。

这个示例回答的问题是:怎样用整数人数表示不断变化的观念,使其能够精确存档、恢复和重放,同时让政策决策和观察者的视角都经过参伍的权限与可见性校验?

观念、cohort 和数字都是合成数据,不对应真实的教义或事件。

  • canwu-society:可选的模拟领域扩展 crate,需要在 canwu-api 之外另行添加依赖,自带类型、记录 schema、命令和输入类型,非正式地称为社会传播模拟模块。
  • 稀疏的人口状态:每个 cohort(SocietyCohort,某一地区内的人口群体)有一个整数人数。每个 cohort 与目标(AffiliationTarget,人们对其持有立场的观念或群体)的组合有一个 DispositionDistribution,把人数拆分到若干 DispositionBucket 中。
  • 传播:SocialInfluenceEdge 和 OrganizationNode 产生信号,TransitionRule 以整人为单位在倾向组合之间迁移人口,TransitionRemainder 保存不足一人的零头。
  • 由 institutional_policy_ticket 构造的决策票据,选中的选项会执行领域命令 set_institutional_policy。
  • 按 society 结算节奏运行的边界系统(本示例为每日,即 Daily):各自位于固定的结算阶段,负责应用政策、结算转化、汇总和物化投影。
  • projection_for_viewer 返回的角色相对估计。
  • 用 from_society_snapshot_json 恢复快照,以及 fork 和精确重放。
终端窗口
cargo run -p canwu-society --example local_community_diffusion

完整输出:

day 1: local diffusion: aware=118 assenting=5 public=0 hidden=2000 of 2000
policy selected; population dispositions remain unchanged until the next daily boundary
day 2: institutional response applied: aware=229 assenting=23 public=5 hidden=1995 of 2000
observer estimate: public=23 tied=20 (ground truth public=5 tied=0)
snapshot restore, fork, and exact replay reproduced the same authoritative state

这些计数都是村庄里针对目标 new-idea 的人数:aware 是听说过的人,assenting 是私下同情及以上的人,public 是公开顺从或倡导的人,hidden 是可见性为 Private 或 Hidden 的人,tied 是与某个组织有联系的人。

代码都在 local_community_diffusion.rs 中。main 复用已标记为 deprecated 的兼容场景 Canwu::demo(42) 中的实体、世界和知识,加入由 tutorial_state() 构造的一条 society 记录,注册 SocietyPlugin::DAILY,然后结算四个边界:

边界 调用位置 发生了什么
1 settle_day(&mut canwu, 1) 每日边界:第一天的传播
2 register_policy_decision 中的 step_canonical() 注册控制者,开启政策票据
3 drive_decision 之后的 step_canonical() 写入决策轨迹,执行 set_institutional_policy
4 settle_day(&mut canwu, 2) 每日边界:先应用政策,再进行第二天的传播

DispositionProfile 把七个维度分开记录。集市里的支持者使用下面这个倾向组合(profile):

let source = DispositionProfile {
awareness: AwarenessBand::Aware,
assent: AssentBand::Sympathetic,
practice: PracticeBand::Occasional,
public_alignment: PublicAlignmentBand::Advocating,
organizational_tie: OrganizationalTieBand::Member,
mobilization: MobilizationBand::Latent,
visibility: VisibilityBand::Public,
};

tutorial_state 为每个 cohort 建立一个针对 new-idea 的分布:

insert_distribution(
&mut state,
"market",
1_000,
vec![(neutral, 800), (source, 200)],
);
insert_distribution(&mut state, "village", 2_000, vec![(neutral, 2_000)]);

SocietyState::validate 要求同一分布中所有 bucket 的人数之和等于 cohort 人数。只有你插入的组合才有分布;转化规则作用到某个还没有分布的 cohort 时,会按需创建一个全员中性的分布。每个目标各有一份分布,所以同一个 cohort 可以对多个目标分别持有独立的立场。

村庄有两条 SocialInfluenceEdge 指向它:来自集市 cohort 的 market-contact,以及来自组织 local-network 的 network-contact。每条边为村庄的影响信号贡献 来源强度 × reach × trust / 1,000,000:集市中有 200‰ 的人支持这一观念,200 × 800 × 800 得 128;组织没有与其他组织的关联,强度就是基础覆盖 300‰,300 × 700 × 800 得 168。两者合计,影响信号为 296‰。

真正决定谁改变倾向组合的是三条 TransitionRule,第一条负责“知晓”:

insert_rule(
&mut state,
"01-awareness",
neutral,
aware,
TransitionWeights {
influence: 200_000,
..TransitionWeights::default()
},
);

solver.rs 中的 settle_transitions 把这个权重换算成速率 296 × 200,000 / 1,000 = 59,200(单位为百万分之一)。2,000 × 59,200 / 1,000,000 = 118.4,所以第一天有 118 人变为知晓,剩下的 0.4 人作为 TransitionRemainder 保存,第二天再计入。

02-assent 把知晓者转为同情者,由影响和制度支持推动,受政策强制抑制。03-public 把同情者转为公开顺从,只由制度执行力度和政策强制推动,而这两项初始都为 0。规则按规则 ID 顺序执行,每条规则再按 cohort ID 顺序处理各个 cohort,并且能看到前面规则已经完成的迁移。所以第一天 02-assent 就作用在刚变为知晓的 118 名村民身上,并迁移了其中 5 人。

register_policy_decision 把控制者 council-controller 绑定到指挥官的权限上,命令对象是政府。随后用 institutional_policy_ticket 开启票据;这个函数把每个 PolicyChoice 转成一个携带 set_institutional_policy 命令的决策选项:

PolicyChoice {
id: "public-conformity".to_owned(),
label: "Require public conformity".to_owned(),
decision: PolicyDecision {
alignment_id: "council-alignment".to_owned(),
decision_version: 1,
support_per_mille: 300,
enforcement_per_mille: 800,
access_grant_per_mille: 700,
},
utility_inputs: BTreeMap::from([("control".to_owned(), 100)]),
},

另一个选项 limited-access 的 enforcement_per_mille 为 0,control 输入为 10。main 用一个把 control 权重设为 1 的 WeightedUtilityPolicy 为票据打分,public-conformity 以 100 比 10 胜出。票据与控制者的机制和军阀请求邻军援助相同。

plugin.rs 中的 set_institutional_policy 处理函数只接受满足以下条件的命令:来自票据的控制者,针对拥有该制度立场(InstitutionalAlignment)的机构,代表该立场的 authorized_actor,并且 decision_version 比已应用和待应用的版本都新。它把决策存成一个待应用的插件组件,main 随即确认分布没有变化:

let immediately_after_decision = load_society_state(&canwu)?;
assert_eq!(
immediately_after_decision.distributions, before_policy.distributions,
"the policy choice must not instantly rewrite population dispositions"
);

settle_day 结算一个带 SystemCadence::Daily 的边界。Daily 正是本示例注册的结算节奏,所以 society 插件的结算系统 settle-social-transitions 在第 7 阶段随之运行:

  • apply_pending_policies 先把待应用的决策写到 council-alignment 上,再执行转化规则。
  • 同一个系统接着重新算出因它的变化而失效的内容,并只写一次根记录:动员候选(本次运行没有)、聚合状态(main 打印的计数),以及每个观察者的一份估计。

制度执行力度为 800‰ 时,03-public 的速率为 800 × 300,000 / 1,000 = 240,000(单位为百万分之一),23 名同情者中有 5 人转为公开顺从。

let viewer = canwu.viewer_context(ids.observer)?;
let projection = projection_for_viewer(&canwu, &viewer)?;

projection_for_viewer 先确认传入的 ViewerContext 与参伍为该角色签发的一致,再返回阶段 13 为该角色物化的 SocietyProjection。它只读取已物化的投影;没有投影的角色会得到 InvalidAuthority 错误。

观察者的 ObserverProfile 对公开表态的识别率为 800‰,误报率为 10‰。每项估计按 (真实人数 × 识别率 + 其余人数 × 误报率) / 1,000 计算,向下取整:

  • public:(5 × 800 + 1,995 × 10) / 1,000 = 23
  • tied:组织的隐蔽度 500‰ 把私下识别率 200 减半为 100,因此 (0 × 100 + 2,000 × 10) / 1,000 = 20

1,995 名村民中的误报压过了 5 名真实的公开顺从者,所以观察者高估了人数。

let saved = canwu.snapshot_json()?;
let restored = from_society_snapshot_json(&saved)?;
assert_eq!(restored.snapshot(), canwu.snapshot());
let forked = canwu.fork();
assert_eq!(forked.snapshot(), canwu.snapshot());
let replayed = Canwu::replay_from_journal(&[&plugin], &canwu.replay_journal())?;
assert_eq!(replayed.snapshot(), canwu.snapshot());

在引擎的通用快照检查之后,from_society_snapshot_json 会调用 validate_society_runtime:核对 society 记录的核心引用是否与 payload 中提到的实体一致,重新计算已保存的汇总、动员候选和投影并逐一比较,再根据输入日志重新推导所有排队中的 society 输入。零头是记录的一部分,所以恢复后的运行会带着同样的零头继续推进。

  • 政策决策在下一次 society 结算(本示例中即下一个每日边界)改变制度立场的信号;人口随后只通过转化规则迁移,因此制度执行力度对公开顺从和私下认同各有多大影响,由你写的规则权重决定。
  • 不足一人的零头保存在 TransitionRemainder 中,随状态一起存档,并在下一个边界继续累计。
  • 观察者读到的投影既有漏报,也有误报。客户端展示给玩家的就是这个估计。
  • 政策选择。 在 register_policy_decision 中把 limited-access 的 control 输入提高到 200。效用策略会改选 limited-access,它的执行力度为 0,于是 03-public 的速率为 0,第二天输出 public=0。
  • 观察精度。 把观察者的 false_positive_per_mille 设为 0。估计变为 public=4 tied=0,因为 5 × 800 / 1,000 向下取整为 4。
  • 影响来源。 把 network-contact 边的 active 设为 false。只剩集市的 128‰ 影响,第一天输出 aware=51。
  • 多模拟一天。 在读取投影之前调用 settle_day(&mut canwu, 3),观察第三天各项计数和估计如何变化。

当某个 bucket 达到 mobilization: MobilizationBand::Active 时,阶段 10 的系统会为该分布记录一个 MobilizationCandidate,其中包括动员人数、该目标的组织能力,以及作用于该 cohort 的政策强制。候选最终算作抗议、暴乱、迁徙还是别的情况,由你的政治或安全扩展判断。

示例注册的是 SocietyPlugin::DAILY,所以 society 在每个带 SystemCadence::Daily 的边界结算。要换成别的节奏,注册插件时指定即可:

let plugin = SocietyPlugin::with_settlement_cadence(SystemCadence::Monthly);
  • 节奏只是一个类别。 参伍不内置历法。Monthly 的 society 会在上层应用每次结算 Monthly 边界时结算,不管你的“月”有多长。想按周或其他周期结算,就挑一个别处用不到的类别(例如 EraScheduled),按这个周期安排边界。EventDriven 在注册时会被拒绝。
  • 速率每次结算生效一次。 速率要按你注册的节奏来写:一条速率算下来是百万分之 59,200 的规则(如上文的 01-awareness),不管两次结算相隔一天还是一个月,每次结算都迁移来源人数的这个比例。零头精确结转,舍入误差不会累积。
  • 一切都等这个节奏。 政策决策、压力修订、到期的 cohort 转移、人口校准和文化生命周期变化,都只在结算时应用。如果这类变化必须在一天之内生效,就保留 Daily 节奏。
  • 到期转移默认批量结算。 同一周期内的每个签发者看到的都是同一个状态,所以 SocietyPlugin::DAILY、default() 和 with_settlement_cadence 默认使用 TransferSettlement::Batch:每笔到期转移都按结算开始时的状态检查,仍然成立的按准入顺序逐笔应用,每笔都在前面几笔留下的状态上执行;它准入之后、更早的某次结算改动了它涉及的 cohort 的转移结算为 stale_source,数量已经放不进来源 cohort 的转移结算为 insufficient_source。SocietyPlugin::with_transfer_settlement(TransferSettlement::OneByOne) 恢复 0.15.0 的规则:每笔到期转移按同一次结算中更早的转移留下的状态检查,涉及同一个 cohort 的到期转移只有一笔完成,其余结算为 stale_source,需要重新提交。节奏较粗,或者两次结算之间可能有多笔转移把人口移出或移入同一个 cohort 时选 Batch;每笔转移都必须看到前一笔的结果时选 OneByOne。模式是插件身份的一部分,恢复和重放要用同一模式的插件。
  • 什么都没改变的结算不写入任何内容。 闲置的 society 不会写出新的记录版本,也不会留下记录变更证据。观察者的投影及其 observed_at 时间,只在这位观察者自己的估计发生变化时才会改变。
  • 节奏随模拟运行一起保存。 它是插件描述的一部分,所以恢复和重放必须使用同一节奏的插件。from_society_snapshot_json 按 Daily 恢复;其他节奏要调用 from_society_snapshot_json_with_plugin(&saved, &plugin)。
  • 节奏越粗,社群输入队列等得越久。 队列最多容纳 4,096 条输入(MAX_SOCIETY_INGRESS_QUEUE)。队列已满时到达的输入由准入它的边界拒绝:cohort 交换账本中记下终态的 queue_full 结果,并发出一个 society_ingress_rejected_v1 事件(压力修订则是 society_pressure_revision_rejected_v1),需要重新发送;队列已满期间,revise_society_pressure 命令在准入时就被拒绝。队列是一条很小的头记录,加上每个准入了输入的边界各一条批次记录(SocietyIngressBatch,用 society_ingress_queue_entries 读取),所以不管队列等多久,它的证据都只随各边界准入的数量增长。

set_institutional_policy 只改一个制度立场上的三个字段。要在模拟运行期间调整压力本身,机构使用 revise_society_pressure 命令。命令的载荷是一个 PressureRevisionDecision;pressure_revision_ticket 负责构造决策票据,用法与构造政策票据的 institutional_policy_ticket 相同。在这个示例里,议事会可以这样切断组织与村庄的接触:

PressureRevisionDecision {
alignment_id: "council-alignment".to_owned(),
decision_version: 1,
expected_version: 0,
policies: Vec::new(),
influence_edges: vec![InfluenceEdgeRevision {
edge_id: "network-contact".to_owned(),
active: false,
reach_per_mille: 700,
}],
organizations: Vec::new(),
}

机构通过某个制度立场,只能修订作用在该立场所影响 cohort 上的内容:

  • 同一目标下由这个机构自己签发(issuer)、且影响的 cohort 都在该立场范围之内的 PolicyPressure 条目,可以修订它的八个压力字段。没有签发者的条目(例如本示例中的 local-policy)不能修订;
  • 同一目标下、目标 cohort 在该立场范围之内的影响边,可以修订 active 和 reach_per_mille;
  • 同一目标下的组织节点可以修订 active 和 base_reach_per_mille,但只能通过覆盖全部 cohort 的立场,因为组织会作用于其目标下的每一个 cohort。

一项决定要写明它所修订的每个条目的全部字段,最多修订 256 个条目,要么整体生效,要么完全不生效。它不会新增或删除条目,也不会改动转化规则。每个制度立场有自己的修订版本序列:决定的 decision_version 比上一次已应用的版本新,且 expected_version 等于上一次已应用的版本(第一次之前为 0)时,决定才会应用。

命令把决定作为一条内部输入排入队列。下一个边界准入这条输入,此后的第一次 society 结算在执行转化规则之前应用它;set_institutional_policy 则要早一个边界生效。结果保存在 SocietyCohortExchangeLedger::pressure_revisions 中,同时发出 society_pressure_revised_v1 或 society_pressure_revision_rejected_v1 事件。各种情形见压力修订测试。

  • 政策来源。 PolicyPressure 可以记录签发者 issuer 和产生这项压力的决策版本 decision_version,见人口校准与政策来源测试中的 gap_g31_society_policy_pressure_provenance。
  • cohort 人口校准。 这种输入(cohort_headcount_rebase_v1)按所引用的、由另一个插件拥有的人口记录重置某个 cohort 的人数,并在下一次 society 结算时按比例重算它的分布,见同一组测试中的 gap_g32_society_cohort_rebase。
  • 被拒绝的命令。 society 命令在预期之内的拒绝,会记录为一次被拒绝的命令尝试,所在边界照常提交。例如,重发的政策决策会以 IdempotencyConflict 被拒绝,指向不存在的 cohort 的转移会以 EntityNotFound 被拒绝。重发的转移或压力修订会被接受,但不产生任何变化。
  • 文化生命周期。 canwu-culture 把文化目标装入 society 状态,并让目标在 Active、Dormant、Retired 之间转换,详见社会、文化与法律。

打开可运行示例

阅读社会框架测试

阅读人口校准与政策来源测试