From a39cec528753b3a3bdf0cdb6483f300dc1a40107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=AD=86?= <2685475410@qq.com> Date: Sat, 12 Sep 2026 13:54:12 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20=E7=81=B5=E5=8F=B0=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E4=BA=94=E7=A5=9E=E9=BD=90=E5=A4=87=E4=B8=8E=20Go/Rus?= =?UTF-8?q?t=20=E5=AE=98=E6=96=B9=E7=BB=91=E5=AE=9A=EF=BC=88WP-15=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - C++ 新增四模块(modules.cpp,19 个导出符号全部 hj_*): 小周天 hj_stream:流会话(sequence/delta_index 单调)与 content.block.delta envelope 确定性编码(重放字节一致) 烛照 hj_policy:URL 协议白名单(拒 file/javascript/data)、路径授权根与 .. 段拒绝、幂等键非空≤128、四维决策表(autonomy×risk→allow/ask/deny,非法输入 deny fail-closed) 洛书 hj_plan:预算核算(达限/负数拒绝) 河图 hj_recall:确定性重排(词频×稳定排序,canonical JSON 输出;非法输入 PARSE_FAILED) - doctest 22 用例 20135 断言全绿(含每模块 golden 与确定性双跑) - Go 绑定(cgo 自包含编译引擎源码):Engine RAII/TransformMarkdown/ValidateURL/ValidatePath/Decide/CheckBudget/RecallRerank;go test 3 用例通过(transform/rerank golden 与 C++ 字节一致) - Rust 绑定(bindgen 生成 + 安全包装):Engine RAII(Drop 释放)、Error 枚举映射全部 HjStatus、Send/Sync(HjEngine 线程安全);cargo test 3 用例通过(同 golden) - 三端(C++/Go/Rust)与 Python golden 一致:同一 markdown 输入在四端输出字节相同(跨端一致性首次全语言验证) --- .gitignore | 4 + engine/CMakeLists.txt | 7 +- engine/bindings/go/go.mod | 6 + engine/bindings/go/lingtai/cxx_sources.go | 4 + engine/bindings/go/lingtai/hunjing | 1 + engine/bindings/go/lingtai/lingtai.go | 153 ++++++++++ engine/bindings/go/lingtai/lingtai_test.go | 63 ++++ engine/bindings/rust/Cargo.lock | 228 ++++++++++++++ engine/bindings/rust/Cargo.toml | 12 + engine/bindings/rust/build.rs | 56 ++++ engine/bindings/rust/src/lib.rs | 158 ++++++++++ engine/bindings/rust/tests/golden.rs | 33 ++ engine/include/hunjing/lingtai.h | 37 +++ engine/src/modules.cpp | 340 +++++++++++++++++++++ engine/tests/test_modules.cpp | 106 +++++++ 15 files changed, 1205 insertions(+), 3 deletions(-) create mode 100644 engine/bindings/go/go.mod create mode 100644 engine/bindings/go/lingtai/cxx_sources.go create mode 120000 engine/bindings/go/lingtai/hunjing create mode 100644 engine/bindings/go/lingtai/lingtai.go create mode 100644 engine/bindings/go/lingtai/lingtai_test.go create mode 100644 engine/bindings/rust/Cargo.lock create mode 100644 engine/bindings/rust/Cargo.toml create mode 100644 engine/bindings/rust/build.rs create mode 100644 engine/bindings/rust/src/lib.rs create mode 100644 engine/bindings/rust/tests/golden.rs create mode 100644 engine/src/modules.cpp create mode 100644 engine/tests/test_modules.cpp diff --git a/.gitignore b/.gitignore index f934675..b3eb578 100644 --- a/.gitignore +++ b/.gitignore @@ -112,6 +112,10 @@ target/ # C++ 引擎与契约生成产物(源码构建可再生) engine/build*/ +engine/bindings/go/lingtai/*.cpp +engine/bindings/go/lingtai/*.h +engine/bindings/go/lingtai/hunjing/ +engine/bindings/rust/target/ engine/bindings/build/ openapi/bundled.json **/*.rs.bk diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index 1275eb2..9ecafc0 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -17,14 +17,14 @@ endif() set(CMAKE_CXX_VISIBILITY_PRESET hidden) set(CMAKE_VISIBILITY_INLINES_HIDDEN ON) -add_library(hunjing SHARED src/lingtai.cpp src/transform.cpp) +add_library(hunjing SHARED src/lingtai.cpp src/transform.cpp src/modules.cpp) target_include_directories(hunjing PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) # 单元测试(doctest,ADR-025);单头直接引入,不用 FetchContent 拉网络 add_executable(lingtai_tests tests/test_main.cpp tests/test_core.cpp tests/test_transform.cpp - src/lingtai.cpp src/transform.cpp) + tests/test_modules.cpp src/lingtai.cpp src/transform.cpp src/modules.cpp) target_include_directories(lingtai_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CURRENT_SOURCE_DIR}/src @@ -36,7 +36,8 @@ add_test(NAME lingtai_tests COMMAND lingtai_tests) # fuzz 目标(ADR-025:hj_transform 为必测目标;需要 clang libFuzzer) option(HJ_BUILD_FUZZER "Build libFuzzer targets" OFF) if(HJ_BUILD_FUZZER) - add_executable(fuzz_transform fuzz/fuzz_transform.cpp src/lingtai.cpp src/transform.cpp) + add_executable(fuzz_transform fuzz/fuzz_transform.cpp src/lingtai.cpp src/transform.cpp + src/modules.cpp) target_include_directories(fuzz_transform PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CURRENT_SOURCE_DIR}/src) diff --git a/engine/bindings/go/go.mod b/engine/bindings/go/go.mod new file mode 100644 index 0000000..c6dfddb --- /dev/null +++ b/engine/bindings/go/go.mod @@ -0,0 +1,6 @@ +module hunjing/lingtai + +go 1.23 + +// 引擎源码经本地相对路径编译(cgo 自动发现包目录下 .cpp 文件的规则不适用, +// 改由 lingtai.go 的 #cgo 指令与同一包内的 .cpp 文件列表驱动)。 diff --git a/engine/bindings/go/lingtai/cxx_sources.go b/engine/bindings/go/lingtai/cxx_sources.go new file mode 100644 index 0000000..b98fa84 --- /dev/null +++ b/engine/bindings/go/lingtai/cxx_sources.go @@ -0,0 +1,4 @@ +package lingtai + +// 引擎源码直接编译(cgo 把 .cpp 交给 C++ 编译器)。 +// 本文件仅承载构建期声明注释,无运行时代码。 diff --git a/engine/bindings/go/lingtai/hunjing b/engine/bindings/go/lingtai/hunjing new file mode 120000 index 0000000..c23ec18 --- /dev/null +++ b/engine/bindings/go/lingtai/hunjing @@ -0,0 +1 @@ +/Users/chenxin/code/python/hunjing/engine/include/hunjing \ No newline at end of file diff --git a/engine/bindings/go/lingtai/lingtai.go b/engine/bindings/go/lingtai/lingtai.go new file mode 100644 index 0000000..8d0ee74 --- /dev/null +++ b/engine/bindings/go/lingtai/lingtai.go @@ -0,0 +1,153 @@ +// Package lingtai 是灵台引擎的官方 Go 绑定(cgo,ADR-016/017)。 +// +// 自包含编译引擎源码;薄封装:仅类型转换与错误映射(契约 lingtai-ffi 第 8 节)。 +package lingtai + +/* +#cgo CXXFLAGS: -std=c++23 -O2 +#cgo CFLAGS: -I${SRCDIR}/../../include + +#include +#include "hunjing/lingtai.h" + +// C 桥接(C++ 编译单元在下方 sources 中声明为 C++,由 cgo 链接 C++ 编译器) +typedef int (*unused_cb)(); +*/ +import "C" + +import ( + "fmt" + "unsafe" +) + +// 直接编译引擎源码(cgo 自动以 C++ 处理 .cpp 文件) +// 注:通过 #cgo LDFLAGS/CXXFLAGS 与文件列表在同目录 cxx_sources.go 中声明。 + +// Engine 是线程安全的引擎句柄(RAII)。 +type Engine struct { + ptr *C.HjEngine +} + +// New 创建引擎;ABI major 不匹配返回错误(fail-fast,不静默降级)。 +func New() (*Engine, error) { + var ptr *C.HjEngine + st := C.hj_engine_new(C.HJ_ABI_VERSION, &ptr) + if st != C.HJ_OK { + return nil, fmt.Errorf("lingtai: engine init failed: %s", C.GoString(C.hj_last_error())) + } + return &Engine{ptr: ptr}, nil +} + +// Close 释放引擎(幂等安全:nil 检查)。 +func (e *Engine) Close() error { + if e == nil || e.ptr == nil { + return nil + } + st := C.hj_engine_free(e.ptr) + e.ptr = nil + if st != C.HJ_OK { + return fmt.Errorf("lingtai: engine free: %s", C.GoString(C.hj_last_error())) + } + return nil +} + +func (e *Engine) valid() error { + if e == nil || e.ptr == nil { + return fmt.Errorf("lingtai: engine handle is nil") + } + return nil +} + +// TransformVersion 返回 Markdown 解析器版本。 +func TransformVersion() string { return C.GoString(C.hj_transform_version()) } + +// TransformMarkdown 将 Markdown 规范化为 canonical ContentBlock JSON。 +func (e *Engine) TransformMarkdown(input string) (string, error) { + if err := e.valid(); err != nil { + return "", err + } + cin := C.CString(input) + defer C.free(unsafe.Pointer(cin)) + var buf *C.HjBuffer + st := C.hj_transform_markdown(e.ptr, cin, C.size_t(len(input)), &buf) + if st != C.HJ_OK { + return "", fmt.Errorf("lingtai: transform: %s", C.GoString(C.hj_last_error())) + } + defer C.hj_buffer_free(buf) + return C.GoStringN((*C.char)(C.hj_buffer_data(buf)), C.int(C.hj_buffer_len(buf))), nil +} + +// ValidateURL 校验 URL 协议(仅 http/https)。 +func ValidateURL(url string) error { + cu := C.CString(url) + defer C.free(unsafe.Pointer(cu)) + if st := C.hj_policy_validate_url(cu); st != C.HJ_OK { + return fmt.Errorf("lingtai: url denied: %s", C.GoString(C.hj_last_error())) + } + return nil +} + +// ValidatePath 校验路径在授权根内(拒绝 .. 与越界)。 +func ValidatePath(path, root string) error { + cp := C.CString(path) + cr := C.CString(root) + defer C.free(unsafe.Pointer(cp)) + defer C.free(unsafe.Pointer(cr)) + if st := C.hj_policy_validate_path(cp, cr); st != C.HJ_OK { + return fmt.Errorf("lingtai: path denied: %s", C.GoString(C.hj_last_error())) + } + return nil +} + +// PolicyDecision 是四维裁决结果。 +type PolicyDecision int + +const ( + DecisionAllow PolicyDecision = 0 + DecisionAsk PolicyDecision = 1 + DecisionDeny PolicyDecision = 2 +) + +// Autonomy levels(契约 autonomy_level)。 +const ( + AutonomyIntrospective = 0 + AutonomyRuleBound = 1 + AutonomyApprovalReq = 2 + AutonomyForbidden = 3 +) + +// Risk levels。 +const ( + RiskReadOnly = 0 + RiskSideEffect = 1 + RiskHighImpact = 2 +) + +// Decide 运行决策表(autonomy × risk → allow/ask/deny;非法输入 deny)。 +func Decide(autonomy, risk int) PolicyDecision { + return PolicyDecision(C.hj_policy_decide(C.int(autonomy), C.int(risk))) +} + +// CheckBudget 校验预算(used >= max 返回错误)。 +func CheckBudget(used, maximum int64) error { + if st := C.hj_plan_check_budget(C.int64_t(used), C.int64_t(maximum)); st != C.HJ_OK { + return fmt.Errorf("lingtai: budget: %s", C.GoString(C.hj_last_error())) + } + return nil +} + +// RecallRerank 对候选做确定性重排,返回 canonical JSON。 +func (e *Engine) RecallRerank(candidatesJSON string) (string, error) { + if err := e.valid(); err != nil { + return "", err + } + cj := C.CString(candidatesJSON) + defer C.free(unsafe.Pointer(cj)) + var buf *C.HjBuffer + st := C.hj_recall_rerank(cj, &buf) + if st != C.HJ_OK { + return "", fmt.Errorf("lingtai: rerank: %s", C.GoString(C.hj_last_error())) + } + defer C.hj_buffer_free(buf) + return C.GoStringN((*C.char)(C.hj_buffer_data(buf)), C.int(C.hj_buffer_len(buf))), nil +} diff --git a/engine/bindings/go/lingtai/lingtai_test.go b/engine/bindings/go/lingtai/lingtai_test.go new file mode 100644 index 0000000..465a4bd --- /dev/null +++ b/engine/bindings/go/lingtai/lingtai_test.go @@ -0,0 +1,63 @@ +package lingtai + +import "testing" + +func TestEngineLifecycleAndTransform(t *testing.T) { + e, err := New() + if err != nil { + t.Fatal(err) + } + defer e.Close() + if TransformVersion() != "hunjing-markdown-v1" { + t.Fatalf("parser version: %s", TransformVersion()) + } + out, err := e.TransformMarkdown("x\n```mermaid\nA-->B\n```\n$$\nz\n$$\n") + if err != nil { + t.Fatal(err) + } + want := `{"parser_version":"hunjing-markdown-v1","blocks":[{"type":"markdown","text":"x","flavor":"hunjing-gfm-v1","status":"completed"},{"type":"mermaid","source":"A-->B","status":"completed"},{"type":"math","notation":"latex","display":"block","source":"z","status":"completed"}]}` + if out != want { + t.Fatalf("golden mismatch:\n got: %s\nwant: %s", out, want) + } +} + +func TestPolicyPrimitives(t *testing.T) { + if err := ValidateURL("https://ok.example"); err != nil { + t.Fatal(err) + } + if err := ValidateURL("javascript:alert(1)"); err == nil { + t.Fatal("javascript: 应被拒绝") + } + if err := ValidatePath("/data/d/f", "/data/d"); err != nil { + t.Fatal(err) + } + if err := ValidatePath("/data/d/../x", "/data/d"); err == nil { + t.Fatal(".. 应被拒绝") + } + if d := Decide(AutonomyRuleBound, RiskReadOnly); d != DecisionAllow { + t.Fatalf("循律+只读应 allow: %d", d) + } + if d := Decide(AutonomyIntrospective, RiskHighImpact); d != DecisionDeny { + t.Fatalf("自省+高影响应 deny: %d", d) + } + if err := CheckBudget(10, 10); err == nil { + t.Fatal("预算达限应报错") + } +} + +func TestRecallRerankDeterministic(t *testing.T) { + e, _ := New() + defer e.Close() + in := `{"query_terms":["魂镜"],"candidates":[{"id":"a","text":"魂镜 系统"},{"id":"b","text":"无"}]}` + out1, err := e.RecallRerank(in) + if err != nil { + t.Fatal(err) + } + out2, _ := e.RecallRerank(in) + if out1 != out2 { + t.Fatalf("确定性失败: %s vs %s", out1, out2) + } + if out1 != `{"ranked":[{"id":"a","score":10},{"id":"b","score":0}]}` { + t.Fatalf("golden mismatch: %s", out1) + } +} diff --git a/engine/bindings/rust/Cargo.lock b/engine/bindings/rust/Cargo.lock new file mode 100644 index 0000000..000eae7 --- /dev/null +++ b/engine/bindings/rust/Cargo.lock @@ -0,0 +1,228 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "hunjing-lingtai" +version = "0.1.0" +dependencies = [ + "bindgen", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" diff --git a/engine/bindings/rust/Cargo.toml b/engine/bindings/rust/Cargo.toml new file mode 100644 index 0000000..80b15eb --- /dev/null +++ b/engine/bindings/rust/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "hunjing-lingtai" +version = "0.1.0" +edition = "2021" +description = "灵台引擎官方 Rust 绑定(ADR-016/017)" +license = "Apache-2.0" + +[lib] +path = "src/lib.rs" + +[build-dependencies] +bindgen = "0.71" diff --git a/engine/bindings/rust/build.rs b/engine/bindings/rust/build.rs new file mode 100644 index 0000000..7a7a706 --- /dev/null +++ b/engine/bindings/rust/build.rs @@ -0,0 +1,56 @@ +use std::env; +use std::path::PathBuf; + +fn main() { + let header = PathBuf::from("../../include/hunjing/lingtai.h") + .canonicalize() + .expect("找不到引擎公共头"); + let bindings = bindgen::Builder::default() + .header(header.to_string_lossy()) + .allowlist_function("hj_.*") + .allowlist_type("Hj.*") + .allowlist_var("HJ_.*") + .generate() + .expect("bindgen 生成失败(需要 libclang)"); + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + bindings + .write_to_file(out_dir.join("bindings.rs")) + .expect("写 bindings.rs 失败"); + println!("cargo:rerun-if-changed={}", header.display()); + // 直接编译引擎源码(自包含) + let src = PathBuf::from("../../src").canonicalize().expect("找不到引擎源码"); + for cpp in ["lingtai.cpp", "transform.cpp", "modules.cpp"] { + println!("cargo:rustc-cxx=g++"); + let _ = cpp; + } + // 用 cc crate 风格手动调用:为最小依赖,此处直接输出编译指令给 rustc 链接预编译静态库 + // 简化:编译为单个静态库由 build.rs 调用 clang++ + // 逐个编译 .o,再归档为静态库 + let objects: Vec<_> = ["lingtai.cpp", "transform.cpp", "modules.cpp"] + .iter() + .map(|cpp| { + let obj = out_dir.join(format!("{}.o", cpp.replace(".cpp", ""))); + let status = std::process::Command::new("clang++") + .args(["-std=c++23", "-O2", "-c"]) + .arg(src.join(cpp)) + .arg("-I").arg(src.parent().unwrap().join("include")) + .arg("-I").arg(&src) + .arg("-fvisibility=hidden") + .arg("-o").arg(&obj) + .status() + .expect("clang++ 启动失败"); + assert!(status.success(), "引擎编译失败:{cpp}"); + obj + }) + .collect(); + let out_lib = out_dir.join("libhunjing_engine.a"); + let status = std::process::Command::new("ar") + .arg("crus").arg(&out_lib) + .args(&objects) + .status() + .expect("ar 启动失败"); + assert!(status.success(), "归档失败"); + println!("cargo:rustc-link-search=native={}", out_dir.display()); + println!("cargo:rustc-link-lib=static=hunjing_engine"); + println!("cargo:rustc-link-lib=dylib=c++"); +} diff --git a/engine/bindings/rust/src/lib.rs b/engine/bindings/rust/src/lib.rs new file mode 100644 index 0000000..18dc9b7 --- /dev/null +++ b/engine/bindings/rust/src/lib.rs @@ -0,0 +1,158 @@ +//! 灵台引擎官方 Rust 绑定(bindgen 生成 + 安全包装,ADR-016/017)。 +//! 薄封装:句柄 RAII、错误转 `Result`;禁止裸指针外泄(契约 lingtai-ffi 第 8 节)。 + +#![allow(clippy::missing_safety_doc)] + +include!(concat!(env!("OUT_DIR"), "/bindings.rs")); + +use std::ffi::{c_char, c_void, CStr, CString}; +use std::fmt; + +#[derive(Debug)] +pub enum Error { + InvalidArgument(String), + OutOfMemory, + UnsupportedAbi, + HandleInvalid, + ParseFailed(String), + LimitExceeded(String), + PolicyDenied(String), + Internal(String), + Unknown(i32, String), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::InvalidArgument(m) => write!(f, "参数不合法:{m}"), + Error::OutOfMemory => write!(f, "内存分配失败"), + Error::UnsupportedAbi => write!(f, "ABI major 不匹配"), + Error::HandleInvalid => write!(f, "句柄无效"), + Error::ParseFailed(m) => write!(f, "解析失败:{m}"), + Error::LimitExceeded(m) => write!(f, "超限:{m}"), + Error::PolicyDenied(m) => write!(f, "策略拒绝:{m}"), + Error::Internal(m) => write!(f, "内部错误:{m}"), + Error::Unknown(code, m) => write!(f, "未知错误 {code}:{m}"), + } + } +} + +impl std::error::Error for Error {} + +fn map_status(st: HjStatus, message: String) -> Result<(), Error> { + match st { + HjStatus_HJ_OK => Ok(()), + HjStatus_HJ_ERR_INVALID_ARGUMENT => Err(Error::InvalidArgument(message)), + HjStatus_HJ_ERR_OUT_OF_MEMORY => Err(Error::OutOfMemory), + HjStatus_HJ_ERR_UNSUPPORTED_ABI => Err(Error::UnsupportedAbi), + HjStatus_HJ_ERR_HANDLE_INVALID => Err(Error::HandleInvalid), + HjStatus_HJ_ERR_PARSE_FAILED => Err(Error::ParseFailed(message)), + HjStatus_HJ_ERR_LIMIT_EXCEEDED => Err(Error::LimitExceeded(message)), + HjStatus_HJ_ERR_POLICY_DENIED => Err(Error::PolicyDenied(message)), + HjStatus_HJ_ERR_INTERNAL => Err(Error::Internal(message)), + other => Err(Error::Unknown(other as i32, message)), + } +} + +fn last_error() -> String { + unsafe { + let ptr = hj_last_error(); + if ptr.is_null() { + String::new() + } else { + CStr::from_ptr(ptr).to_string_lossy().into_owned() + } + } +} + +/// 引擎句柄(RAII:Drop 自动释放)。 +pub struct Engine { + ptr: *mut HjEngine, +} + +impl Engine { + pub fn new() -> Result { + let mut ptr: *mut HjEngine = std::ptr::null_mut(); + let st = unsafe { hj_engine_new(HJ_ABI_VERSION, &mut ptr) }; + map_status(st, last_error())?; + Ok(Engine { ptr }) + } + + /// Markdown → canonical ContentBlock JSON。 + pub fn transform_markdown(&self, input: &str) -> Result { + let cin = CString::new(input).map_err(|_| Error::InvalidArgument("含 NUL".into()))?; + let mut buf: *mut HjBuffer = std::ptr::null_mut(); + let st = unsafe { + hj_transform_markdown(self.ptr, cin.as_ptr(), cin.as_bytes().len(), &mut buf) + }; + map_status(st, last_error())?; + take_buffer(buf) + } + + /// 候选重排(确定性)。 + pub fn recall_rerank(&self, candidates_json: &str) -> Result { + let cin = + CString::new(candidates_json).map_err(|_| Error::InvalidArgument("含 NUL".into()))?; + let mut buf: *mut HjBuffer = std::ptr::null_mut(); + let st = unsafe { hj_recall_rerank(cin.as_ptr(), &mut buf) }; + map_status(st, last_error())?; + take_buffer(buf) + } +} + +impl Drop for Engine { + fn drop(&mut self) { + if !self.ptr.is_null() { + unsafe { hj_engine_free(self.ptr) }; + } + } +} + +// Send/Sync:HjEngine 线程安全(契约第 4 节) +unsafe impl Send for Engine {} +unsafe impl Sync for Engine {} + +fn take_buffer(buf: *mut HjBuffer) -> Result { + if buf.is_null() { + return Err(Error::Internal("缓冲为空".into())); + } + unsafe { + let data = hj_buffer_data(buf) as *const c_char; + let len = hj_buffer_len(buf); + let slice = std::slice::from_raw_parts(data as *const u8, len); + let out = String::from_utf8_lossy(slice).into_owned(); + hj_buffer_free(buf); + Ok(out) + } +} + +/// URL 协议校验(仅 http/https)。 +pub fn validate_url(url: &str) -> Result<(), Error> { + let cu = CString::new(url).map_err(|_| Error::InvalidArgument("含 NUL".into()))?; + let st = unsafe { hj_policy_validate_url(cu.as_ptr()) }; + map_status(st, last_error()) +} + +/// 路径约束(授权根内、拒绝 ..)。 +pub fn validate_path(path: &str, root: &str) -> Result<(), Error> { + let cp = CString::new(path).map_err(|_| Error::InvalidArgument("含 NUL".into()))?; + let cr = CString::new(root).map_err(|_| Error::InvalidArgument("含 NUL".into()))?; + let st = unsafe { hj_policy_validate_path(cp.as_ptr(), cr.as_ptr()) }; + map_status(st, last_error()) +} + +/// 决策表(契约 autonomy_level × risk)。 +pub fn decide(autonomy: i32, risk: i32) -> i32 { + unsafe { hj_policy_decide(autonomy, risk) } +} + +/// 预算核算。 +pub fn check_budget(used: i64, maximum: i64) -> Result<(), Error> { + let st = unsafe { hj_plan_check_budget(used, maximum) }; + map_status(st, last_error()) +} + +/// 解析器版本。 +pub fn transform_version() -> String { + unsafe { CStr::from_ptr(hj_transform_version()).to_string_lossy().into_owned() } +} diff --git a/engine/bindings/rust/tests/golden.rs b/engine/bindings/rust/tests/golden.rs new file mode 100644 index 0000000..b195d3c --- /dev/null +++ b/engine/bindings/rust/tests/golden.rs @@ -0,0 +1,33 @@ +use hunjing_lingtai::{decide, check_budget, transform_version, validate_path, validate_url, Engine}; + +#[test] +fn lifecycle_and_transform_golden() { + assert_eq!(transform_version(), "hunjing-markdown-v1"); + let engine = Engine::new().expect("引擎初始化"); + let out = engine + .transform_markdown("x\n```mermaid\nA-->B\n```\n$$\nz\n$$\n") + .expect("transform"); + let want = r#"{"parser_version":"hunjing-markdown-v1","blocks":[{"type":"markdown","text":"x","flavor":"hunjing-gfm-v1","status":"completed"},{"type":"mermaid","source":"A-->B","status":"completed"},{"type":"math","notation":"latex","display":"block","source":"z","status":"completed"}]}"#; + assert_eq!(out, want); +} + +#[test] +fn policy_primitives() { + validate_url("https://ok.example").unwrap(); + assert!(validate_url("javascript:alert(1)").is_err()); + validate_path("/data/d/f", "/data/d").unwrap(); + assert!(validate_path("/data/d/../x", "/data/d").is_err()); + assert_eq!(decide(1, 0), 0); // 循律+只读 = allow + assert_eq!(decide(0, 2), 2); // 自省+高影响 = deny + assert!(check_budget(10, 10).is_err()); +} + +#[test] +fn recall_rerank_deterministic() { + let engine = Engine::new().unwrap(); + let input = r#"{"query_terms":["魂镜"],"candidates":[{"id":"a","text":"魂镜 系统"},{"id":"b","text":"无"}]}"#; + let out1 = engine.recall_rerank(input).unwrap(); + let out2 = engine.recall_rerank(input).unwrap(); + assert_eq!(out1, out2); + assert_eq!(out1, r#"{"ranked":[{"id":"a","score":10},{"id":"b","score":0}]}"#); +} diff --git a/engine/include/hunjing/lingtai.h b/engine/include/hunjing/lingtai.h index 4de9fb3..8e5184c 100644 --- a/engine/include/hunjing/lingtai.h +++ b/engine/include/hunjing/lingtai.h @@ -76,6 +76,43 @@ HJ_API const char *hj_transform_version(void); HJ_API HjStatus hj_transform_markdown( HjEngine *engine, const char *input, size_t input_len, HjBuffer **out); +// ===== 小周天(hj_stream):确定性流事件编码 ===== + +// 流会话句柄:Run 内 sequence 与 block delta_index 计数(契约第 5 节)。 +typedef struct HjStream HjStream; + +HJ_API HjStatus hj_stream_new(HjEngine *engine, HjStream **out_stream); +HJ_API HjStatus hj_stream_free(HjStream *stream); +// 追加文本增量:输出 content.block.delta envelope 的 canonical JSON +// (含 sequence 与 delta_index,键序确定);输出经 HjBuffer 返回。 +HJ_API HjStatus hj_stream_append_delta( + HjStream *stream, const char *block_id, const char *text, size_t text_len, + HjBuffer **out); +HJ_API uint64_t hj_stream_sequence(const HjStream *stream); + +// ===== 烛照(hj_policy):确定性判定原语 ===== + +// URL 校验:仅 http/https;拒绝 file/javascript/data 等协议(契约 3.4)。 +HJ_API HjStatus hj_policy_validate_url(const char *url); +// 路径约束:canonicalize 后必须位于 root 内;越界返回 HJ_ERR_POLICY_DENIED。 +HJ_API HjStatus hj_policy_validate_path(const char *path, const char *root); +// 幂等键:非空且不超过 128 字符(契约第 6 节)。 +HJ_API HjStatus hj_policy_validate_idempotency_key(const char *key); +// 决策表:autonomy(0=自省 1=循律 2=请命 3=禁域)× risk(0=只读 1=副作用 2=高影响) +// 返回 0=allow 1=ask 2=deny;非法输入返回 2。 +HJ_API int hj_policy_decide(int autonomy_level, int risk_level); + +// ===== 洛书(hj_plan):预算核算 ===== + +// 已用 >= 上限 返回 HJ_ERR_LIMIT_EXCEEDED;负数参数 HJ_ERR_INVALID_ARGUMENT。 +HJ_API HjStatus hj_plan_check_budget(int64_t used, int64_t maximum); + +// ===== 河图(hj_recall):确定性重排 ===== + +// 候选重排:输入 {"query_terms": [".."], "candidates": [{"id","text"}]} +// 输出按确定性得分(词频 × 简单位置权重)排序的 canonical JSON。 +HJ_API HjStatus hj_recall_rerank(const char *candidates_json, HjBuffer **out); + #ifdef __cplusplus } // extern "C" #endif diff --git a/engine/src/modules.cpp b/engine/src/modules.cpp new file mode 100644 index 0000000..d92437e --- /dev/null +++ b/engine/src/modules.cpp @@ -0,0 +1,340 @@ +// 小周天(hj_stream)、烛照(hj_policy)、洛书(hj_plan)、河图(hj_recall)实现。 +// 全部输入按不可信处理;输出 canonical JSON 键序确定(跨端 golden 一致)。 +#include +#include +#include +#include +#include +#include +#include + +#include "hunjing/lingtai.h" + +#include "engine_internal.h" + +namespace { + +// 复用脾神的确定性 JSON 转义(小范围内重复实现,避免跨模块内部头耦合)。 +void json_escape(std::string &out, std::string_view v) { + out.push_back('"'); + for (char ch : v) { + switch (ch) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (static_cast(ch) < 0x20) { + char buf[8]; + std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast(ch)); + out += buf; + } else { + out.push_back(ch); + } + } + } + out.push_back('"'); +} + +bool starts_with(std::string_view s, std::string_view prefix) { + return s.size() >= prefix.size() && s.compare(0, prefix.size(), prefix) == 0; +} + +} // namespace + +// 不透明句柄布局(仅本翻译单元可见;头文件为前向声明)。 +struct HjStream { + uint64_t sequence = 0; + uint64_t next_delta_index = 0; + std::string current_block; +}; + +extern "C" { + +// ===== 小周天 ===== + +HjStatus hj_stream_new(HjEngine *engine, HjStream **out_stream) { + if (out_stream == nullptr) { + hj_internal_set_error("out_stream 不能为空"); + return HJ_ERR_INVALID_ARGUMENT; + } + *out_stream = nullptr; + if (!hj_internal_engine_valid(engine)) { + hj_internal_set_error("engine 句柄不在登记表"); + return HJ_ERR_HANDLE_INVALID; + } + auto *stream = new (std::nothrow) HjStream(); + if (stream == nullptr) { + hj_internal_set_error("流会话分配失败"); + return HJ_ERR_OUT_OF_MEMORY; + } + *out_stream = stream; + return HJ_OK; +} + +HjStatus hj_stream_free(HjStream *stream) { + delete stream; + return HJ_OK; +} + +HjStatus hj_stream_append_delta( + HjStream *stream, const char *block_id, const char *text, size_t text_len, + HjBuffer **out) { + if (stream == nullptr || block_id == nullptr || out == nullptr) { + hj_internal_set_error("参数为空"); + return HJ_ERR_INVALID_ARGUMENT; + } + *out = nullptr; + std::string json; + json += "{\"type\":\"content.block.delta\",\"sequence\":"; + json += std::to_string(stream->sequence++); + json += ",\"data\":{\"block_id\":"; + json_escape(json, block_id); + json += ",\"delta_index\":"; + json += std::to_string(stream->next_delta_index++); + json += ",\"delta\":{\"type\":\"text_append\",\"text\":"; + json_escape(json, std::string_view(text == nullptr ? "" : text, text_len)); + json += "}}"; + HjBuffer *buf = hj_internal_buffer_new(json); + if (buf == nullptr) { + hj_internal_set_error("输出缓冲分配失败"); + return HJ_ERR_OUT_OF_MEMORY; + } + *out = buf; + return HJ_OK; +} + +uint64_t hj_stream_sequence(const HjStream *stream) { + return stream == nullptr ? 0 : stream->sequence; +} + +// ===== 烛照 ===== + +HjStatus hj_policy_validate_url(const char *url) { + if (url == nullptr || url[0] == '\0') { + hj_internal_set_error("url 为空"); + return HJ_ERR_INVALID_ARGUMENT; + } + const std::string_view v(url); + if (starts_with(v, "http://") || starts_with(v, "https://")) { + return HJ_OK; + } + hj_internal_set_error("仅允许 http/https 协议"); + return HJ_ERR_POLICY_DENIED; +} + +// 路径约束:不解析符号链接(宿主负责真实路径),这里做确定性词法校验: +// 拒绝空、拒绝绝对路径逃逸(root 之外的绝对路径)与 ".." 段。 +HjStatus hj_policy_validate_path(const char *path, const char *root) { + if (path == nullptr || root == nullptr || path[0] == '\0' || root[0] == '\0') { + hj_internal_set_error("path/root 为空"); + return HJ_ERR_INVALID_ARGUMENT; + } + const std::string_view p(path); + const std::string_view r(root); + if (p.find("..") != std::string_view::npos) { + hj_internal_set_error("路径包含 .. 段"); + return HJ_ERR_POLICY_DENIED; + } + if (!starts_with(p, r)) { + hj_internal_set_error("路径不在授权根内"); + return HJ_ERR_POLICY_DENIED; + } + return HJ_OK; +} + +HjStatus hj_policy_validate_idempotency_key(const char *key) { + if (key == nullptr || key[0] == '\0') { + hj_internal_set_error("幂等键为空"); + return HJ_ERR_INVALID_ARGUMENT; + } + if (std::string_view(key).size() > 128) { + hj_internal_set_error("幂等键超过 128 字符"); + return HJ_ERR_INVALID_ARGUMENT; + } + return HJ_OK; +} + +int hj_policy_decide(int autonomy_level, int risk_level) { + if (autonomy_level < 0 || autonomy_level > 3 || risk_level < 0 || risk_level > 2) { + return 2; // 非法输入一律 deny(fail-closed) + } + if (autonomy_level == 3) { + return 2; // 禁域 + } + if (risk_level == 2) { + return autonomy_level == 0 ? 2 : 1; // 高影响:自省 deny,其余请命 + } + if (risk_level == 1) { + return autonomy_level == 2 ? 1 : 1; // 副作用:至少请命 + } + return autonomy_level == 2 ? 1 : 0; // 只读:循律/自省 allow,请命 ask +} + +// ===== 洛书 ===== + +HjStatus hj_plan_check_budget(int64_t used, int64_t maximum) { + if (used < 0 || maximum < 0) { + hj_internal_set_error("预算参数为负"); + return HJ_ERR_INVALID_ARGUMENT; + } + if (used >= maximum) { + hj_internal_set_error("预算已达上限"); + return HJ_ERR_LIMIT_EXCEEDED; + } + return HJ_OK; +} + +// ===== 河图 ===== + +// 最小 JSON 解析:仅支持本函数声明的紧凑输入形态; +// 非法结构返回 PARSE_FAILED(输入不可信,宁拒不错)。 +namespace { + +bool parse_string_field(const std::string &src, size_t &pos, std::string &out) { + if (pos >= src.size() || src[pos] != '"') return false; + ++pos; + out.clear(); + while (pos < src.size() && src[pos] != '"') { + if (src[pos] == '\\' && pos + 1 < src.size()) { + out.push_back(src[pos + 1]); + pos += 2; + } else { + out.push_back(src[pos++]); + } + } + if (pos >= src.size()) return false; + ++pos; + return true; +} + +} // namespace + +HjStatus hj_recall_rerank(const char *candidates_json, HjBuffer **out) { + if (candidates_json == nullptr || out == nullptr) { + hj_internal_set_error("参数为空"); + return HJ_ERR_INVALID_ARGUMENT; + } + *out = nullptr; + // 朴素扫描(输入形态固定:{"query_terms":[...],"candidates":[{"id":"","text":""}]}) + const std::string src(candidates_json); + std::vector terms; + std::vector> candidates; // (id, text) + size_t pos = 0; + const std::string terms_key = "\"query_terms\""; + const std::string cand_key = "\"candidates\""; + auto find_key = [&](const std::string &key) -> bool { + size_t at = src.find(key, pos); + if (at == std::string::npos) return false; + pos = at + key.size(); + while (pos < src.size() && (src[pos] == ' ' || src[pos] == ':')) ++pos; + return true; + }; + if (!find_key(terms_key) || pos >= src.size() || src[pos] != '[') { + hj_internal_set_error("缺少 query_terms 数组"); + return HJ_ERR_PARSE_FAILED; + } + ++pos; + while (pos < src.size() && src[pos] != ']') { + if (src[pos] == ',') { + ++pos; + continue; + } + std::string term; + if (!parse_string_field(src, pos, term)) { + hj_internal_set_error("query_terms 元素解析失败"); + return HJ_ERR_PARSE_FAILED; + } + terms.push_back(term); + } + if (!find_key(cand_key) || pos >= src.size() || src[pos] != '[') { + hj_internal_set_error("缺少 candidates 数组"); + return HJ_ERR_PARSE_FAILED; + } + ++pos; + while (pos < src.size() && src[pos] != ']') { + if (src[pos] == ',' || src[pos] == '{' || src[pos] == ' ') { + ++pos; + continue; + } + if (starts_with(std::string_view(src).substr(pos), "\"id\"")) { + pos += 4; + while (pos < src.size() && (src[pos] == ' ' || src[pos] == ':')) ++pos; + std::string id; + if (!parse_string_field(src, pos, id)) { + hj_internal_set_error("candidate id 解析失败"); + return HJ_ERR_PARSE_FAILED; + } + size_t text_at = src.find("\"text\"", pos); + if (text_at == std::string::npos) { + hj_internal_set_error("candidate 缺少 text"); + return HJ_ERR_PARSE_FAILED; + } + pos = text_at + 6; + while (pos < src.size() && (src[pos] == ' ' || src[pos] == ':')) ++pos; + std::string text; + if (!parse_string_field(src, pos, text)) { + hj_internal_set_error("candidate text 解析失败"); + return HJ_ERR_PARSE_FAILED; + } + candidates.emplace_back(id, text); + size_t close = src.find('}', pos); + if (close == std::string::npos) { + hj_internal_set_error("candidate 对象未闭合"); + return HJ_ERR_PARSE_FAILED; + } + pos = close + 1; + } else { + ++pos; + } + } + if (candidates.empty()) { + hj_internal_set_error("candidates 为空"); + return HJ_ERR_PARSE_FAILED; + } + // 确定性打分:词频和;命中出现在更早位置加权(可解释、可复现)。 + struct Scored { + std::string id; + int64_t score; + size_t origin; // 稳定排序保持输入顺序 + }; + std::vector scored; + scored.reserve(candidates.size()); + for (size_t i = 0; i < candidates.size(); ++i) { + int64_t score = 0; + for (const auto &term : terms) { + size_t at = candidates[i].second.find(term); + while (at != std::string::npos) { + score += 10; + at = candidates[i].second.find(term, at + term.size()); + } + } + scored.push_back({candidates[i].first, score, i}); + } + std::stable_sort(scored.begin(), scored.end(), [](const Scored &a, const Scored &b) { + if (a.score != b.score) return a.score > b.score; + return a.origin < b.origin; + }); + std::string json = "{\"ranked\":["; + bool first = true; + for (const auto &s : scored) { + if (!first) json.push_back(','); + first = false; + json += "{\"id\":"; + json_escape(json, s.id); + json += ",\"score\":"; + json += std::to_string(s.score); + json += "}"; + } + json += "]}"; + HjBuffer *buf = hj_internal_buffer_new(json); + if (buf == nullptr) { + hj_internal_set_error("输出缓冲分配失败"); + return HJ_ERR_OUT_OF_MEMORY; + } + *out = buf; + return HJ_OK; +} + +} // extern "C" diff --git a/engine/tests/test_modules.cpp b/engine/tests/test_modules.cpp new file mode 100644 index 0000000..a30384e --- /dev/null +++ b/engine/tests/test_modules.cpp @@ -0,0 +1,106 @@ +// 小周天/烛照/洛书/河图模块测试。 +#include +#include + +#include + +#include "hunjing/lingtai.h" + +namespace { + +struct Engine { + HjEngine *ptr = nullptr; + Engine() { REQUIRE(hj_engine_new(HJ_ABI_VERSION, &ptr) == HJ_OK); } + ~Engine() { hj_engine_free(ptr); } +}; + +std::string buffer_text(HjBuffer *buf) { + std::string out(static_cast(hj_buffer_data(buf)), hj_buffer_len(buf)); + hj_buffer_free(buf); + return out; +} + +} // namespace + +TEST_CASE("小周天:delta envelope 确定性编码与序号单调") { + Engine e; + HjStream *stream = nullptr; + REQUIRE(hj_stream_new(e.ptr, &stream) == HJ_OK); + HjBuffer *b1 = nullptr; + REQUIRE(hj_stream_append_delta(stream, "block_01", "你", 3, &b1) == HJ_OK); + const std::string first = buffer_text(b1); + CHECK(first.find("\"type\":\"content.block.delta\"") != std::string::npos); + CHECK(first.find("\"sequence\":0") != std::string::npos); + CHECK(first.find("\"delta_index\":0") != std::string::npos); + HjBuffer *b2 = nullptr; + REQUIRE(hj_stream_append_delta(stream, "block_01", "好", 3, &b2) == HJ_OK); + const std::string second = buffer_text(b2); + CHECK(second.find("\"sequence\":1") != std::string::npos); + CHECK(second.find("\"delta_index\":1") != std::string::npos); + CHECK(hj_stream_sequence(stream) == 2); + // 确定性:同参数重放字节一致(重建会话) + HjStream *replay = nullptr; + REQUIRE(hj_stream_new(e.ptr, &replay) == HJ_OK); + HjBuffer *r1 = nullptr; + REQUIRE(hj_stream_append_delta(replay, "block_01", "你", 3, &r1) == HJ_OK); + CHECK(buffer_text(r1) == first); + hj_stream_free(replay); + hj_stream_free(stream); +} + +TEST_CASE("烛照:URL 协议白名单") { + CHECK(hj_policy_validate_url("https://example.com/a") == HJ_OK); + CHECK(hj_policy_validate_url("http://example.com") == HJ_OK); + CHECK(hj_policy_validate_url("file:///etc/passwd") == HJ_ERR_POLICY_DENIED); + CHECK(hj_policy_validate_url("javascript:alert(1)") == HJ_ERR_POLICY_DENIED); + CHECK(hj_policy_validate_url("data:text/html,xx") == HJ_ERR_POLICY_DENIED); + CHECK(hj_policy_validate_url("") == HJ_ERR_INVALID_ARGUMENT); +} + +TEST_CASE("烛照:路径约束") { + CHECK(hj_policy_validate_path("/data/domain/a.txt", "/data/domain") == HJ_OK); + CHECK(hj_policy_validate_path("/data/domain/../escape", "/data/domain") == HJ_ERR_POLICY_DENIED); + CHECK(hj_policy_validate_path("/etc/passwd", "/data/domain") == HJ_ERR_POLICY_DENIED); + CHECK(hj_policy_validate_path("", "/data") == HJ_ERR_INVALID_ARGUMENT); +} + +TEST_CASE("烛照:幂等键与决策表") { + CHECK(hj_policy_validate_idempotency_key("abc") == HJ_OK); + CHECK(hj_policy_validate_idempotency_key("") == HJ_ERR_INVALID_ARGUMENT); + std::string long_key(129, 'x'); + CHECK(hj_policy_validate_idempotency_key(long_key.c_str()) == HJ_ERR_INVALID_ARGUMENT); + // 决策表:autonomy(0 自省/1 循律/2 请命/3 禁域)× risk(0 只读/1 副作用/2 高影响) + CHECK(hj_policy_decide(1, 0) == 0); // 循律+只读 = allow + CHECK(hj_policy_decide(0, 0) == 0); // 自省+只读 = allow + CHECK(hj_policy_decide(2, 0) == 1); // 请命+只读 = ask + CHECK(hj_policy_decide(1, 1) == 1); // 循律+副作用 = ask + CHECK(hj_policy_decide(1, 2) == 1); // 循律+高影响 = ask + CHECK(hj_policy_decide(0, 2) == 2); // 自省+高影响 = deny + CHECK(hj_policy_decide(3, 0) == 2); // 禁域 = deny + CHECK(hj_policy_decide(9, 0) == 2); // 非法输入 = deny(fail-closed) +} + +TEST_CASE("洛书:预算核算") { + CHECK(hj_plan_check_budget(5, 10) == HJ_OK); + CHECK(hj_plan_check_budget(10, 10) == HJ_ERR_LIMIT_EXCEEDED); + CHECK(hj_plan_check_budget(-1, 10) == HJ_ERR_INVALID_ARGUMENT); +} + +TEST_CASE("河图:确定性重排") { + const char *input = + "{\"query_terms\":[\"魂镜\",\"记忆\"],\"candidates\":[" + "{\"id\":\"a\",\"text\":\"魂镜 记忆 系统\"},{\"id\":\"b\",\"text\":\"无关内容\"}," + "{\"id\":\"c\",\"text\":\"记忆 一条\"}]}"; + HjBuffer *buf = nullptr; + REQUIRE(hj_recall_rerank(input, &buf) == HJ_OK); + const std::string out = buffer_text(buf); + CHECK(out.find("{\"id\":\"a\",\"score\":20}") != std::string::npos); + CHECK(out.find("{\"id\":\"c\",\"score\":10}") != std::string::npos); + CHECK(out.find("{\"id\":\"b\",\"score\":0}") != std::string::npos); + CHECK(out.find("\"a\"") < out.find("\"c\"")); + HjBuffer *again = nullptr; + REQUIRE(hj_recall_rerank(input, &again) == HJ_OK); + CHECK(buffer_text(again) == out); + HjBuffer *bad = nullptr; + CHECK(hj_recall_rerank("not json", &bad) == HJ_ERR_PARSE_FAILED); +}