@furongjun1999/dsh-memory 0.7.3 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,221 @@
1
+ # -*- coding: utf-8 -*-
2
+ """状态槽位寄存器投影(L2「主体×谓词槽位」)——**事件是源、槽位是投影**。
3
+
4
+ 设计稿:`docs/plans/语义时空图补全_世界模型功能端_设计_v0.1.md` §2/§3(P2 批)。
5
+
6
+ 本模块是**投影,不是第二真源**:
7
+ · 源=`md_cg/state_events.py` 的 append-only 台账 `<root>/_state_events.jsonl`;
8
+ · 查询时**现算**——不落盘、不建缓存、不写任何盘面文件;
9
+ · 台账追加一条,投影立刻随之变化(无缓存、无物化表 ⇒ 无第二真源、无漂移面);
10
+ · 本模块**只读**台账(经 `state_events.read`;`append` 归 `state_events`)。
11
+
12
+ 投影单元字段(设计稿 §3 表逐字):
13
+ subject / slot / value / state(active|retired) / t_from / t_to /
14
+ seq_from / seq_to / tier / alternatives / blindspots / history / event_count
15
+
16
+ 回放规则(分组键 `(subject, slot)`,组内按 `state_events.read()` 的**落盘序**):
17
+ · `value` 自 None 起:事件 `new` 非空 → `value=new`;`new` 空(撤回)→ `value=None`
18
+ 且 `state=retired`;其后带值事件自然复活(事件序自然处理,无特判分支)。
19
+ · `state`:**最后一个事件的形态**决定——`new` 空 → `'retired'`;否则 `'active'`。
20
+ · 区间(取值一律用事件行的 `t` 落账时间戳,不另算时间):
21
+ `t_from`/`seq_from` =**最后产生当前值**的那条事件的 `t`/`seq`(retired 态=
22
+ 最后一个**有值**事件的);`t_to`/`seq_to` =**覆盖它的下一条事件**的 `t`/`seq`
23
+ (不存在=None,即「未闭合/仍有效」);retired 态的覆盖者=撤回事件(闭合点
24
+ =值作废时刻)。
25
+ · `tier` =最后有值事件的顶层 `tier` 透传(`append(extra=…)` 会把 extra 并入行
26
+ 顶层,故「extra.tier」与「行顶层 tier」是同一处);缺失(键不在或为 None)→
27
+ `TIER_UNSPECIFIED`——**不编造**。
28
+ · `alternatives` / `blindspots`:本批**结构在场、恒为空列表**——「同期未决值」与
29
+ 「声明缺口」的判定口径**后置**(P1 事件行尚无该信号),**不得臆造填充**。
30
+ · `history` =该槽位全部事件原样(每条=事件行的 `t/seq/old/new/kind/evidence/
31
+ actor` 七字段,值不改写;缺键取 None);`history=False` 时**不带该键**。
32
+
33
+ 撤回判据取 **payload**(`new` 为空)而不是 `kind` 标签:台账口径是「撤回=old 有值而
34
+ new 为空」(`state_events` 模块头),且该模块明言「分类本身不进判据(记账不判语义)」
35
+ ——两者一致时等价;冲突时以 payload 为准(不按标签改写数据)。判据单点=`_is_void`。
36
+
37
+ 过滤与排序:`subject`/`slot` 为 None 不加过滤(该过滤**委托** `state_events.read`,
38
+ 此处不写第二套条件解析);`include_retired=False` 剔除 `state=='retired'` 的单元;
39
+ 结果按 `(subject, slot)` 字典序稳定排序。
40
+
41
+ CLI(纯只读人工入口,**恒可用、无开关**):
42
+ python -X utf8 -m md_cg.state_slots --status
43
+ python -X utf8 -m md_cg.state_slots --subject 鲸娘 [--slot 住所]
44
+ 数据根取 `MDCG_ROOT`(缺省走 `datapath.mdcg_root()`,与 `md_cg/sleep.py` 的
45
+ `_main` 同形)。本面与 stg 的 `MDCG_STG_STATE` 开关**无关**:开关管的是 MCP 读面,
46
+ CLI 是人工排查入口(只读、不改任何状态)。
47
+
48
+ 不适用条件:本模块**不判语义**——「不是事实」的言语(玩笑/元层/回忆转述)之消歧
49
+ 标记(`not_fact` 等)不在本批(P1 事件行无该信号);`alternatives`/`blindspots` 的
50
+ 真值判定亦未落地(见上,恒空列表)。跨库/多租户合并不在此(单库=单台账)。
51
+ """
52
+ from __future__ import annotations
53
+
54
+ import argparse
55
+ import json
56
+ import os
57
+ import sys
58
+
59
+ from . import state_events
60
+
61
+ #: 缺档位占位(台账未带 `tier` 时如实标注,不编造档位)。
62
+ TIER_UNSPECIFIED = "unspecified"
63
+
64
+ #: `history` 条目**必在**字段(单元 schema 逐字;缺键取 None,不改写数据)。
65
+ _HIST_FIELDS = ("t", "seq", "old", "new", "kind", "evidence", "actor")
66
+
67
+
68
+ # 生效条件:rec 为台账记录 dict——`rec.get("new")` 为 None 时返回 True(撤回/值作废),非 None 时返回 False;入参非 dict 时按 `.get` 抛 AttributeError(台账读面保证为 dict,不吞错)。
69
+ def _is_void(rec) -> bool:
70
+ """撤回判据(单点):`new` 为空=值作废、无新值(口径见模块头)。"""
71
+ return rec.get("new") is None
72
+
73
+
74
+ # 生效条件:rec 为一条台账记录 dict 时,返回含 _HIST_FIELDS 七键(缺键取 None)的新 dict——字段集=单元 schema 的 history 条目逐字(不并入行内其余顶层键:新增键即「发明字段」,故限定枚举);值原样不改写、不改写入参。
75
+ def _hist_item(rec) -> dict:
76
+ """变迁史单条:单元 schema 的七字段原样(值不改写)。"""
77
+ return {k: rec.get(k) for k in _HIST_FIELDS}
78
+
79
+
80
+ # 生效条件:subject/slot 为分组键、recs 为同组记录(**非空**,落盘序)、history 为真值时——按模块头回放规则返回单元 dict(含 history 键);history 为假值时同一单元但不带 history 键;recs 为空时抛 IndexError(组由记录现场建成,恒非空,不吞错)。
81
+ def _replay(subject, slot, recs, *, history=True) -> dict:
82
+ """单组回放:现值 / 档位 / 区间 / 变迁史(规则逐条见模块头)。"""
83
+ value = None
84
+ last_valued = None # 最后**产生当前值**的事件下标(无则 None)
85
+ for i, rec in enumerate(recs):
86
+ if _is_void(rec):
87
+ value = None # 撤回:值作废、无新值
88
+ else:
89
+ value = rec.get("new")
90
+ last_valued = i
91
+ last = recs[-1]
92
+ retired = _is_void(last) # state:最后一个事件的形态决定
93
+ if last_valued is None:
94
+ # 全程无有值事件(孤立撤回):起端无可溯源 ⇒ None(不编造)。
95
+ t_from, seq_from = None, None
96
+ tier = TIER_UNSPECIFIED
97
+ else:
98
+ lv = recs[last_valued]
99
+ t_from, seq_from = lv.get("t"), lv.get("seq")
100
+ tier = lv.get("tier")
101
+ if tier is None:
102
+ tier = TIER_UNSPECIFIED
103
+ # 覆盖者:retired 态=撤回事件(最后一条,闭合点=值作废时刻);否则=有值事件的
104
+ # **下一条**事件(存在则闭合,不存在=None=未闭合);两者共用同一取舍。
105
+ nxt = (len(recs) - 1) if retired else (
106
+ None if last_valued is None else last_valued + 1)
107
+ if nxt is not None and 0 <= nxt < len(recs):
108
+ t_to, seq_to = recs[nxt].get("t"), recs[nxt].get("seq")
109
+ else:
110
+ t_to, seq_to = None, None
111
+ unit = {"subject": subject, "slot": slot, "value": value,
112
+ "state": "retired" if retired else "active",
113
+ "t_from": t_from, "t_to": t_to,
114
+ "seq_from": seq_from, "seq_to": seq_to,
115
+ "tier": tier,
116
+ # 结构在场、恒空:判定口径后置(模块头,不得臆造填充)。
117
+ "alternatives": [], "blindspots": [],
118
+ "event_count": len(recs)}
119
+ if history:
120
+ unit["history"] = [_hist_item(r) for r in recs]
121
+ return unit
122
+
123
+
124
+ # 生效条件:recs 为台账记录序列(落盘序,可空)时——按 (subject, slot) 分组(组序=首现序)后按 (str(subject), str(slot)) 字典序稳定排序,逐组经 _replay;include_retired 为假时剔除 state=='retired' 的单元,为真时全留;返回单元列表(recs 为空返回 []);history 透传 _replay(假时不带 history 键)。
125
+ def _project(recs, *, include_retired=False, history=True) -> list:
126
+ """记录序列 → 单元列表(project 与 status 共用的回放单点)。"""
127
+ groups = {}
128
+ for rec in recs:
129
+ key = (rec.get("subject"), rec.get("slot"))
130
+ groups.setdefault(key, []).append(rec)
131
+ # 排序键经 str() 归一:台账是外部可写面(append-only),非字符串 subject/slot 会让
132
+ # 裸元组比较抛 TypeError;字符串形态(正常)下与裸元组字典序逐位一致。
133
+ out = []
134
+ for key in sorted(groups, key=lambda k: (str(k[0]), str(k[1]))):
135
+ unit = _replay(key[0], key[1], groups[key], history=history)
136
+ if unit["state"] == "retired" and not include_retired:
137
+ continue
138
+ out.append(unit)
139
+ return out
140
+
141
+
142
+ # 生效条件:cg 可解析出 root(经 state_events.ledger_path)时——以 state_events.read(cg, subject=subject, slot=slot) 为源(subject/slot 非 None 才过滤,空串亦为合法过滤值)、现算回放,返回单元列表;include_retired=False 剔除 state=='retired' 的单元;history=False 时单元不带 history 键;台账缺失/为空返回 [](不报错)。**零落盘、零缓存**——同一调用序列中台账追加一条,下一次调用结果即变。
143
+ def project(cg, subject=None, slot=None, include_retired=False,
144
+ history=True) -> list:
145
+ """槽位寄存器投影(查询时现算):list[dict](单元字段见模块头)。
146
+
147
+ 纯读函数:不改台账、不写盘、不缓存;过滤委托 `state_events.read`(单点),
148
+ 本模块不另写一套条件解析。
149
+ """
150
+ recs = state_events.read(cg, subject=subject, slot=slot)
151
+ return _project(recs, include_retired=include_retired, history=history)
152
+
153
+
154
+ # 生效条件:cg 可解析出 root 时——一次 state_events.read(cg) 后返回只读统计 dict:events(台账记录总数)/ subjects(去重 subject 数)/ slots(去重 slot 数)/ retired(回放后 state=='retired' 的单元数)/ bad_rows(state_events.BAD_ROWS 累计值,坏行采样与告警由该模块负责);台账缺失/为空时 events/subjects/slots/retired 皆 0。
155
+ def status(cg) -> dict:
156
+ """台账与投影的只读读数(不计语义、不改任何状态)。
157
+
158
+ 只读一次台账后回放:`bad_rows` 的口径是「累计**跳过动作次数**」(同一坏行每次
159
+ read 均计一次,见 `state_events`)——本函数每次调用只产生**一次** read。
160
+ `slots` 为去重**槽位名**数(同一槽位名挂在多个主体下只算一次);
161
+ `retired` 为回放后退役单元数(值已作废、未复活)。
162
+ """
163
+ recs = state_events.read(cg)
164
+ units = _project(recs, include_retired=True, history=False)
165
+ return {"events": len(recs),
166
+ "subjects": len({r.get("subject") for r in recs}),
167
+ "slots": len({r.get("slot") for r in recs}),
168
+ "retired": sum(1 for u in units if u["state"] == "retired"),
169
+ "bad_rows": state_events.BAD_ROWS}
170
+
171
+
172
+ # 生效条件:root 给定且 md_cg.mdcos 可导入时返回该 root 上的 MdCGOS 实例(形态同 md_cg/sleep.py:_open_cg);导入失败抛原异常。
173
+ def _open_cg(root: str):
174
+ from .mdcos import MdCGOS
175
+ return MdCGOS(root)
176
+
177
+
178
+ # 生效条件:传入 argv(None 取 sys.argv)——--status 时打印 status(cg) 的 JSON 并返回 0;否则要求 --subject(可配 --slot)或 --slot 单用,打印 project(cg, subject, slot, include_retired, history) 的 JSON 并返回 0;两者皆缺时打印**全量投影**(含退役需显式 --include-retired)并返回 0;--root 缺省取 datapath.mdcg_root()(env MDCG_ROOT);argparse 对未知参数照常以 SystemExit(2) 退出(不吞)。
179
+ def _main(argv=None) -> int:
180
+ """只读人工入口:台账现状(--status)/ 槽位投影(--subject [--slot])。
181
+
182
+ 本入口**恒可用**(不受 `MDCG_STG_STATE` 开关约束——那把开关管的是 MCP 读面);
183
+ 全程只读:不 append、不落盘、不改任何状态。
184
+ """
185
+ ap = argparse.ArgumentParser(
186
+ description="状态槽位投影(只读):台账现算,不落盘、不缓存")
187
+ ap.add_argument("--root", default=None, help="数据根(缺省 mdcg_root())")
188
+ ap.add_argument("--status", action="store_true",
189
+ help="只打印台账/投影统计(events/subjects/slots/retired/bad_rows)")
190
+ ap.add_argument("--subject", default=None, help="按主体过滤(省略=不过滤)")
191
+ ap.add_argument("--slot", default=None, help="按槽位过滤(省略=不过滤)")
192
+ ap.add_argument("--include-retired", dest="include_retired",
193
+ action="store_true", help="含退役单元(缺省剔除)")
194
+ ap.add_argument("--no-history", dest="history", action="store_false",
195
+ help="不带变迁史(history 键省略)")
196
+ a = ap.parse_args(argv)
197
+ try:
198
+ # 进程内调用(守卫/嵌入)会重定向 stdout:无 reconfigure 的流上跳过,
199
+ # 不改变任何输出内容。
200
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
201
+ except Exception: # noqa: BLE001
202
+ pass
203
+ root = os.path.abspath(a.root or _default_root())
204
+ cg = _open_cg(root)
205
+ if a.status:
206
+ out = status(cg)
207
+ else:
208
+ out = project(cg, subject=a.subject, slot=a.slot,
209
+ include_retired=a.include_retired, history=a.history)
210
+ print(json.dumps(out, ensure_ascii=False, indent=2))
211
+ return 0
212
+
213
+
214
+ # 生效条件:无入参;返回 datapath.mdcg_root()(env MDCG_ROOT 非空时取该值,否则 paths.json/缺省),失败时抛原异常(不吞)。
215
+ def _default_root() -> str:
216
+ from .datapath import mdcg_root
217
+ return mdcg_root()
218
+
219
+
220
+ if __name__ == "__main__":
221
+ sys.exit(_main())
package/md_cg/stg.py CHANGED
@@ -12,6 +12,10 @@
12
12
  anchors(...) 落在给定时间窗 / 空间范围内的节点
13
13
  consistency() 时空字段自洽性检查
14
14
 
15
+ 第 5 op(本批):`state_chain(cg, subject, slot, …)`——L2 **状态槽位投影**
16
+ (某主体某槽位的现值/区间/变迁史;flag `MDCG_STG_STATE`,**默认关**)。语义单点
17
+ 在 `md_cg/state_slots.py`(投影不是第二真源:查询时从 append-only 台账现算)。
18
+
15
19
  **分层(issue #52 线)**:
16
20
  · 第 1/2 层(已收口):条件先于限额 + 截断可观测——`_scan` 只做「遍历 +
17
21
  layer/可见性过滤」,条件过滤与 `_cap_hits` 截断归各接口。
@@ -28,6 +32,7 @@ from __future__ import annotations
28
32
 
29
33
  import os
30
34
 
35
+ from . import lifecycle
31
36
  from . import stgidx
32
37
  from . import trust
33
38
 
@@ -130,7 +135,7 @@ def _node(cg, node_id):
130
135
  "content": n.get("content") or ""}
131
136
 
132
137
 
133
- # 生效条件:nid/e 为一条快照条目、layer 为层过滤值时——layer 为真值且 e.get("layer") != layer 即返回 None;cg 带可调用的 _readable(MdCGSecure)且判不可见即返回 None;e 含 "temporal" 或 "spatial" 键时以快照字段构造 frontmatter,否则调用 cg._read(e) 且在 fm 为 None 时返回 None;返回 {"id","frontmatter","layer","path"}。
138
+ # 生效条件:nid/e 为一条快照条目、layer 为层过滤值时——layer 为真值且 e.get("layer") != layer 即返回 None;lifecycle.is_archived(e) 为真即返回 None(退役不参与默认检索,判据单点在 lifecycle.py);cg 带可调用的 _readable(MdCGSecure)且判不可见即返回 None;e 含 "temporal" 或 "spatial" 键时以快照字段构造 frontmatter,否则调用 cg._read(e) 且在 fm 为 None 时返回 None;返回 {"id","frontmatter","layer","path"}。
134
139
  def _scan_one(cg, nid, e, layer=None):
135
140
  """单条目 → 候选条目(`_scan` 的逐条实现**单点**:全量遍历与索引子集共用)。
136
141
 
@@ -140,6 +145,16 @@ def _scan_one(cg, nid, e, layer=None):
140
145
  """
141
146
  if layer and e.get("layer") != layer:
142
147
  return None
148
+ if lifecycle.is_archived(e):
149
+ return None # lifecycle.py:42 标称兑现:archived 不参与默认检索。
150
+ # 本处是 **stg 扫描面单点**(`_scan`(:172) 的全量遍历与
151
+ # 索引子集两条分支都经本函数逐条实现)——timeline /
152
+ # anchors / consistency 三 op 的候选面同点覆盖;第 5 op
153
+ # `state_chain` 走 append-only 台账、不经节点扫描,不受影响;
154
+ # `relation` 面是显式 id 直读(`_node`→`cg.get`),也不经此。
155
+ # 判据单点在 lifecycle.is_archived(fail-open:缺键=active
156
+ # 照常;只剔 archived——converged/demoted 是降权轴仍参与)。
157
+ # 直读/审计/恢复面不经本函数:退役不删除、可显式恢复。
143
158
  _sec = getattr(cg, "_readable", None)
144
159
  if _sec is not None and not _sec(e):
145
160
  return None
@@ -164,7 +179,7 @@ def _scan_one(cg, nid, e, layer=None):
164
179
  "path": e.get("path")}
165
180
 
166
181
 
167
- # 生效条件:nodes 为 None 时**逐条**遍历 cg.index["nodes"] 全部条目(不按索引序切片、不读正文);nodes 为 (nid, entry) 对的序列时只遍历该序列(第 3 层索引子集,序由调用方保证=索引物理序);两种形态都逐条经 _scan_one(layer 过滤 + 可见性 + 条目化同一单点);返回 out 列表(全部 layer/可见性命中,**不做截断**——截断由各接口在条件过滤之后经 _cap_hits 执行,issue #52);
182
+ # 生效条件:nodes 为 None 时**逐条**遍历 cg.index["nodes"] 全部条目(不按索引序切片、不读正文);nodes 为 (nid, entry) 对的序列时只遍历该序列(第 3 层索引子集,序由调用方保证=索引物理序);两种形态都逐条经 _scan_one(layer 过滤 + 退役剔除 + 可见性 + 条目化同一单点);返回 out 列表(全部 layer/可见性命中,**不做截断**——截断由各接口在条件过滤之后经 _cap_hits 执行,issue #52);
168
183
  def _scan(cg, layer=None, nodes=None):
169
184
  """遍历节点:时空字段直接读索引快照(不读文件,O(1)/节点)。
170
185
 
@@ -174,6 +189,10 @@ def _scan(cg, layer=None, nodes=None):
174
189
  时逐条过读可见性——密级 × 会话绑定档在此与 _candidates 同口径,
175
190
  stg 各 op(timeline/relation/anchors)不得成为绕过路径。可见性判定
176
191
  **先于一切**(含截断):不可见节点既不进候选、也不占 kept 名额。
192
+ 退役单点(本轮):`lifecycle.is_archived` 为真的节点同样不进候选
193
+ (判据单点在 lifecycle.py,与 cg 读面 `MdCGOS._candidates` 共引一份),
194
+ 两个分支共用的逐条实现单点即 `_scan_one`——flag 开(索引子集)/关
195
+ (全量遍历)逐位一致,不因取数面不同而漏一次过滤。
177
196
 
178
197
  issue #52(条件先行于限额):旧实现在此处 `list(index["nodes"].items())[:max_scan]`
179
198
  ——按 id 字典序在**条件过滤之前**砍尾巴,库超 max_scan 后(a)本会话记忆等条件
@@ -269,6 +288,8 @@ def _with_scan_reads(out, *, scanned, hits, kept, truncated, max_scan,
269
288
  _INDEX_ENV = "MDCG_STG_INDEX"
270
289
  #: 条件资格首验开关(只上报不过滤)。
271
290
  _QUALIFY_ENV = "MDCG_STG_QUALIFY"
291
+ #: 状态槽位投影开关(第 5 op;与上两把同纪律:**默认关**,探针全绿后逐档开)。
292
+ _STATE_ENV = "MDCG_STG_STATE"
272
293
 
273
294
 
274
295
  # 生效条件:环境变量 name 取值属 ("1","true","True") 时返回 True,其余(含未设/其它值)返回 False——默认关的开关一律走本判据(与 MDCG_LEGACY_ENV_AUTH 同形)。
@@ -699,4 +720,48 @@ def consistency(cg, layer=None, limit=50, max_scan=5000, time_axis="observed"):
699
720
  return _with_scan_reads(
700
721
  {"issues": len(issues), "limit": limit, "items": issues[:limit]},
701
722
  scanned=scanned, hits=scanned, kept=kept, truncated=truncated,
702
- max_scan=max_scan, index_meta=imeta)
723
+ max_scan=max_scan, index_meta=imeta)
724
+
725
+
726
+ # ---------------------------------------------------------------------------
727
+ # 第 5 op:状态槽位投影(flag MDCG_STG_STATE,默认关)
728
+ # ---------------------------------------------------------------------------
729
+
730
+ # 生效条件:_flag_on(_STATE_ENV) 为假(含未设)时恒返回 {"error":"disabled","hint":…}(不抛异常、不静默降级、不触台账);为真时以 state_slots.project(cg, subject, slot, include_retired, history) 为 items 全量(查询时现算,零落盘零缓存),limit 经 int() 归一(负数归 0)后取前 limit 条,返回含 count(命中总数,截断前)/subject/slot/include_retired/limit/items/truncated(count > kept)/kept(len(items))的 dict;project 抛异常时**不吞**(原样上抛)。
731
+ def state_chain(cg, subject=None, slot=None, include_retired=False, history=True,
732
+ limit=50):
733
+ """状态槽位投影(第 5 op):某主体某槽位的**现值 / 区间 / 变迁史**。
734
+
735
+ 语义单点在 `md_cg/state_slots.py`(**投影不是第二真源**:查询时从 append-only
736
+ 台账 `<root>/_state_events.jsonl` 现算,不落盘、不缓存)——本函数只做开关与
737
+ 返回体成形,不复制任何回放逻辑(改投影只改那一处)。
738
+
739
+ 开关(`MDCG_STG_STATE`,**默认关**,与 `MDCG_STG_INDEX`/`MDCG_STG_QUALIFY`
740
+ 同纪律):关臂返回 `{"error": "disabled", "hint": …}`——**不是异常**,是「本面
741
+ 未启用」的如实答复;不静默降级成空结果(「没开」与「没命中」必须可分辨)。
742
+
743
+ 返回体沿 stg 既有口径(issue #52:截断可观测):`count` 为**条件命中总数**
744
+ (截断前,不受 limit 影响)、`items` 为前 `limit` 条、`kept` 为实际返回条数、
745
+ `truncated` 为 `count > kept`(limit=0 ⇒ items 空、truncated 随 count 真值)。
746
+
747
+ 不适用条件:`time_axis` 不适用于本 op(台账事件行只有落账时间戳 `t`,无
748
+ 双轴端点——本面**不伪造**第二轴;需要轴语义请走 timeline/relation/anchors);
749
+ `alternatives`/`blindspots` 本批恒空列表(判定口径后置,见 `state_slots` 模块头)。
750
+ """
751
+ if not _flag_on(_STATE_ENV):
752
+ return {"error": "disabled",
753
+ "hint": "本面默认关:设 MDCG_STG_STATE=1 显式启用"
754
+ "(与 MDCG_STG_INDEX/MDCG_STG_QUALIFY 同纪律)"}
755
+ from . import state_slots
756
+ units = state_slots.project(cg, subject=subject, slot=slot,
757
+ include_retired=include_retired,
758
+ history=history)
759
+ total = len(units)
760
+ cap = int(limit) if limit is not None else 0
761
+ if cap < 0:
762
+ cap = 0
763
+ items = units[:cap]
764
+ return {"count": total, "subject": subject, "slot": slot,
765
+ "include_retired": include_retired, "limit": limit,
766
+ "items": items, "truncated": total > len(items),
767
+ "kept": len(items)}