Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -388,8 +388,10 @@ before writing any artifact, including when the second owner is invalid.

Python field assignments (including subscript/attribute and annotated writes),
dictionaries, call keywords, owner-member results and declared scalar returns
are parsed with AST. Imported enum aliases resolve only to the registered owner;
shadowed names, reassignments and unresolved calls remain unknown. Conditional
are parsed with AST. Imported enum aliases resolve only to the registered
owner, including one unrenamed re-export hop through a tracked module (a
second hop, a renamed re-export or a rebinding stays unknown); shadowed
names, reassignments and unresolved calls remain unknown. Conditional
results exclude the condition's literals. TypeScript object writes, assignments
and declared returns use the repository's TypeScript parser rather than regex.
Neither parser executes inspected source. These are syntactic result witnesses,
Expand Down Expand Up @@ -998,6 +1000,26 @@ introduce a competing target state.

## Appendix A: Execution ledger (non-normative)

### 2026-09-16 — B2 pilot: one re-export hop bound in the Python producer scanner

- **Trigger:** after M2 moved the three Turn owners into
`turn_contract_generated.py`, every production site that still imported an
owner through the `transaction.py` / `driver.py` compatibility re-exports
became `unknown_producer` (43 → 52 unresolved sites) with no code change in
those modules and no failing check, because the scanner bound an owner only
when imported from the owner's own module.
- **Delivered:** `python_production` binds one unrenamed re-export hop
through a tracked module; a second hop, a renamed re-export, a same-name
class or assignment, or a later `import` leaves the consumer unknown, with
positive and negative fixtures. Only names that are owner symbols are
followed, so the full smoke keeps its runtime. The two executable input
witnesses are selected from one code-owned table keyed by the registered
`input_producer` site, and the smoke keeps one anchor for both instead of
four literal copies. Unresolved sites: 52 → 41; no site becomes newly
visible or unregistered; registry values and budgets are unchanged.
- **Effect on normative design:** the bounded producer model in Section 5
names the one-hop rule explicitly; no invariant or milestone changes.

### 2026-09-16 — Review consistency repair

- Keep one candidate-decision section per language.
Expand Down Expand Up @@ -1158,6 +1180,7 @@ introduce a competing target state.
| Date | Decision | Owner / approval | Alternatives | Normative sections changed |
| --- | --- | --- | --- | --- |
| 2026-09-16 | Q9: compute the full inventory on demand; retire the committed census | Implementation for [maintainer feedback](https://github.com/huangruiteng/loopx/pull/4360#issuecomment-5692062394); PR review pending | Committed snapshot with post-merge regeneration; diff-only scan rejected | 1, I6, 3, 5, 9, 10, 12 |
| 2026-09-16 | B2: bind one unrenamed re-export hop in the Python producer scanner | Implementation, Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B2; PR review pending | Require every consumer to import the owner module (fragile; failed silently in M2); unbounded multi-hop resolution rejected | 5, Appendix A |

## Appendix C: Evidence registry

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ M0.5 之后新增或删除值时;`cross_module` 只在晋升后(Q8)。持
返回整个 packet 的构建器不会因此把无关返回文字当作词表值。

Python 通过 AST 解析字段赋值(含下标、属性及带注解赋值)、字典、调用关键字、
owner 成员结果及声明函数的标量返回。导入枚举的别名只解析到已登记 owner;
owner 成员结果及声明函数的标量返回。导入枚举的别名只解析到已登记 owner,含经由一个被跟踪模块的一跳未改名再导出(第二跳、改名再导出或重新绑定保持 unknown);
被遮蔽的名字、重复赋值及未解析调用仍为 unknown。条件表达式只检查结果分支,
排除条件中的字面量。TypeScript 的对象写入、赋值及声明返回使用仓库的 TypeScript
解析器。两个解析器都不执行被检查源码。这些是句法结果证据,不是可达性或全程序
Expand Down Expand Up @@ -817,6 +817,21 @@ PR review 保留这些层级。普通改动记录检查范围和理由,无共

## 附录 A:执行账本(非规范)

### 2026-09-16 — B2 试点:Python producer 扫描器绑定一跳再导出

- **触发:** M2 把三个 Turn owner 迁入 `turn_contract_generated.py` 后,仍经
`transaction.py` / `driver.py` 兼容再导出取 owner 的生产位点全部变为
`unknown_producer`(未解决位点 43 → 52),这些模块没有任何代码改动,也没有
任何检查变红,因为扫描器只在从 owner 所在模块导入时才绑定 owner。
- **交付:** `python_production` 经由一个被跟踪模块绑定一跳未改名再导出;
第二跳、改名再导出、同名类或赋值、之后的 `import` 都让消费者保持 unknown,
并附正负 fixture。只跟随 owner 符号名,完整 smoke 耗时不变。两个可执行输入
见证改由一张代码持有、按已登记 `input_producer` 位点键控的表选择,smoke
用一个锚点取代四处字面量副本。未解决位点 52 → 41;没有位点新变为可见或
未登记;注册表值与预算不变。
- **对规范设计的影响:** 第 5 节的有界 producer 模型显式写明一跳规则;
不变量与里程碑不变。

### 2026-09-16 — 评审一致性修复

- 每种语言只保留一个候选决策小节。
Expand Down Expand Up @@ -946,6 +961,7 @@ PR review 保留这些层级。普通改动记录检查范围和理由,无共
| 日期 | 决策 | Owner / 批准 | 备选 | 变更的规范章节 |
| --- | --- | --- | --- | --- |
| 2026-09-16 | Q9:全树按需计算;移除已提交结构清单 | 根据[维护者反馈](https://github.com/huangruiteng/loopx/pull/4360#issuecomment-5692062394)实现,PR 评审待完成 | 取代合并后补再生成;拒绝只扫描 diff | 1、I6、3、5、9、10、12 |
| 2026-09-16 | B2:Python producer 扫描器绑定一跳未改名再导出 | 实现,Refs [#4447](https://github.com/huangruiteng/loopx/issues/4447) B2;PR 评审待完成 | 要求每个消费者都从 owner 模块导入(脆弱;M2 中已静默失效);拒绝无界多跳解析 | 5、附录 A |

## 附录 C:证据登记

Expand Down
24 changes: 11 additions & 13 deletions examples/semantic-vocabulary-drift-smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
)

from loopx.semantics.production import ( # noqa: E402
collect_production, validate_production, probe_turn_result_input_domain, quota_action_domain, collect_literal_uses,
collect_production, validate_production, INPUT_WITNESSES, quota_action_domain, collect_literal_uses,
)
from loopx.semantics.python_production import scan_python_production # noqa: E402
from scripts.generate_semantic_bindings import build_artifacts # noqa: E402
Expand Down Expand Up @@ -113,6 +113,11 @@
}
COVERAGE_SUFFIX_ANCHOR = (".py", ".ts")
LITERAL_SCAN_ROOTS = ["loopx"]
# Input producers with an executable witness in loopx.semantics.production.
INPUT_PRODUCER_ANCHOR = {
"turn_result_kind": "loopx/control_plane/turn_driver/transaction.py::_result_kind",
"loop_disposition": "loopx/control_plane/turn_driver/loop_controller.py::decide_loop_disposition",
}
PRODUCER_VOCABULARY_ANCHOR = {
"effective_action", "turn_route", "loop_disposition", "agent_scope_frontier_action", "turn_result_kind", "lease_action",
}
Expand Down Expand Up @@ -230,9 +235,7 @@ def load_registry() -> dict[str, Any]:
require(bool(producers) or set(vocabulary.get('compatibility_only', {})) == set(values), f"{name}: empty producers require every value to be compatibility-only")
require(all(isinstance(site, str) and OWNER_SHAPE.match(site) for site in producers), f"{name}: producers must be module::Symbol sites")
if 'input_producer' in vocabulary:
input_owners = {'turn_result_kind': 'loopx/control_plane/turn_driver/transaction.py::_result_kind',
'loop_disposition': 'loopx/control_plane/turn_driver/loop_controller.py::decide_loop_disposition'}
require(vocabulary['input_producer'] == input_owners.get(name), f"{name}: unrecognised input producer")
require(vocabulary['input_producer'] == INPUT_PRODUCER_ANCHOR.get(name), f"{name}: unrecognised input producer")
returns = vocabulary.get("return_producers", [])
require(isinstance(returns, list) and all(isinstance(site, str) and OWNER_SHAPE.match(site) for site in returns), f"{name}: return_producers must be module::Symbol sites")
require(set(returns) <= set(producers or []), f"{name}: return_producers must also be registered producers")
Expand Down Expand Up @@ -320,13 +323,10 @@ def check_coverage_floor(registry: dict[str, Any]) -> str:
quota_action_domain(registry)
except ValueError as error:
raise Drift(str(error)) from error
require(
registry['vocabularies']['turn_result_kind'].get('input_producer') == 'loopx/control_plane/turn_driver/transaction.py::_result_kind',
'turn_result_kind: input producer coverage must retain the anchored decoder',
)
require(registry['vocabularies']['loop_disposition'].get('input_producer') ==
'loopx/control_plane/turn_driver/loop_controller.py::decide_loop_disposition',
'controller input production must retain the anchored decision function')
for name, site in INPUT_PRODUCER_ANCHOR.items():
require(registry['vocabularies'][name].get('input_producer') == site,
f"{name}: input producer coverage must retain the anchored site {site}")
require(site in INPUT_WITNESSES, f"{name}: anchored input producer has no executable witness")
for name in PRODUCER_VOCABULARY_ANCHOR:
require("producers" in registry["vocabularies"][name], f"{name}: producer coverage dropped below PRODUCER_VOCABULARY_ANCHOR")
for name, required in RETURN_PRODUCER_ANCHOR.items():
Expand Down Expand Up @@ -470,8 +470,6 @@ def check_producers(registry: dict[str, Any], sources: list[SourceFile]) -> list
continue # Other kernel families retain an explicit M0.5 coverage gap.
try:
rows = collect_production(REPO_ROOT, vocabulary, sources)
if name == 'turn_result_kind':
rows.extend(probe_turn_result_input_domain(vocabulary))
field_domain = quota_action_domain(registry) if name == 'effective_action' else None
unknown.extend(validate_production(name, vocabulary, rows, field_domain=field_domain))
except ValueError as error:
Expand Down
23 changes: 17 additions & 6 deletions loopx/semantics/production.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import json
from pathlib import Path
import subprocess
from typing import Any
from typing import Any, Callable

from .inventory import SourceFile
from .python_production import Production, enum_members, scan_python_production
Expand Down Expand Up @@ -68,12 +68,10 @@ def collect_production(root: Path, vocabulary: dict[str, Any], sources: list[Sou
paths = {site.split('::')[1]: tuple(path) for site, path in return_paths.items()
if site.split('::')[0] == source.path}
rows.extend(scan_python_production(source, field=field, enums=enums, return_functions=names,
return_paths=paths, call_arguments=calls))
return_paths=paths, call_arguments=calls, modules=by_path))
rows.extend(_typescript_scan(root, [s for s in selected if s.suffix == '.ts'], field, returns))
if vocabulary.get('input_producer') == 'loopx/control_plane/turn_driver/loop_controller.py::decide_loop_disposition':
from .turn_contract_witness import probe_controller_production, probe_projection_production
rows.extend(probe_controller_production())
rows.extend(probe_projection_production())
if witness := INPUT_WITNESSES.get(vocabulary.get('input_producer') or ''):
rows.extend(witness(vocabulary))
return rows


Expand Down Expand Up @@ -221,3 +219,16 @@ def probe_turn_result_input_domain(vocabulary: dict[str, Any]) -> list[Productio
if actual is not None or not errors:
raise ValueError('turn_result_kind: decoder accepted an invalid input probe')
return rows


def _probe_controller_domain(vocabulary: dict[str, Any]) -> list[Production]:
from .turn_contract_witness import probe_controller_production, probe_projection_production
return probe_controller_production() + probe_projection_production()


# Executable input witnesses are fixed in code and selected only by the
# registered ``input_producer`` site; registry data cannot import a callable.
INPUT_WITNESSES: dict[str, Callable[[dict[str, Any]], list[Production]]] = {
'loopx/control_plane/turn_driver/transaction.py::_result_kind': probe_turn_result_input_domain,
'loopx/control_plane/turn_driver/loop_controller.py::decide_loop_disposition': _probe_controller_domain,
}
64 changes: 59 additions & 5 deletions loopx/semantics/python_production.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,48 @@ def _import_module(path: str, node: ast.ImportFrom) -> str:
return '.'.join(parts[:len(parts) - node.level + 1] + ([node.module] if node.module else []))


_TREES: dict[tuple[str, int], ast.Module] = {}


def _parsed(source: SourceFile) -> ast.Module:
key = (source.path, hash(source.text))
tree = _TREES.get(key)
if tree is None:
tree = _TREES[key] = ast.parse(source.text, filename=source.path)
return tree


def _tracked_module(module: str, modules: Mapping[str, SourceFile]) -> SourceFile | None:
base = module.replace('.', '/')
return modules.get(f'{base}.py') or modules.get(f'{base}/__init__.py')


def _reexported(source: SourceFile, symbol: str, imports: Mapping[tuple[str, str], _Binding]) -> _Binding | None:
"""One hop only: ``source`` imports ``symbol`` unrenamed from its owner and never rebinds it.

``import X as X`` counts as unrenamed. A renamed import, a second hop through
another module, a local class or assignment of the same name, or a later
``import`` of that name leaves the symbol unbound, so the consumer stays unknown.
"""
value = None
for node in _parsed(source).body:
if isinstance(node, ast.ImportFrom):
for alias in node.names:
if (alias.asname or alias.name) == symbol:
unrenamed = alias.asname in (None, alias.name)
value = imports.get((_import_module(source.path, node), symbol)) if unrenamed else None
elif isinstance(node, ast.Import):
if any((alias.asname or alias.name.split('.')[0]) == symbol for alias in node.names):
value = None
elif isinstance(node, (ast.Assign, ast.AnnAssign)):
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
if any(isinstance(t, ast.Name) and t.id == symbol for t in targets):
value = None
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and node.name == symbol:
value = None
return value


def enum_members(source: SourceFile, symbol: str, *, strict: bool = False) -> dict[str, str]:
"""Extract literal members, with fail-closed generation as an explicit mode.

Expand Down Expand Up @@ -138,16 +180,25 @@ def literals(node: ast.AST) -> set[str]:
_Binding = TypeVar('_Binding')


def _qualified_bindings(source: SourceFile, tree: ast.Module, owners: Mapping[str, _Binding]) -> dict[str, _Binding]:
def _qualified_bindings(source: SourceFile, tree: ast.Module, owners: Mapping[str, _Binding],
modules: Mapping[str, SourceFile] | None = None) -> dict[str, _Binding]:
bindings = {owner.split('::')[1]: value for owner, value in owners.items()
if owner.split('::')[0] == source.path}
imports = {(_module(owner.split('::')[0]), owner.split('::')[1]): value
for owner, value in owners.items()}
symbols = {symbol for _, symbol in imports}
for node in tree.body:
if isinstance(node, ast.ImportFrom):
module = _import_module(source.path, node)
for alias in node.names:
name = alias.asname or alias.name
value = imports.get((_import_module(source.path, node), alias.name))
value = imports.get((module, alias.name))
if value is None and modules is not None and alias.name in symbols:
# One unrenamed re-export hop through a tracked module binds the
# same owner; only names that are owner symbols are followed.
target = _tracked_module(module, modules)
if target is not None and target.path != source.path:
value = _reexported(target, alias.name, imports)
if value is not None:
bindings[name] = value
else:
Expand All @@ -172,20 +223,23 @@ def scan_python_production(
return_functions: frozenset[str] = frozenset(),
return_paths: Mapping[str, tuple[str | int, ...]] | None = None,
call_arguments: Mapping[str, Mapping[str, int | None]] | None = None,
modules: Mapping[str, SourceFile] | None = None,
) -> list[Production]:
"""Observe writes and owner-member results with bounded local resolution.

``enums`` maps module::Class to literal member values from tracked owners.
Only imported owner classes (including aliases) or the local owner qualify.
Only imported owner classes (including aliases) or the local owner qualify;
``modules`` additionally lets one unrenamed re-export hop through a tracked
module bind the owner. Longer chains and renamed re-exports stay unknown.
Local aliases and complete branch selections resolve only at output sites.
General reassignment and parameter shadowing become unknown. Explicit call
metadata names only reviewed builder arguments; arbitrary calls are consumers.
Nested function returns belong to that function, not a registered enclosure.
"""
tree = ast.parse(source.text, filename=source.path)
bindings = _qualified_bindings(source, tree, enums)
bindings = _qualified_bindings(source, tree, enums, modules)
call_arguments = call_arguments or {}
calls = _qualified_bindings(source, tree, call_arguments)
calls = _qualified_bindings(source, tree, call_arguments, modules)
return_paths = return_paths or {}

result: list[Production] = []
Expand Down
Loading
Loading