From ffd6dbcb1662f1e458021ede4150c1a3440b8028 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 04:02:17 +0000 Subject: [PATCH 01/30] chore(deps): bump actions/cache from 4 to 6 Bumps [actions/cache](https://github.com/actions/cache) from 4 to 6. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6b4976..1937c40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: # bge-small-zh 模型在测试时由 hf-hub 下载,缓存避免三平台重复下载。 - name: Cache embedding model - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ~/.cache/huggingface key: hf-${{ runner.os }}-bge-small-zh From 75bfb85ed322c102c7d8de0d35b7f243eec5b82a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 04:02:22 +0000 Subject: [PATCH 02/30] chore(deps): bump actions/checkout from 4 to 7 Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/branch-drift.yml | 2 +- .github/workflows/ci.yml | 6 +++--- .github/workflows/deny-drift.yml | 2 +- .github/workflows/mutants.yml | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/branch-drift.yml b/.github/workflows/branch-drift.yml index 05be4b9..44829a3 100644 --- a/.github/workflows/branch-drift.yml +++ b/.github/workflows/branch-drift.yml @@ -29,7 +29,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: # 需要完整历史才能数提交数 fetch-depth: 0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6b4976..157e660 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] timeout-minutes: 90 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable with: @@ -50,7 +50,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable with: @@ -81,7 +81,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: EmbarkStudios/cargo-deny-action@v2 with: diff --git a/.github/workflows/deny-drift.yml b/.github/workflows/deny-drift.yml index 7587d17..9e93c27 100644 --- a/.github/workflows/deny-drift.yml +++ b/.github/workflows/deny-drift.yml @@ -33,7 +33,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: # 定时任务本身由 main 上的本文件触发,但检查的必须是 dev 的工作树与 # dev 的 deny.toml。 diff --git a/.github/workflows/mutants.yml b/.github/workflows/mutants.yml index 6071989..36fc21e 100644 --- a/.github/workflows/mutants.yml +++ b/.github/workflows/mutants.yml @@ -44,7 +44,7 @@ jobs: if: (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && needs.decide-weekly.outputs.run == 'true' timeout-minutes: 360 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 @@ -75,7 +75,7 @@ jobs: if: github.event_name == 'pull_request' timeout-minutes: 360 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 From cf22e710abe9d76c182ceb64c761be8ebbf79697 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 04:02:28 +0000 Subject: [PATCH 03/30] chore(deps): bump actions/github-script from 7 to 9 Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 9. - [Release notes](https://github.com/actions/github-script/releases) - [Commits](https://github.com/actions/github-script/compare/v7...v9) --- updated-dependencies: - dependency-name: actions/github-script dependency-version: '9' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/branch-drift.yml | 2 +- .github/workflows/deny-drift.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/branch-drift.yml b/.github/workflows/branch-drift.yml index 05be4b9..24f4e18 100644 --- a/.github/workflows/branch-drift.yml +++ b/.github/workflows/branch-drift.yml @@ -43,7 +43,7 @@ jobs: echo "main 落后 dev $behind 个提交(阈值 $DRIFT_THRESHOLD)" - name: 超阈值开 issue,回到阈值内自动关闭 - uses: actions/github-script@v7 + uses: actions/github-script@v9 env: BEHIND: ${{ steps.count.outputs.behind }} THRESHOLD: ${{ env.DRIFT_THRESHOLD }} diff --git a/.github/workflows/deny-drift.yml b/.github/workflows/deny-drift.yml index 7587d17..0cff8f7 100644 --- a/.github/workflows/deny-drift.yml +++ b/.github/workflows/deny-drift.yml @@ -50,7 +50,7 @@ jobs: arguments: --workspace - name: 漂移开 issue,恢复后自动关闭 - uses: actions/github-script@v7 + uses: actions/github-script@v9 env: OUTCOME: ${{ steps.deny.outcome }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} From b09974d14c791ee2ea8d8b9f545873d22d0fcc75 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 04:02:37 +0000 Subject: [PATCH 04/30] chore(deps): bump actions/upload-artifact from 4 to 7 Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/mutants.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mutants.yml b/.github/workflows/mutants.yml index 6071989..b904a66 100644 --- a/.github/workflows/mutants.yml +++ b/.github/workflows/mutants.yml @@ -63,7 +63,7 @@ jobs: - name: Upload mutants report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: mutants-out-weekly path: mutants.out @@ -108,7 +108,7 @@ jobs: - name: Upload mutants report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: mutants-out-pr path: mutants.out From 3dd649883ac653f0e1245a209f116edca81ce159 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 04:02:49 +0000 Subject: [PATCH 05/30] chore(deps): bump text-splitter from 0.29.3 to 0.32.0 Bumps [text-splitter](https://github.com/benbrandt/text-splitter) from 0.29.3 to 0.32.0. - [Release notes](https://github.com/benbrandt/text-splitter/releases) - [Changelog](https://github.com/benbrandt/text-splitter/blob/main/CHANGELOG.md) - [Commits](https://github.com/benbrandt/text-splitter/compare/v0.29.3...v0.32.0) --- updated-dependencies: - dependency-name: text-splitter dependency-version: 0.32.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Cargo.lock | 48 ++++++++++++++++++++++++++++---- crates/soul-mem-query/Cargo.toml | 2 +- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bc7d5ce..1c97072 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2277,7 +2277,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", - "strum", + "strum 0.27.2", "text-splitter 0.29.3", "tokenizers 0.22.2", "tokio", @@ -7449,7 +7449,7 @@ dependencies = [ "serde", "soul-mem-core", "strsim", - "text-splitter 0.29.3", + "text-splitter 0.32.0", "thiserror 2.0.20", "tokio", "wide", @@ -7677,7 +7677,16 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -7692,6 +7701,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" @@ -8157,7 +8178,7 @@ dependencies = [ "itertools 0.14.0", "memchr", "pulldown-cmark", - "strum", + "strum 0.27.2", "thiserror 2.0.20", "tokenizers 0.21.4", ] @@ -8176,11 +8197,28 @@ dependencies = [ "itertools 0.14.0", "memchr", "pulldown-cmark", - "strum", + "strum 0.27.2", "thiserror 2.0.20", "tokenizers 0.22.2", ] +[[package]] +name = "text-splitter" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3eeec76988617ff1434d754d7e8e197be2cb8981ea77c17e32f3f7a2c6f95e" +dependencies = [ + "ahash 0.8.12", + "auto_enums", + "either", + "icu_provider 2.2.0", + "icu_segmenter 2.2.0", + "itertools 0.14.0", + "memchr", + "strum 0.28.0", + "thiserror 2.0.20", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/crates/soul-mem-query/Cargo.toml b/crates/soul-mem-query/Cargo.toml index f98bb15..eb2fa39 100644 --- a/crates/soul-mem-query/Cargo.toml +++ b/crates/soul-mem-query/Cargo.toml @@ -14,7 +14,7 @@ serde = { workspace = true } candle-core = { workspace = true } embed_anything = "0.7.1" rayon = "1.10.0" -text-splitter = "0.29.3" +text-splitter = "0.32.0" strsim = "0.11.1" hotpath = { workspace = true } # SIMD lane 累加(f32x8):Rust 无 fast-math,普通 += 归约不会被向量化, From b83e362a3ecbe4c0e686b2e231f898ebd5188615 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 04:02:53 +0000 Subject: [PATCH 06/30] chore(deps): bump flutter_rust_bridge from 2.12.0 to 2.13.0 Bumps [flutter_rust_bridge](https://github.com/fzyzcjy/flutter_rust_bridge) from 2.12.0 to 2.13.0. - [Release notes](https://github.com/fzyzcjy/flutter_rust_bridge/releases) - [Changelog](https://github.com/fzyzcjy/flutter_rust_bridge/blob/master/CHANGELOG.md) - [Commits](https://github.com/fzyzcjy/flutter_rust_bridge/compare/v2.12.0...v2.13.0) --- updated-dependencies: - dependency-name: flutter_rust_bridge dependency-version: 2.13.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Cargo.lock | 8 ++++---- crates/soul-tune-api/Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bc7d5ce..c0a50d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2584,9 +2584,9 @@ checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" [[package]] name = "flutter_rust_bridge" -version = "2.12.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0884853aae8a6517b5b58cf36f55da487f2fe110e1686938eb29b6640aae4a5" +checksum = "24a973d15b8fc7de9fb29cdfbec58a2ac41f6252492d3b882abfdc603e32b34f" dependencies = [ "allo-isolate", "android_logger", @@ -2613,9 +2613,9 @@ dependencies = [ [[package]] name = "flutter_rust_bridge_macros" -version = "2.12.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b5ce32f35f710ced8c5aa557f023f1a624e737b5460cee2b70fcd3a8df09e1b" +checksum = "901eec2d226107dad1c55b30f7421c4687bebcf70ed43ca44d1b447ca087b238" dependencies = [ "hex", "md-5", diff --git a/crates/soul-tune-api/Cargo.toml b/crates/soul-tune-api/Cargo.toml index b7b574e..309676e 100644 --- a/crates/soul-tune-api/Cargo.toml +++ b/crates/soul-tune-api/Cargo.toml @@ -10,7 +10,7 @@ license = "MIT" crate-type = ["cdylib", "staticlib"] [dependencies] -flutter_rust_bridge = "=2.12.0" +flutter_rust_bridge = "=2.13.0" soul-tune = { path = "../soul-tune" } soul-mem-core = { path = "../soul-mem-core" } soul-mem-query = { path = "../soul-mem-query" } From 977087a027e67eb39dc995f745441f7c99d5a3b4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 04:40:48 +0000 Subject: [PATCH 07/30] chore(deps): bump serde from 1.0.228 to 1.0.229 Bumps [serde](https://github.com/serde-rs/serde) from 1.0.228 to 1.0.229. - [Release notes](https://github.com/serde-rs/serde/releases) - [Commits](https://github.com/serde-rs/serde/compare/v1.0.228...v1.0.229) --- updated-dependencies: - dependency-name: serde dependency-version: 1.0.229 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Cargo.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1c97072..87f883a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7106,9 +7106,9 @@ checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -7116,22 +7116,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] From b119d2cfccf4b76fb82640d755836a72f1167cf2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 04:40:51 +0000 Subject: [PATCH 08/30] chore(deps): bump tokio from 1.52.3 to 1.53.1 Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.52.3 to 1.53.1. - [Release notes](https://github.com/tokio-rs/tokio/releases) - [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.52.3...tokio-1.53.1) --- updated-dependencies: - dependency-name: tokio dependency-version: 1.53.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Cargo.lock | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1c97072..9d6e9d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -242,7 +242,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -253,7 +253,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1313,7 +1313,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1993,7 +1993,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2375,7 +2375,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4134,7 +4134,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4975,7 +4975,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6850,7 +6850,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6909,7 +6909,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7085,7 +7085,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7353,7 +7353,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -8130,7 +8130,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8449,9 +8449,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -9412,7 +9412,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] From 7a5b724197f7ea11a1fbf1274b889fcc20a9e1a8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 04:50:58 +0000 Subject: [PATCH 09/30] chore(deps): bump log from 0.4.30 to 0.4.34 Bumps [log](https://github.com/rust-lang/log) from 0.4.30 to 0.4.34. - [Release notes](https://github.com/rust-lang/log/releases) - [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md) - [Commits](https://github.com/rust-lang/log/compare/0.4.30...0.4.34) --- updated-dependencies: - dependency-name: log dependency-version: 0.4.34 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9fc5253..9fbe444 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4491,9 +4491,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.30" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "loop9" From ec2a5d49b730deaa9634f61d1995ef8088a79c7d Mon Sep 17 00:00:00 2001 From: dynamder Date: Sun, 27 Sep 2026 12:13:15 +0800 Subject: [PATCH 10/30] =?UTF-8?q?docs(convention):=20=E6=96=B0=E5=A2=9E=20?= =?UTF-8?q?docs/dev-convention.md=20=E4=BD=9C=E4=B8=BA=E5=8D=8F=E4=BD=9C?= =?UTF-8?q?=E8=A7=84=E8=8C=83=E5=94=AF=E4=B8=80=E6=9D=83=E5=A8=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 收拢分支模型、提交格式、推进流程、发布节奏、文档规范 - 提交规范改为硬性 Conventional Commits,不追溯历史 - 发布流程定义两种触发:每两周定期、版本完成 - docs/README.md 登记索引,CONTRIBUTING.md 改为指向 --- CONTRIBUTING.md | 12 +-- docs/README.md | 10 ++- docs/dev-convention.md | 181 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 195 insertions(+), 8 deletions(-) create mode 100644 docs/dev-convention.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5829ec2..2fedd65 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,9 @@ 感谢你愿意为 SoulMem 贡献代码、文档或想法! +**协作规范以 [`docs/dev-convention.md`](docs/dev-convention.md) 为唯一权威**(分支模型、提交信息、 +推进流程、发布节奏、文档规范);冲突以它为准。以下各节为摘要。 + ## 分支模型 本仓库有两条长期分支。下面的规则是硬性的,它们来自一次真实的教训——`main` 曾经在无人察觉的 @@ -57,10 +60,6 @@ git worktree add ../SoulMem-book doc/book - PR 还会按本次改动范围运行 cargo-mutants,杀灭率低于 90% 会直接失败。 - 每周(每隔一周)自动执行一次全量 mutants,并上传报告 artifact。 - 依赖安全由 Dependabot(自动更新 PR)和 cargo-deny(安全公告 + 许可证)把关。 -- `Deny drift` workflow 每周在 `dev` 上单独跑一次同一份 cargo-deny。上游 advisory 库 - 漂移(新增公告、crate 被 yank)时它会开 issue 并让 run 变红。**这类失败与具体 PR 无关**: - advisory 检查是全局的,基于 `dev` 的所有 PR 会一起红在同一个 `Security (cargo-deny)` 上, - 所以先看 issue 正文的处理步骤,不要靠往 `deny.toml` 的 `ignore` 里塞条目让它变绿。 ## 测试要求 @@ -72,5 +71,8 @@ git worktree add ../SoulMem-book doc/book ## 提交规范 -- 提交信息建议遵循 `type(scope): 描述` 的格式,例如 `fix(retrieve): 修复PPR传播截断`。 +- 提交信息**必须**遵循 Conventional Commits:`(): <描述>`,其中 `type` 只能取 + `feat` `fix` `refactor` `perf` `docs` `test` `chore` `ci` `build` `style` `revert`。 + 例:`fix(retrieve): 修复PPR传播截断`。CI 会检查本次 PR 新增提交的**首行**——完整规则见 + [`docs/dev-convention.md`](docs/dev-convention.md) §2。 - 保持小步提交,一个提交只做一件事。 diff --git a/docs/README.md b/docs/README.md index 6e55f67..973384a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ | 文档 | 内容 | |---|---| +| [`dev-convention.md`](dev-convention.md) | **协作规范的唯一权威出处**:分支模型、提交信息格式(Conventional Commits)、推进流程、发布节奏(`dev` 如何合入 `main`)、门禁清单,以及 §6 的文档规范 | | [`测试数据规范.md`](测试数据规范.md) | **测试数据集的唯一权威规范**:Graph JSON、Query JSON、BlendSweep 配置、完整示例。与 `fixtures/` 实际内容一致 | | [`architecture/激发测试设计.md`](architecture/激发测试设计.md) | 激发测试(excitation)的接口契约、断言清单 E1~E6、延缓指标定义与质量门槛 | | [`architecture/算法测试.md`](architecture/算法测试.md) | 记忆算法测试的流程定义与图谱变换操作 | @@ -50,6 +51,9 @@ ## 维护提示 -新增文档时请归入上面对应的一节。若某份"规范"被取代,**在同一提交里更新本索引并标注取代关系**, -不要把两份互相矛盾的规范并列留在目录里——这个目录曾经在"测试数据格式/规范"上出现过 -两份命名相近、字段互不相同的文档,导致读者无法判断哪份权威。 +新增、重命名、删除本目录下的文档,**必须在同一个提交里**更新上面的索引——完整规则见 +[`dev-convention.md`](dev-convention.md) §6.3。新增文档时请归入上面对应的一节。 + +若某份"规范"被取代,同样在**同一提交里**标注取代关系,不要把两份互相矛盾的规范并列留在目录里: +这个目录曾经在"测试数据格式/规范"上出现过两份命名相近、字段互不相同的文档,导致读者无法判断 +哪份权威。 diff --git a/docs/dev-convention.md b/docs/dev-convention.md new file mode 100644 index 0000000..ec81d22 --- /dev/null +++ b/docs/dev-convention.md @@ -0,0 +1,181 @@ +# dev 约定 + +SoulMem 协作规范的唯一权威出处。[`CONTRIBUTING.md`](../CONTRIBUTING.md) 为简版入口;冲突以本文件为准。 + +**适用范围**:新提交与新 PR。已合并的历史不追溯、不重写。 + +--- + +## 1. 分支模型 + +| 分支 | 规则 | +|---|---| +| `main` | 发布线。只接受来自 `dev` 的 release PR;禁止直接提交 | +| `dev` | 唯一的集成分支。所有普通 PR 的目标 | +| 主题分支 | 自 `dev` 切出。前缀 `feat/` `fix/` `chore/` `docs/` `refactor/` `perf/` | + +- 主题分支寿命 ≤ 1 周,且提交数 ≤ 20;超出则拆分。 +- 分支保护(`main` 与 `dev` 相同,`enforce_admins = true`):§4 的 5 个必需检查;`main` 另需 1 个 approve。 +- 工程规范类改动单独开 PR:CI 配置、门禁脚本、`.editorconfig`、`.gitattributes`、`AGENTS.md`、本文件、文档索引。 + +--- + +## 2. 提交信息 + +Conventional Commits。格式参考 [Cheatsheet](https://gist.github.com/qoomon/5dfcdf8eec66a051ecd85625518cfd13)。 + +``` +(): + +<可选 body> + +<可选 footer> +``` + +| 部分 | 规则 | +|---|---| +| `type` | 小写。白名单:`feat` `fix` `refactor` `perf` `style` `test` `docs` `build` `ci` `chore` `revert` | +| `scope` | 可选。填受影响模块或 crate;单个值;不用 issue 号 | +| `description` | 必填。祈使/陈述式;不加句号;英文小写开头 | +| `body` | 可选。动机与行为差异;子弹列表 3~5 条;不写段落 | +| `footer` | 可选。`Closes #123`;破坏性变更必须写 `BREAKING CHANGE: <影响>` | +| 破坏性变更 | `!` 置于 `:` 之前,如 `feat(api)!: ...` | + +- 一个提交只做一件事。 +- 门禁仅校验 `type` / `scope` / `description`(§7);语气、篇幅与正文结构由 review 把关。 + +```bash +python3 scripts/check_commit_messages.py origin/dev..HEAD +``` + +--- + +## 3. 推进流程 + +1. 开 issue(推荐):bug 用 `bug_report`,需求用 `feature_request`。blank issue 已禁用。 +2. 自 `dev` 切主题分支:`git switch -c feat/xxx origin/dev`。 +3. 本地通过 §4 的全部门禁。 +4. 开 PR 到 `dev`;按 `.github/PULL_REQUEST_TEMPLATE.md` 填写,关联 issue 写 `Closes #N`。 +5. 5 个必需检查全绿后合并。 +6. 合并方式为 merge commit;禁止 squash。 +7. 删除主题分支。 + +--- + +## 4. 门禁 + +| 检查 | 内容 | +|---|---| +| `Quality (fmt + clippy)` | 编码、模块布局、提交信息 → `cargo fmt --check` → `clippy -D warnings` | +| `Test (ubuntu / windows / macos)` | `cargo build --all-targets` + `cargo test --workspace` | +| `Security (cargo-deny)` | RustSec advisory + 许可证合规 | + +纯文本门禁不需要工具链,提交前本地执行: + +```bash +python3 scripts/check_encoding.py +python3 scripts/check_layout.py +python3 scripts/check_commit_messages.py <范围> +``` + +`Mutants (PR diff)` 为非必需检查:按改动范围运行 cargo-mutants,杀灭率 <90% 失败。 + +`Security (cargo-deny)` 失败而本次改动未触及依赖时,属上游 advisory 漂移;处理步骤见 `Deny drift` workflow 所开 issue。不得向 `deny.toml` 的 `ignore` 添加条目消红。 + +--- + +## 5. 发布流程 + +触发(任一): + +1. 定期:每两周一次,与全量 mutants 同周节奏(周一,ISO 奇数周)。 +2. 版本驱动:该版本功能开发完成时。 + +步骤: + +1. 开 release PR(`dev` → `main`),标题 `release: vX.Y.Z`,正文写变更摘要。 +2. 合并条件:1 个 approve + 5 个必需检查全绿。`main` 不接受其他 PR。 +3. 合并后打 annotated tag:`git tag -a vX.Y.Z -m "..."`,并写 release notes。 +4. 立即把 `main` 合回 `dev`:`--no-ff` 合并提交,经 PR 完成。 +5. 兜底:`main` 落后 `dev` 超过 30 个提交时,`Branch drift` workflow 开 issue。 + +版本号:`0.x` 阶段破坏性变更升 minor,其余升 patch;`1.0.0` 起按 SemVer。 + +--- + +## 6. 文档规范 + +### 6.1 落点 + +| 内容 | 落点 | 权威性 | +|---|---|---| +| 规范 | `docs/` 根 | 权威 | +| 架构说明 | `docs/architecture/` | 描述当前实现 | +| 一次性报告 | `docs/` 或 `docs/tests/` | 仅对当时的 commit 与数据集有效 | +| crate 用法 | `crates//docs/` | 该 crate 的权威 | +| 成书章节 | `doc/book` 分支的 `book/src/**` | 见 6.5 | + +- 同一事实只允许一个权威出处;别处用相对链接引用,不复制。 +- 一次性报告必须注明测量时间点(或 commit)、数据集、环境,并声明其非当前状态。 + +### 6.2 命名 + +- 文件名可用中文(既有先例);不改名既有文档。 +- 迭代报告用 `-v2` / `-v3` 后缀,并在新版本注明取代关系。 +- 目录索引统一为 `README.md`。 +- 非 ASCII 文件名:解析 `git diff` 的工具须设 `-c core.quotepath=false`(见 CI 的 mutants 步骤)。 + +### 6.3 索引维护 + +- 新增、重命名、删除 `docs/` 下的文档,必须在同一提交更新 [`docs/README.md`](README.md)。 +- 规范被取代时,在同一提交标注取代关系;不并列两套相互矛盾的规范。 +- 本条无门禁,由 review 把关。 + +### 6.4 编码与格式 + +- 标识符 ASCII;注释与文档用中文。 +- 文件编码 UTF-8:`.editorconfig` 约束,`scripts/check_encoding.py` 强制。 +- `*.md` 行尾 LF(`.gitattributes`);`*.rs` / `*.toml` 不强制,统一须单独提交。 +- markdown:每文件一个 H1 且与索引名一致;标题不跳级;相对链接;代码块标语言;表格仅用于结构化对照。 +- `*.md` 的行尾双空格为硬换行,故 `.editorconfig` 对 `*.md` 关闭 `trim_trailing_whitespace`。 + +### 6.5 禁止改动的区域 + +- `doc/book` 分支的 `book/src/**`:由维护者手工撰写,禁止批量改写、重排或优化。其写作规范见该分支的 `book/src/contributing/writing-style.md`。 +- 忽略规则不得覆盖源码: + + ```gitignore + /book/book/ # 正确:仅忽略 HTML 输出目录 + /book/ # 错误:连带忽略 book/book.toml 与 book/src/** + ``` + + `__pycache__/` 与 `*.pyc` 必须保持忽略(`scripts/` 下为 Python 门禁脚本)。 +- 禁止向 `.git/info/exclude` 添加排除项(仅本机生效、不随仓库分发);并行处理差异较大的分支用 `git worktree`。 + +--- + +## 7. 门禁覆盖现状 + +| 规则 | 状态 | 实现 | +|---|---|---| +| UTF-8 编码(6.4) | 强制 | `scripts/check_encoding.py`(CI `Quality`) | +| 模块布局 | 强制 | `scripts/check_layout.py`(CI `Quality`) | +| 提交信息格式(§2) | 强制 | `scripts/check_commit_messages.py`(CI `Quality`;仅目标为 `dev` 的 PR) | +| 文档索引(6.3) | review | — | +| mutants 杀灭率 ≥90% | 非必需检查 | `scripts/mutants_gate.py`(CI `Mutants`) | +| 标题层级、死链、术语统一 | 待接入 | 计划 mdbook-linkcheck / markdownlint | + +变更门禁时同步更新本表。 + +--- + +## 8. PR 自检清单 + +- [ ] 分支自 `dev` 切出,前缀正确,寿命与提交数符合 §1 +- [ ] 提交信息符合 §2 +- [ ] `check_encoding.py`、`check_layout.py`、`check_commit_messages.py` 通过 +- [ ] `cargo fmt --all --check`、`cargo clippy --workspace --all-targets -- -D warnings` 通过 +- [ ] 关联 issue 已写 `Closes #N` +- [ ] 新增、重命名或删除的文档已登记 `docs/README.md` +- [ ] 未改动 `doc/book` 书稿;未向 `.git/info/exclude` 添加排除项 +- [ ] 改动 `soul-mem-llm` 调用链时已同步 `docs/architecture/llm-layer.md` From fdcdc26554b199be5eb88cbcf3dd7b0dc188b55f Mon Sep 17 00:00:00 2001 From: dynamder Date: Sun, 27 Sep 2026 12:13:15 +0800 Subject: [PATCH 11/30] =?UTF-8?q?ci(commits):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E4=BF=A1=E6=81=AF=E9=97=A8=E7=A6=81=E5=B9=B6?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=20Quality=20job?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 scripts/check_commit_messages.py:校验 type 白名单、`: ` 分隔符、非空描述 - 仅查首行,跳过 merge 与 Revert,不查正文、行长、scope 用词 - 接入既有 Quality job,checkout 改 fetch-depth: 0 - 仅对目标为 dev 的 PR 生效,避免 release PR 带入历史提交 - AGENTS.md 登记脚本与提交信息约定 --- .github/workflows/ci.yml | 17 ++- AGENTS.md | 7 + scripts/check_commit_messages.py | 214 +++++++++++++++++++++++++++++++ 3 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 scripts/check_commit_messages.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55165f2..0c203e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,10 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@v7 + with: + # 提交信息门禁要沿 PR 范围回溯(base.sha..head),默认的浅克隆拿不到区间历史。 + # 只有 quality 这一个 job 需要完整历史,其余 job 保持默认。 + fetch-depth: 0 - uses: dtolnay/rust-toolchain@stable with: @@ -58,7 +62,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - # 两个纯文本门禁放在最前面:不需要工具链、秒级返回,而且——关键——它们必须在 + # 三个纯文本门禁放在最前面:不需要工具链、秒级返回,而且——关键——它们必须在 # `cargo fmt` 之前运行。此前它们排在 fmt 之后,于是 fmt 一红这两步就被 skip, # 门禁等于没装(正是这条注释原本想表达的意思,但位置写错了)。 # @@ -70,6 +74,17 @@ jobs: - name: Check module layout run: python3 scripts/check_layout.py + # 提交信息:Conventional Commits,只查本次 PR 新增提交的首行(docs/dev-convention.md §2)。 + # + # 只在「目标分支是 dev」的 PR 上跑。dev → main 的 release PR 会把 dev 上成百个历史 + # 提交一起带进范围,其中包含规范生效前的旧写法——那批历史按约定**不追溯、不重写**, + # 在 release PR 上重跑只会制造一批无解的假红。 + - name: Check commit messages + if: github.event_name == 'pull_request' && github.event.pull_request.base.ref == 'dev' + env: + COMMIT_RANGE: ${{ github.event.pull_request.base.sha }}..${{ github.sha }} + run: python3 scripts/check_commit_messages.py "$COMMIT_RANGE" + - name: Check formatting run: cargo fmt --all --check diff --git a/AGENTS.md b/AGENTS.md index 660655b..bdb5e3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,7 @@ SoulMem 是**角色扮演用的记忆系统**,本质是一个"向量检索 + | `docs/` | 架构与规范文档(见 §6) | | `scripts/check_encoding.py` | 编码门禁(CI 中执行) | | `scripts/check_layout.py` | 模块布局门禁(CI 中执行) | +| `scripts/check_commit_messages.py` | 提交信息门禁(Conventional Commits,CI 中执行) | 依赖方向:`core → query/runtime/llm → algo → soul-tune → soul-tune-api`。 `runtime → algo` 仅存在于 **dev-dependencies**,生产依赖图中没有反向边。 @@ -78,6 +79,7 @@ cargo fmt --all --check cargo clippy --workspace --all-targets -- -D warnings python3 scripts/check_encoding.py # 编码门禁 python3 scripts/check_layout.py # 模块布局门禁 +python3 scripts/check_commit_messages.py # 提交信息门禁(Conventional Commits) cargo mutants --workspace # 杀灭率 ≥90%,门禁见 scripts/mutants_gate.py ``` @@ -106,6 +108,10 @@ cargo mutants --workspace # 杀灭率 ≥90%,门禁见 scripts > **不要批量改写、重排或"优化"书正文**——那不是可以自动化的区域。需要文档改动时改 `docs/` 或 crate 内注释。 > 书源码保留在 `doc/book` 分支,当前分支**不含**书源码(磁盘上的 `book/book/` 只是 HTML 构建产物)。 +**分支模型、提交信息、推进与发布流程、文档规范以 +[`docs/dev-convention.md`](docs/dev-convention.md) 为唯一权威**([`CONTRIBUTING.md`](CONTRIBUTING.md) +为简版入口)。改文档前另见该文件 §6。 + **取用文档时的三条规则**: - **测试数据集**以 [`docs/测试数据规范.md`](docs/测试数据规范.md) 为准。 @@ -127,6 +133,7 @@ cargo mutants --workspace # 杀灭率 ≥90%,门禁见 scripts - **`.cargo/mutants.toml` 的每个排除项都要写原因**。另注意两个坑:`exclude_re` **不作用于结构体字面量字段变异体**(cargo-mutants 27.1.0 实测);"行为可观测但没有测试覆盖"应当补测试,而不是加排除项。 - **编码由 `.editorconfig` 约束为 UTF-8**:中文注释被 GBK 保存会变成乱码且部分字节永久丢失(不可逆)。提交前跑 `python3 scripts/check_encoding.py`。 - **行尾不一致是已知现状**:`core.autocrlf=true` 而索引里部分 blob 本身含 CRLF,工作区因此 CRLF/LF 混杂。`.gitattributes` 目前只统一 `*.md`。**要统一 `.rs` 请单独提一个只改行尾的提交**,不要混在功能改动里。 +- **提交信息**用 Conventional Commits:`(): `。type 取白名单,描述用祈使/陈述式、不加句号;正文用子弹列表列摘要(3~5 条),不写长段落。完整规则与参考见 [`docs/dev-convention.md`](docs/dev-convention.md) §2。 --- diff --git a/scripts/check_commit_messages.py b/scripts/check_commit_messages.py new file mode 100644 index 0000000..20c3170 --- /dev/null +++ b/scripts/check_commit_messages.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Fail on commit subjects that do not follow Conventional Commits. + +**Rule** (authoritative text: `docs/dev-convention.md` §2): every non-merge +commit subject in the range under review must look like one of + + (): + : + ()!: + +where `` is one of ALLOWED_TYPES in lower case, the separator is exactly +`: ` (colon + one space), and `` is non-empty. + +Why a gate and not just a documentation line: the convention existed in +CONTRIBUTING.md only as "建议遵循" -- no type whitelist, no enforcement. The +result is measurable: 111 of the last 300 commits on `dev` carry no `type` +prefix at all, alongside typos like `fear(retrieve_algo)` and free-form subjects +like `wip`. A written rule without a gate decays into a suggestion. + +Scope is deliberately narrow -- this is the "cheap check" half of the split in +`docs/dev-convention.md` §7. The check reads **the subject line only** and +validates **only the four hard rules above**. It does not look at the body, the +footer, subject length, scope spelling, or the language of the description. +Those are review concerns; gating them would make contributors push repeatedly +to satisfy a linter, which is the exact cost this repository's gates avoid. + +Two classes of subject are skipped because the author does not control them: +merge commits (`Merge ...`) and GitHub-generated reverts (`Revert "..."`). + +Only **new** commits are checked, never history: see "只对新提交生效,不追溯 +历史". Callers pass a range; the CI job passes the pull request's range. + +Usage: + python3 scripts/check_commit_messages.py origin/dev..HEAD # a range + python3 scripts/check_commit_messages.py # default: HEAD only + python3 scripts/check_commit_messages.py --list # show the rule, then exit + +Exit codes: + 0 - clean + 1 - findings reported + 2 - usage or IO error +""" + +import argparse +import re +import subprocess +import sys + +# Keep in sync with docs/dev-convention.md §2. +ALLOWED_TYPES = ( + "build", + "chore", + "ci", + "docs", + "feat", + "fix", + "perf", + "refactor", + "revert", + "style", + "test", +) + +# `docs(规范): 描述` / `fix: 描述` / `feat(api)!: 描述` +SUBJECT_RE = re.compile( + r"^(?P[a-z]+)(?:\((?P[^()]*)\))?(?P!)?: (?P.+)$" +) + +SKIPPED_PREFIXES = ("Merge ", 'Revert "') + +# A push event with no previous head reports this as the "before" revision. +ZERO_SHA = "0" * 40 + + +def _log_args(range_spec: str | None) -> list[str]: + """Translate a user-supplied range into `git log` arguments. + + A bare revision means "that single commit", not "everything reachable from + it" -- the default (`HEAD`) must not walk the whole history. + """ + if not range_spec: + return ["-1", "HEAD"] + if ".." in range_spec: + left, _, right = range_spec.partition("..") + left, right = left.strip(), right.strip() or "HEAD" + if not left or set(left) == {"0"}: + return ["-1", right] + return [f"{left}..{right}"] + return ["-1", range_spec] + + +def subjects(range_spec: str | None) -> list[tuple[str, str]]: + """(abbreviated hash, subject) for every non-merge commit in the range.""" + cmd = [ + "git", + "-c", + "core.quotepath=false", + "log", + "--no-merges", + "--format=%H%x1f%s", + *_log_args(range_spec), + ] + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=True, + ) + except FileNotFoundError as exc: + print(f"ERROR: git not found: {exc}", file=sys.stderr) + raise SystemExit(2) from exc + except subprocess.CalledProcessError as exc: + print(f"ERROR: cannot read commits: {exc.stderr.strip()}", file=sys.stderr) + print( + "Hint: pass a range whose left side exists locally, e.g. `origin/dev..HEAD`.\n" + " In CI, the checkout must fetch enough history (fetch-depth: 0).", + file=sys.stderr, + ) + raise SystemExit(2) from exc + + commits = [] + for line in result.stdout.splitlines(): + if not line.strip(): + continue + sha, _, subject = line.partition("\x1f") + commits.append((sha[:7], subject)) + return commits + + +def violations(commits: list[tuple[str, str]]) -> list[tuple[str, str, str]]: + """(hash, subject, reason) for each subject that breaks a hard rule.""" + findings = [] + allowed = ", ".join(ALLOWED_TYPES) + for sha, subject in commits: + if subject.startswith(SKIPPED_PREFIXES): + continue + match = SUBJECT_RE.match(subject) + if not match: + findings.append( + ( + sha, + subject, + "does not match `(): ` " + "(the separator must be `: ` -- colon + exactly one space)", + ) + ) + continue + type_ = match.group("type") + scope = match.group("scope") + if type_ not in ALLOWED_TYPES: + findings.append((sha, subject, f"type {type_!r} is not allowed; allowed: {allowed}")) + elif scope is not None and not scope.strip(): + findings.append((sha, subject, "scope is empty; drop the parentheses or name a scope")) + elif not match.group("description").strip(): + findings.append((sha, subject, "description is empty")) + return findings + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "range", + nargs="?", + help="git revision range to check, e.g. `origin/dev..HEAD` (default: HEAD only)", + ) + parser.add_argument( + "--list", + action="store_true", + help="print the enforced rule, then exit", + ) + args = parser.parse_args() + + if args.list: + print("rule: (): , subject line only") + print(f"allowed types: {', '.join(ALLOWED_TYPES)}") + skipped = " | ".join(repr(prefix) for prefix in SKIPPED_PREFIXES) + print(f"skipped subjects: {skipped}") + print("not checked (deliberately): body, footer, length, scope spelling, language") + return 0 + + commits = subjects(args.range) + if not commits: + print("Commit message check PASS (no commits in range)") + return 0 + + findings = violations(commits) + if not findings: + print(f"Commit message check PASS ({len(commits)} commit(s))") + return 0 + + print( + f"Commit message check FAILED: {len(findings)} of {len(commits)} commit(s) " + "do not follow Conventional Commits.", + file=sys.stderr, + ) + for sha, subject, reason in findings: + print(f" {sha} {subject!r}", file=sys.stderr) + print(f" -> {reason}", file=sys.stderr) + print( + "\nFix: rewrite the subject as `(): `, keeping the type\n" + "inside the whitelist. Amend the tip commit (`git commit --amend`) or reword\n" + "with `git rebase -i`. Only new commits are checked -- do not rewrite history\n" + "that is already on `dev` or `main`.\n" + f"Rule and rationale: docs/dev-convention.md §2. Run `--list` for the short form.", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) From 912d7b1fab9a5fb949c252d64d0c4c0b3bfd5d20 Mon Sep 17 00:00:00 2001 From: dynamder Date: Sun, 27 Sep 2026 13:21:35 +0800 Subject: [PATCH 12/30] =?UTF-8?q?ci(test):=20=E7=BA=AF=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E6=94=B9=E5=8A=A8=E8=B7=B3=E8=BF=87=E4=B8=89=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 scripts/classify_changes.py:判断改动是否全部落在文档白名单内 - 分类不可判定时(区间读不出、改动为空、左端不可用)一律返回 code=true - ci.yml 在 test job 内用步骤级 if 跳过重步骤,job 本身照常运行 - 不用 paths-ignore 也不用 job 级 if:前者不创建必需检查,后者在 matrix 展开前求值 - 分类结果以显式 LF 写入 $GITHUB_OUTPUT:Windows 的 \r\n 会让 != 'false' 失效 --- .github/workflows/ci.yml | 32 +++++++ scripts/classify_changes.py | 185 ++++++++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 scripts/classify_changes.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55165f2..e3a0101 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,24 +25,56 @@ jobs: timeout-minutes: 90 steps: - uses: actions/checkout@v7 + with: + # 文档分类要读区间内的改动文件,浅克隆拿不到区间历史(仓库约 37 MiB,代价可忽略)。 + fetch-depth: 0 + + # 纯文档改动无法影响编译或测试,没必要付三平台全量测试的时间:先分类,再决定下面的 + # 重步骤跑不跑。 + # + # 为什么必须留在 **job 内、用步骤级 if**,而不能给 job 加 `if`,也不能给 + # `on.pull_request` 加 `paths-ignore`: + # - workflow 级路径过滤会让必需检查根本不被创建,PR 永久停在 "Expected — Waiting"; + # - **job 级 `if` 在 matrix 展开之前求值**:被跳过时只上报未插值的模板名 + # `Test (${{ matrix.os }})`,而分支保护要的是 `Test (ubuntu-latest)` 等三个具体 + # 名字——它们永远不会被创建,同样死锁。 + # 步骤级跳过不影响 job 自身完成,三个名字照常上报。 + # + # 输出只有三种可能:`false`(改动全是文档 → 跳过重步骤)、`true`(有代码改动 → 全跑)、 + # 空(非 PR 事件,或本步骤被跳过 → 全跑)。条件写成 `!= 'false'` 是为了让最后一种 + # 落到"全跑",fail-safe。 + # + # 这里**不要**写成 `>> "$GITHUB_OUTPUT"`:Windows 上 Python 以文本模式写 stdout 会把 + # `\n` 变成 `\r\n`,尾随的 CR 混进输出值后 `!= 'false'` 就不再成立,重步骤会在最贵的 + # 那个平台上照跑(首版实测:ubuntu 5s / macos 8s 跳过,windows 编译并跑完测试)。 + # 脚本自己以显式 LF 写输出文件,屏蔽掉这个平台差异。 + - name: Classify changed paths + id: classify + if: github.event_name == 'pull_request' + run: python3 scripts/classify_changes.py "${{ github.event.pull_request.base.sha }}..${{ github.sha }}" - uses: dtolnay/rust-toolchain@stable + if: steps.classify.outputs.code != 'false' with: components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 + if: steps.classify.outputs.code != 'false' # bge-small-zh 模型在测试时由 hf-hub 下载,缓存避免三平台重复下载。 - name: Cache embedding model + if: steps.classify.outputs.code != 'false' uses: actions/cache@v6 with: path: ~/.cache/huggingface key: hf-${{ runner.os }}-bge-small-zh - name: Build all targets + if: steps.classify.outputs.code != 'false' run: cargo build --workspace --all-targets - name: Run tests + if: steps.classify.outputs.code != 'false' run: cargo test --workspace quality: diff --git a/scripts/classify_changes.py b/scripts/classify_changes.py new file mode 100644 index 0000000..1f6b4d1 --- /dev/null +++ b/scripts/classify_changes.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""Decide whether a change set needs the expensive CI matrix. + +This repository runs `cargo build --all-targets` and `cargo test --workspace` on +three platforms for every pull request. A change that only touches documentation +cannot affect compilation or tests, yet it pays the full ~25 minutes -- the wall +clock is set by the slowest platform, so three platforms run in parallel and the +cost is not additive but it is still large. + +Output contract (consumed by the `changes` job in `.github/workflows/ci.yml`): + + stdout: exactly one line, `code=true` or `code=false` + stderr: the human-readable reason (ends up in the job log) + +`code=false` means "documentation only": the caller may skip the test matrix. +It is emitted **only** when every changed path matches DOCS_PATTERNS. Everything +else -- source, data, scripts, workflows -- yields `code=true`, and so does an +empty range or a range that cannot be resolved. The default is deliberately the +expensive one: a wrong `code=false` lets a regression through a green pull +request, while a wrong `code=true` only costs time. + +Why the caller must gate *jobs* and never the `pull_request` trigger: filtering +the workflow itself with `paths:` / `paths-ignore:` means the required checks are +never created at all, so the pull request sits at "Expected -- Waiting for +status" forever. A job that a job-level `if` skips still reports its status as +success, which is what branch protection accepts. + +Usage: + python3 scripts/classify_changes.py # e.g. origin/dev..HEAD + python3 scripts/classify_changes.py --list # print the allowlist + +Exit codes: + 0 - classification produced on stdout + 1 - usage error +""" + +import argparse +import os +import re +import subprocess +import sys + +# A changed path counts as documentation only if it matches at least one pattern. +# Everything else is code. Keep this list narrow: adding `fixtures/` or +# `scripts/` here would let a real behavioural change skip the test matrix. +DOCS_PATTERNS = ( + r"^docs/", # the docs/ tree, whatever the file type (specs, reports, images) + r"\.md$", # markdown anywhere: README, CONTRIBUTING, AGENTS.md, crate docs +) + +# A push event with no previous head reports this as the "before" revision. +ZERO_SHA = "0" * 40 + + +def classify_paths(paths: list[str]) -> tuple[bool, str]: + """(needs_code_ci, reason) for an explicit list of changed paths.""" + if not paths: + return True, "no changed paths in range" + + code_paths = [ + path + for path in paths + if not any(re.search(pattern, path) for pattern in DOCS_PATTERNS) + ] + if not code_paths: + return False, f"all {len(paths)} changed path(s) are documentation" + + preview = ", ".join(code_paths[:5]) + if len(code_paths) > 5: + preview += ", ..." + return True, ( + f"{len(code_paths)}/{len(paths)} changed path(s) are not documentation: {preview}" + ) + + +def changed_paths(range_spec: str) -> list[str]: + """Paths changed in `range_spec`, via `git diff --name-only`.""" + result = subprocess.run( + [ + "git", + "-c", + "core.quotepath=false", + "diff", + # --no-renames keeps a rename visible as delete+add, so both sides are + # classified. Rename detection would hide the removed path. + "--no-renames", + "--name-only", + range_spec, + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=True, + ) + return [line for line in result.stdout.splitlines() if line.strip()] + + +def emit(needs_code_ci: bool, reason: str) -> int: + line = f"code={'true' if needs_code_ci else 'false'}" + + # Write the key=value line to the output file ourselves, with an explicit LF. + # + # Do NOT print the line to stdout and let the workflow do `>> "$GITHUB_OUTPUT"`: + # on Windows Python writes "\r\n" in text mode, the trailing CR lands inside + # the output value, and `steps.classify.outputs.code != 'false'` stops + # matching -- so the expensive steps run on the one platform where they cost + # the most. Measured on the first draft: a docs-only pull request skipped the + # cargo steps in 5s on ubuntu and 8s on macos, while the windows leg compiled + # and ran the full test suite. + output_path = os.environ.get("GITHUB_OUTPUT") + if output_path: + try: + with open(output_path, "a", encoding="utf-8", newline="\n") as handle: + handle.write(line + "\n") + except OSError as exc: + # Leave the output unset: the caller's `!= 'false'` check then falls + # back to running the full matrix, which is the fail-safe direction. + print(f"WARNING: cannot write {output_path}: {exc}", file=sys.stderr) + else: + print(line) + + print(f"Classify: {reason}", file=sys.stderr) + + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if summary_path: + verdict = ( + "code changes -> run the full test matrix" + if needs_code_ci + else "documentation only -> skip the test matrix" + ) + try: + with open(summary_path, "a", encoding="utf-8", newline="\n") as handle: + handle.write(f"### Change classification\n\n`{verdict}`\n\n{reason}\n") + except OSError: + pass # a missing summary file must not change the classification + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "range", + nargs="?", + help="git revision range to classify, e.g. `origin/dev..HEAD`", + ) + parser.add_argument( + "--list", + action="store_true", + help="print the documentation allowlist, then exit", + ) + args = parser.parse_args() + + if args.list: + print("documentation allowlist (docs-only requires every path to match):") + for pattern in DOCS_PATTERNS: + print(f" {pattern}") + print("anything else, an empty range, or an unresolvable range -> code=true") + return 0 + + if not args.range: + print("usage: classify_changes.py ", file=sys.stderr) + return 1 + + left, _, _ = args.range.partition("..") + if left and set(left) == {"0"}: + return emit(True, f"range {args.range} has no usable left side") + + try: + paths = changed_paths(args.range) + except FileNotFoundError as exc: + return emit(True, f"git not found: {exc}") + except subprocess.CalledProcessError as exc: + # git prints the useful message first and usage text after it. + detail = (exc.stderr or "").strip().splitlines() + return emit( + True, + f"cannot read range {args.range!r}: {detail[0] if detail else exc}", + ) + + return emit(*classify_paths(paths)) + + +if __name__ == "__main__": + sys.exit(main()) From cf420bb1cbd5ef1ceab1402bf010dd36746babd4 Mon Sep 17 00:00:00 2001 From: dynamder Date: Sun, 27 Sep 2026 14:37:58 +0800 Subject: [PATCH 13/30] =?UTF-8?q?docs(convention):=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E7=BA=AF=E6=96=87=E6=A1=A3=E6=94=B9=E5=8A=A8=E8=B7=B3=E8=BF=87?= =?UTF-8?q?=20Test=20=E7=9F=A9=E9=98=B5=E7=9A=84=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dev-convention §4 写明跳过范围与不可判定时的回退 - CONTRIBUTING 的 CI 说明补上纯文档例外 - AGENTS.md §2 登记 scripts/classify_changes.py --- AGENTS.md | 1 + CONTRIBUTING.md | 2 +- docs/dev-convention.md | 2 ++ 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index bdb5e3f..fda1cb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ SoulMem 是**角色扮演用的记忆系统**,本质是一个"向量检索 + | `scripts/check_encoding.py` | 编码门禁(CI 中执行) | | `scripts/check_layout.py` | 模块布局门禁(CI 中执行) | | `scripts/check_commit_messages.py` | 提交信息门禁(Conventional Commits,CI 中执行) | +| `scripts/classify_changes.py` | 纯文档改动分类器(CI 据此跳过 `Test` 矩阵;不是门禁) | 依赖方向:`core → query/runtime/llm → algo → soul-tune → soul-tune-api`。 `runtime → algo` 仅存在于 **dev-dependencies**,生产依赖图中没有反向边。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2fedd65..988221c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,7 +56,7 @@ git worktree add ../SoulMem-book doc/book ## CI 说明 -- push 到 `main` / `dev`,以及所有 Pull Request,会在 Windows、Ubuntu、macOS 三平台执行编译与测试。 +- push 到 `main` / `dev`,以及所有 Pull Request,会在 Windows、Ubuntu、macOS 三平台执行编译与测试;**纯文档改动**(全部改动落在 `docs/**` 或 `*.md`)跳过测试矩阵,只跑 `Quality` 与 `Security`。 - PR 还会按本次改动范围运行 cargo-mutants,杀灭率低于 90% 会直接失败。 - 每周(每隔一周)自动执行一次全量 mutants,并上传报告 artifact。 - 依赖安全由 Dependabot(自动更新 PR)和 cargo-deny(安全公告 + 许可证)把关。 diff --git a/docs/dev-convention.md b/docs/dev-convention.md index ec81d22..35918a1 100644 --- a/docs/dev-convention.md +++ b/docs/dev-convention.md @@ -70,6 +70,8 @@ python3 scripts/check_commit_messages.py origin/dev..HEAD | `Test (ubuntu / windows / macos)` | `cargo build --all-targets` + `cargo test --workspace` | | `Security (cargo-deny)` | RustSec advisory + 许可证合规 | +纯文档改动(全部改动落在 `docs/**` 或 `*.md`)跳过 `Test` 矩阵的重步骤:`test` job 本身照常运行并上报三个检查名,只跳过 toolchain、缓存、build 与 test。判定由 `scripts/classify_changes.py` 完成;无法判定时回退到跑全量。 + 纯文本门禁不需要工具链,提交前本地执行: ```bash From 5d99530f5772c6ca90a5a3e90079bb1c1768fff9 Mon Sep 17 00:00:00 2001 From: dynamder Date: Sun, 27 Sep 2026 15:05:31 +0800 Subject: [PATCH 14/30] =?UTF-8?q?docs(convention):=20=E8=A1=A5=E4=B8=8A?= =?UTF-8?q?=E9=A6=96=E4=B8=AA=E5=8F=AF=E7=94=A8=E7=89=88=E6=9C=AC=E4=B9=8B?= =?UTF-8?q?=E5=89=8D=E7=9A=84=E5=90=8C=E6=AD=A5=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §1 的 main 规则改为接受来自 dev 的 release 或同步 PR - §5 写明首个版本之前用同步 PR(先例 #26),跳过 tag 与 release notes --- docs/dev-convention.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/dev-convention.md b/docs/dev-convention.md index 35918a1..d8d5dd3 100644 --- a/docs/dev-convention.md +++ b/docs/dev-convention.md @@ -10,7 +10,7 @@ SoulMem 协作规范的唯一权威出处。[`CONTRIBUTING.md`](../CONTRIBUTING. | 分支 | 规则 | |---|---| -| `main` | 发布线。只接受来自 `dev` 的 release PR;禁止直接提交 | +| `main` | 发布线。只接受来自 `dev` 的 PR(release 或同步,见 §5);禁止直接提交 | | `dev` | 唯一的集成分支。所有普通 PR 的目标 | | 主题分支 | 自 `dev` 切出。前缀 `feat/` `fix/` `chore/` `docs/` `refactor/` `perf/` | @@ -88,6 +88,8 @@ python3 scripts/check_commit_messages.py <范围> ## 5. 发布流程 +**首个可用版本之前**:无版可发,代码同步用**同步 PR**——同样 `dev` → `main`,标题 `chore(main): 同步 dev 到 main(不发布 tag)`;下面第 1、2、4、5 步照做,跳过第 3 步(tag 与 release notes)。先例:#26。 + 触发(任一): 1. 定期:每两周一次,与全量 mutants 同周节奏(周一,ISO 奇数周)。 From 8a67e6da7085a8d9b934d3559a7dec4a8c9d4f7d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:04:38 +0000 Subject: [PATCH 15/30] chore(deps): bump tokenizers from 0.21.4 to 0.22.2 Bumps [tokenizers](https://github.com/huggingface/tokenizers) from 0.21.4 to 0.22.2. - [Release notes](https://github.com/huggingface/tokenizers/releases) - [Commits](https://github.com/huggingface/tokenizers/compare/v0.21.4...v0.22.2) --- updated-dependencies: - dependency-name: tokenizers dependency-version: 0.22.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Cargo.lock | 9 +++++---- crates/soul-tune/Cargo.toml | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cd7d36e..ab82afc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1993,7 +1993,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -4975,7 +4975,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -5959,7 +5959,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.11.0", "proc-macro2", "quote", "syn 2.0.117", @@ -7501,7 +7501,7 @@ dependencies = [ "soul-mem-query", "soul-mem-runtime", "tempfile", - "tokenizers 0.21.4", + "tokenizers 0.22.2", "tokio", ] @@ -8427,6 +8427,7 @@ dependencies = [ "esaxx-rs", "getrandom 0.3.4", "hf-hub 0.4.3", + "indicatif 0.18.4", "itertools 0.14.0", "log", "macro_rules_attribute", diff --git a/crates/soul-tune/Cargo.toml b/crates/soul-tune/Cargo.toml index 657bd68..305c0df 100644 --- a/crates/soul-tune/Cargo.toml +++ b/crates/soul-tune/Cargo.toml @@ -26,7 +26,7 @@ tokio.workspace = true dotenvy = "0.15.7" chrono.workspace = true jieba-rs.workspace = true -tokenizers = { version = "0.21", optional = true } +tokenizers = { version = "0.22", optional = true } [features] default = ["llamacpp"] From 302d2badb5f1c7317cba38ed10ad864b0edc68fc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:04:54 +0000 Subject: [PATCH 16/30] chore(deps): bump futures from 0.3.32 to 0.3.34 Bumps [futures](https://github.com/rust-lang/futures-rs) from 0.3.32 to 0.3.34. - [Release notes](https://github.com/rust-lang/futures-rs/releases) - [Changelog](https://github.com/rust-lang/futures-rs/blob/main/CHANGELOG.md) - [Commits](https://github.com/rust-lang/futures-rs/compare/0.3.32...0.3.34) --- updated-dependencies: - dependency-name: futures dependency-version: 0.3.34 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Cargo.lock | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cd7d36e..f3dba4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2706,9 +2706,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -2721,9 +2721,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -2731,15 +2731,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -2748,38 +2748,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", From 20032445869d1b3f1c290fe2a49048ba70d46966 Mon Sep 17 00:00:00 2001 From: dynamder Date: Wed, 30 Sep 2026 22:44:18 +0800 Subject: [PATCH 17/30] =?UTF-8?q?feat(algo):=20=E5=8A=A8=E4=BD=9C=E6=A3=80?= =?UTF-8?q?=E7=B4=A2=E6=94=B9=E4=B8=BA=E6=8C=89=20ActionType=20=E6=AF=8F?= =?UTF-8?q?=E7=B1=BB=E8=B4=AA=E5=BF=83=E5=8F=96=E4=B8=80=E5=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RetrBayesAction 返回 ActionTypeSlots(speak/think/skill 各至多一个,Option 席位) - 删除 BayesActionConfig.top_k 与 AssociateWithActionConfig.action_top_k - AssociateWithActionResult.action 改名 slots,DefaultPipelineResult.action 保留字段名 - 贪心取组内最高分,同分按 MemoryId tiebreak;Skill 席位恒空(SkillRecord 无标识) - proc_none 照常竞争 Think 席,由消费侧解释为空 - playtest 不再反查节点类型,行为通道收敛为单席;benches 合成图动作类型轮换三型 BREAKING CHANGE: 动作检索不再返回 top-k 列表,改为按 ActionType 的 Option 席位结构 --- benches/src/bin/e2e_profile.rs | 12 +- .../src/algo/retrieve/bayes_action.rs | 417 +++++++++++++++--- .../retrieve/complex/assoc_with_action.rs | 198 +++++---- .../algo/retrieve/complex/default_pipeline.rs | 18 +- ...action__tests__multi_action_inference.snap | 8 - crates/soul-mem-algo/src/lib.rs | 6 + .../soul-mem-core/src/memory_note/proc_mem.rs | 14 +- .../soul-tune/src/engine/playtest/runner.rs | 87 ++-- crates/soul-tune/src/engine/playtest/trace.rs | 6 +- crates/soul-tune/src/engine/retrieve/suite.rs | 5 +- docs/architecture/orchestration.md | 4 +- ...50\350\277\271\346\212\245\345\221\212.md" | 67 ++- 12 files changed, 641 insertions(+), 201 deletions(-) delete mode 100644 crates/soul-mem-algo/src/algo/retrieve/snapshots/soul_mem_algo__algo__retrieve__bayes_action__tests__multi_action_inference.snap diff --git a/benches/src/bin/e2e_profile.rs b/benches/src/bin/e2e_profile.rs index c3b2eaf..2a359b0 100644 --- a/benches/src/bin/e2e_profile.rs +++ b/benches/src/bin/e2e_profile.rs @@ -26,7 +26,7 @@ use soul_mem_algo::algo::retrieve::similarity::SimilarityConfig; use soul_mem_core::memory_links::proc_mem::{ProcMemLink, TrigToAction}; use soul_mem_core::memory_links::sem_mem::SemMemLink; use soul_mem_core::memory_links::{MemoryLink, MemoryLinkType}; -use soul_mem_core::memory_note::proc_mem::{Action, ActionType, ProcMemory}; +use soul_mem_core::memory_note::proc_mem::{Action, ActionType, ProcMemory, SkillRecord}; use soul_mem_core::memory_note::sem_mem::{ConceptType, SemMemory}; use soul_mem_core::memory_note::{MemoryId, MemoryNoteBuilder, MemoryType}; use soul_mem_query::embedding::EmbeddingVec; @@ -221,8 +221,15 @@ fn build_working_memory(nodes: usize, topo: Topo) -> (Arc, usize) c.add_single_node(sem_note(ids[i], format!("记忆内容 {}", i), links, i)); if let Some(aid) = action_id { + // 动作类型轮换 Speak / Think / Skill:检索层按类型分席、每类只留一个, + // 单一类型会让合成图在动作腿上塌成 1 个节点,插桩数据失去意义。 + let action_type = match (i / 64) % 3 { + 0 => ActionType::new_speak(), + 1 => ActionType::new_think(), + _ => ActionType::new_skill(SkillRecord {}), + }; let action_note = MemoryNoteBuilder::new(MemoryType::Procedure(ProcMemory::new( - Action::new(format!("Action_{i}"), ActionType::new_speak()), + Action::new(format!("Action_{i}"), action_type), ))) .id(aid) .build() @@ -253,7 +260,6 @@ fn pipeline_config() -> DefaultPipelineConfig { }, assoc_with_action: AssociateWithActionConfig { association: Default::default(), - action_top_k: 3, ..Default::default() }, } diff --git a/crates/soul-mem-algo/src/algo/retrieve/bayes_action.rs b/crates/soul-mem-algo/src/algo/retrieve/bayes_action.rs index db4e32c..e2620ee 100644 --- a/crates/soul-mem-algo/src/algo/retrieve/bayes_action.rs +++ b/crates/soul-mem-algo/src/algo/retrieve/bayes_action.rs @@ -10,21 +10,111 @@ use soul_mem_core::memory_links::{ MemoryLinkType, proc_mem::{ProcMemLink, TrigToAction}, }; +use soul_mem_core::memory_note::proc_mem::ActionType; use soul_mem_core::memory_note::{MemoryId, MemoryType}; use soul_mem_runtime::cluster::memory_cluster::MemoryCluster; use soul_mem_runtime::working_memory::WorkingMemory; -#[derive(Debug, Clone, Deserialize)] -pub struct BayesActionConfig { - #[serde(default = "default_action_top_k")] - pub top_k: usize, +/// 动作席位:某个 `ActionType` 上被判出的**唯一**动作。 +/// +/// `score` 是贝叶斯推理的累积触发概率(`Σ prob × 源权重`),单位与 +/// `TrigToAction.prob` 一致,量纲为"权重归一化后的相对概率",非概率分布, +/// 上界不固定(源权重若经 softmax 归一化则 ≤ 各源 prob 的加权均值)。 +#[derive(Debug, Clone, PartialEq)] +pub struct ActionSeat { + pub id: MemoryId, + pub score: f64, } -fn default_action_top_k() -> usize { - 5 +/// 动作推理结果:按 [`ActionType`] 分成三个固定席位,每席**至多一个**动作。 +/// +/// 席位与类型一一对应,调用方无需再从记忆图反查节点类型;没有候选的类型 +/// 对应 `None`(席位可空),因此刻意不提供 `ActionTypeSlots::new()`。 +/// +/// 已知性质:席位内的胜出者由贪心选择决定——得分只由 +/// `TrigToAction.prob × 源权重` 决定,**与动作内容无关**;同类型内多个候选 +/// 若共享同一批源边,分数可以完全相同,此时仅由 `MemoryId` 的 tiebreak 决定。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ActionTypeSlots { + /// `ActionType::Speak`:语气 / 说话方式。 + pub speak: Option, + /// `ActionType::Think`:思维习惯。 + /// + /// `proc_none`(内容为"没有采取任何特定行动")在图里也标记为 `Think`: + /// 它在**语义上等同 null**,但类型上必须归入本席位,且照常参与贪心竞争—— + /// 它得分最高时本席位就返回 `proc_none` 本身,"是否当作空"由调用方解释。 + pub think: Option, + /// `ActionType::Skill`:技能动作。 + /// + /// `SkillRecord` 目前是空占位(无技能标识),组内没有可区分个体的信息, + /// 且当前检索**不产出技能动作**([`best_per_action_type`] 直接跳过该类型), + /// 因此本席位恒为 `None`。预留席位是为了让"每类型一席"的结构在类型上完整。 + pub skill: Option, } +impl ActionTypeSlots { + /// 三个席位是否全空。 + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// 已占用的席位数(0..=3)。 + pub fn len(&self) -> usize { + [ + self.speak.is_some(), + self.think.is_some(), + self.skill.is_some(), + ] + .into_iter() + .filter(|occupied| *occupied) + .count() + } + + /// 按类型取席位。 + pub fn get(&self, action_type: &ActionType) -> Option<&ActionSeat> { + match action_type { + ActionType::Speak => self.speak.as_ref(), + ActionType::Think => self.think.as_ref(), + ActionType::Skill(_) => self.skill.as_ref(), + } + } + + /// 按类型写入席位。 + pub fn set(&mut self, action_type: &ActionType, seat: Option) { + match action_type { + ActionType::Speak => self.speak = seat, + ActionType::Think => self.think = seat, + ActionType::Skill(_) => self.skill = seat, + } + } + + /// 已占用的席位,附带类型标注;空席位被丢弃,结果按分数降序 + /// (同分时按类型枚举顺序,保证输出确定)。 + pub fn seated(&self) -> Vec<(ActionType, ActionSeat)> { + let mut seats: Vec<(ActionType, ActionSeat)> = ActionType::all() + .into_iter() + .filter_map(|action_type| { + self.get(&action_type) + .cloned() + .map(|seat| (action_type, seat)) + }) + .collect(); + seats.sort_by(|a, b| b.1.score.total_cmp(&a.1.score)); + seats + } + + /// 三个席位本身(顺序:Speak / Think / Skill),无论是否为空。 + pub fn into_seats(self) -> [Option; 3] { + [self.speak, self.think, self.skill] + } +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct BayesActionConfig {} + impl BayesActionConfig { + /// 构造请求。每类型固定一席(贪心取最高分),无可配置项: + /// 席位数由 [`ActionTypeSlots`] 的类型结构决定,不再是可调 top-k。 pub fn into_request( self, working_mem: Arc, @@ -33,7 +123,6 @@ impl BayesActionConfig { BayesActionRequest { working_mem, source, - top_k: self.top_k, } } } @@ -41,7 +130,6 @@ impl BayesActionConfig { pub struct BayesActionRequest { pub working_mem: Arc, pub source: Vec<(MemoryId, f64)>, - pub top_k: usize, } impl BayesActionRequest { @@ -49,13 +137,8 @@ impl BayesActionRequest { Self { working_mem, source, - top_k: 5, } } - pub fn with_top_k(mut self, top_k: usize) -> Self { - self.top_k = top_k; - self - } } pub struct RetrBayesAction; @@ -64,7 +147,7 @@ impl RetrRequest for BayesActionRequest {} impl RetrStrategy for RetrBayesAction { type Request = BayesActionRequest; - type Return<'a> = Vec<(MemoryId, f64)>; + type Return<'a> = ActionTypeSlots; #[hotpath::measure] fn retrieve(&self, request: Self::Request) -> Self::Return<'_> { @@ -73,6 +156,8 @@ impl RetrStrategy for RetrBayesAction { cluster.read_or_compute(|mem_cluster| { let mut possible_actions = get_possible_actions(mem_cluster, &request.source); + // 概率累积与源顺序无关(+ 满足交换律),因此"贪心取每组最高分"的结果 + // 不受 `source` 排列影响;源内部则靠 `seated()` 的排序保证输出确定。 request.source.iter().for_each(|&(id, weight)| { let idx = mem_cluster.get_mem_index(id); @@ -102,13 +187,67 @@ impl RetrStrategy for RetrBayesAction { } }); - let mut actions_vec = possible_actions.into_iter().collect::>(); - actions_vec.sort_by(|x, y| y.1.partial_cmp(&x.1).unwrap_or(std::cmp::Ordering::Equal)); - - actions_vec.into_iter().take(request.top_k).collect() + best_per_action_type(mem_cluster, &possible_actions) }) } } + +/// 按 `ActionType` 分席的**贪心**选择:每个类型只取分数最高的那一个候选。 +/// +/// - 分组键是节点的 `ActionType`(`Speak` / `Think` / `Skill`),结果至多 3 条; +/// - 空组产生空席位(`None`),不补齐、不填充; +/// - 同分时取 `MemoryId` 较小者(`MemoryId` 派生 `Ord`),保证同一张图上 +/// 结果逐位可复现——`HashMap` 的迭代顺序不参与决策; +/// - `ActionType::Skill` 当前直接跳过:`SkillRecord` 是空占位,无法在组内 +/// 区分个体,取谁都只是 tiebreak 的产物,因此不产出技能动作。 +#[hotpath::measure] +fn best_per_action_type( + cluster: &MemoryCluster, + scores: &HashMap, +) -> ActionTypeSlots { + let mut best: HashMap = HashMap::new(); + + for (id, score) in scores { + let Some(idx) = cluster.get_mem_index(*id) else { + continue; + }; + let Some(note) = cluster.graph().node_weight(idx) else { + continue; + }; + let MemoryType::Procedure(proc_mem) = note.note().mem_type() else { + continue; + }; + + let action_type = proc_mem.get_action().get_action_type(); + // 技能动作无个体标识,胜出者没有判别依据 → 本次不产出 + if matches!(action_type, ActionType::Skill(_)) { + continue; + } + + let better = match best.get(action_type) { + None => true, + Some(current) => { + *score > current.score || (*score == current.score && *id < current.id) + } + }; + if better { + best.insert( + action_type.clone(), + ActionSeat { + id: *id, + score: *score, + }, + ); + } + } + + let mut slots = ActionTypeSlots::default(); + for (action_type, seat) in best { + slots.set(&action_type, Some(seat)); + } + slots +} + #[hotpath::measure] fn get_possible_actions( cluster: &MemoryCluster, @@ -120,7 +259,7 @@ fn get_possible_actions( let idx = cluster.get_mem_index(id)?; //同时检查邻居节点类型(Procedure)与链接类型(Proc), //只有经Proc链接可达的动作节点才会指导行为,避免Sem/Situation链接 - //可达的Procedure节点以0.0分挤占top_k + //可达的Procedure节点以0.0分挤占席位 let action_neighbors = cluster .graph() @@ -147,7 +286,7 @@ mod tests { use super::*; use soul_mem_core::memory_links::MemoryLink; use soul_mem_core::memory_links::proc_mem::{ProcMemLink, TrigToAction}; - use soul_mem_core::memory_note::proc_mem::{Action, ActionType, ProcMemory}; + use soul_mem_core::memory_note::proc_mem::{Action, ActionType, ProcMemory, SkillRecord}; use soul_mem_core::memory_note::{ MemoryNoteBuilder, MemoryType, sem_mem::{ConceptType, SemMemory}, @@ -158,17 +297,9 @@ mod tests { }; use soul_mem_query::embedding::sem::SemanticEmbedding; - #[test] - fn test_default_action_top_k() { - assert_eq!(default_action_top_k(), 5); - } - #[test] fn test_bayes_action_config_defaults() { - let config = BayesActionConfig { - top_k: default_action_top_k(), - }; - assert_eq!(config.top_k, 5); + let _config = BayesActionConfig::default(); } fn create_mock_working_memory_with_actions() -> (WorkingMemory, MemoryId, MemoryId) { @@ -232,10 +363,12 @@ mod tests { let request = BayesActionRequest::new(Arc::new(wm), vec![(source_id, 0.8)]); let result = RetrBayesAction {}.retrieve(request); - assert!(!result.is_empty()); - let (id, score) = &result[0]; - assert_eq!(id, &action_id); - assert_eq!(*score, 0.4); + assert_eq!(result.len(), 1); + let seat = result.speak.expect("Speak 席位应被占据"); + assert_eq!(seat.id, action_id); + assert_eq!(seat.score, 0.4); + assert_eq!(result.think, None); + assert_eq!(result.skill, None); } #[test] @@ -245,15 +378,7 @@ mod tests { let result = RetrBayesAction {}.retrieve(request); assert!(result.is_empty()); - } - - #[test] - fn test_retr_bayes_action_top_k() { - let (wm, source_id, _) = create_mock_working_memory_with_actions(); - let request = BayesActionRequest::new(Arc::new(wm), vec![(source_id, 1.0)]).with_top_k(1); - let result = RetrBayesAction {}.retrieve(request); - - assert!(result.len() <= 1); + assert_eq!(result.len(), 0); } #[test] @@ -265,8 +390,112 @@ mod tests { assert!(result.contains_key(&action_id)); } + /// 同一情境源 → 三个不同类型 `proc` 边,用于验证"每类型一席、互不挤占"。 + fn typed_source_wm() -> (WorkingMemory, MemoryId, Vec<(MemoryId, MemoryType)>) { + let wm = WorkingMemory::new(10); + let cluster = wm.memory_cluster(); + let source_id = MemoryId::new(); + let types = vec![ + ( + MemoryId::new(), + MemoryType::Procedure(ProcMemory::new(Action::new( + "Speak".into(), + ActionType::new_speak(), + ))), + ), + ( + MemoryId::new(), + MemoryType::Procedure(ProcMemory::new(Action::new( + "Think".into(), + ActionType::new_think(), + ))), + ), + ( + MemoryId::new(), + MemoryType::Procedure(ProcMemory::new(Action::new( + "Skill".into(), + ActionType::new_skill(SkillRecord {}), + ))), + ), + ]; + + cluster.write(|c| { + let mut links = Vec::new(); + for (aid, _) in &types { + links.push(MemoryLink::new( + source_id, + *aid, + MemoryLinkType::Proc(ProcMemLink::TrigToAction(TrigToAction::new(1.0))), + )); + } + let source_note = MemoryNoteBuilder::new(MemoryType::Semantic(SemMemory { + content: "source".into(), + aliases: vec![], + concept_type: ConceptType::Entity, + description: String::new(), + })) + .id(source_id) + .mem_links(links) + .build() + .unwrap(); + let source_emb = MemoryEmbedding::new( + EmbeddingVec::zero(128), + MemoryEmbeddingVariant::Semantic(SemanticEmbedding::new( + EmbeddingVec::zero(128), + EmbeddingVec::zero(128), + EmbeddingVec::zero(128), + )), + ); + c.add_single_node(EmbeddedMemoryNote { + note: source_note, + embedding: source_emb, + }); + + for (aid, mem_type) in &types { + let note = MemoryNoteBuilder::new(mem_type.clone()) + .id(*aid) + .build() + .unwrap(); + let emb = MemoryEmbedding::new( + EmbeddingVec::zero(128), + MemoryEmbeddingVariant::Procedure(), + ); + c.add_single_node(EmbeddedMemoryNote { + note, + embedding: emb, + }); + } + }); + + (wm, source_id, types) + } + + #[test] + fn test_slots_are_one_per_action_type() { + // 三个类型各一个候选 → 三席齐满,Speak/Think 各有;Skill 恒空 + let (wm, source_id, types) = typed_source_wm(); + let result = RetrBayesAction {}.retrieve(BayesActionRequest::new( + Arc::new(wm), + vec![(source_id, 1.0)], + )); + + assert_eq!(result.len(), 2, "Speak 与 Think 各占一席"); + assert_eq!(result.speak.as_ref().map(|s| s.id), Some(types[0].0)); + assert_eq!(result.think.as_ref().map(|s| s.id), Some(types[1].0)); + assert!( + result.skill.is_none(), + "Skill 动作不产出(SkillRecord 无标识)" + ); + // 席位带类型标注:seated() 可反查类型 + let seated = result.seated(); + assert_eq!(seated.len(), 2); + assert!(seated.iter().any(|(t, _)| matches!(t, ActionType::Speak))); + assert!(seated.iter().any(|(t, _)| matches!(t, ActionType::Think))); + } + #[test] - fn test_multi_action_inference() { + fn test_greedy_keeps_highest_score_per_type() { + // 同类型(Speak)两个候选:0.7 与 0.3 → 只留 0.7 let wm = WorkingMemory::new(10); let cluster = wm.memory_cluster(); let source_id = MemoryId::new(); @@ -326,12 +555,86 @@ mod tests { } }); - let request = BayesActionRequest::new(Arc::new(wm), vec![(source_id, 1.0)]); - let result = RetrBayesAction {}.retrieve(request); + let result = RetrBayesAction {}.retrieve(BayesActionRequest::new( + Arc::new(wm), + vec![(source_id, 1.0)], + )); + + assert_eq!(result.len(), 1); + let seat = result.speak.expect("Speak 席位应被占据"); + assert_eq!(seat.id, action_a, "应保留分数更高的动作"); + assert!((seat.score - 0.7).abs() < 1e-9); + } + + #[test] + fn test_tie_break_is_deterministic() { + // 同类型同分(0.5)两个候选 → tiebreak 取 MemoryId 较小者 + let wm = WorkingMemory::new(10); + let cluster = wm.memory_cluster(); + let source_id = MemoryId::new(); + let mut candidates = vec![MemoryId::new(), MemoryId::new()]; + candidates.sort(); + + cluster.write(|c| { + let links: Vec = candidates + .iter() + .map(|aid| { + MemoryLink::new( + source_id, + *aid, + MemoryLinkType::Proc(ProcMemLink::TrigToAction(TrigToAction::new(0.5))), + ) + }) + .collect(); + let source_note = MemoryNoteBuilder::new(MemoryType::Semantic(SemMemory { + content: "source".into(), + aliases: vec![], + concept_type: ConceptType::Entity, + description: String::new(), + })) + .id(source_id) + .mem_links(links) + .build() + .unwrap(); + let source_emb = MemoryEmbedding::new( + EmbeddingVec::zero(128), + MemoryEmbeddingVariant::Semantic(SemanticEmbedding::new( + EmbeddingVec::zero(128), + EmbeddingVec::zero(128), + EmbeddingVec::zero(128), + )), + ); + c.add_single_node(EmbeddedMemoryNote { + note: source_note, + embedding: source_emb, + }); - assert_eq!(result.len(), 2); - let scores: Vec = result.iter().map(|(_, s)| *s).collect(); - insta::assert_debug_snapshot!(scores); + for aid in &candidates { + let note = MemoryNoteBuilder::new(MemoryType::Procedure(ProcMemory::new( + Action::new("Tied".into(), ActionType::new_think()), + ))) + .id(*aid) + .build() + .unwrap(); + let emb = MemoryEmbedding::new( + EmbeddingVec::zero(128), + MemoryEmbeddingVariant::Procedure(), + ); + c.add_single_node(EmbeddedMemoryNote { + note, + embedding: emb, + }); + } + }); + + let result = RetrBayesAction {}.retrieve(BayesActionRequest::new( + Arc::new(wm), + vec![(source_id, 1.0)], + )); + + assert_eq!(result.len(), 1); + let seat = result.think.expect("Think 席位应被占据"); + assert_eq!(seat.id, candidates[0], "同分时应取 MemoryId 较小者"); } #[test] @@ -387,13 +690,13 @@ mod tests { }); let sources = vec![(src_a, 0.8), (src_b, 0.5)]; - let request = BayesActionRequest::new(Arc::new(wm), sources); - let result = RetrBayesAction {}.retrieve(request); + let result = RetrBayesAction {}.retrieve(BayesActionRequest::new(Arc::new(wm), sources)); assert_eq!(result.len(), 1); - let (id, score) = result[0]; - assert_eq!(id, action_id); - insta::assert_debug_snapshot!(score); + let seat = result.speak.expect("Speak 席位应被占据"); + assert_eq!(seat.id, action_id); + // 多源聚合:0.6×0.8 + 0.4×0.5 = 0.68 + assert!((seat.score - 0.68).abs() < 1e-9, "实际 {}", seat.score); } #[test] @@ -448,12 +751,12 @@ mod tests { let request = BayesActionRequest::new(Arc::new(wm), vec![(source_id, 1.0)]); let result = RetrBayesAction {}.retrieve(request); - assert!(!result.is_empty()); - let score = result[0].1; + let seat = result.speak.expect("Speak 席位应被占据"); let expected = 0.5 * 1.0; assert!( - (score - expected).abs() < 1e-6, - "Expected {expected}, got {score}" + (seat.score - expected).abs() < 1e-6, + "Expected {expected}, got {}", + seat.score ); } } diff --git a/crates/soul-mem-algo/src/algo/retrieve/complex/assoc_with_action.rs b/crates/soul-mem-algo/src/algo/retrieve/complex/assoc_with_action.rs index fac82c0..9e0695c 100644 --- a/crates/soul-mem-algo/src/algo/retrieve/complex/assoc_with_action.rs +++ b/crates/soul-mem-algo/src/algo/retrieve/complex/assoc_with_action.rs @@ -6,7 +6,7 @@ use serde::Deserialize; use crate::algo::retrieve::{ RetrRequest, RetrStrategy, association::{AssociationConfig, AssociationRequest, RetrAssociation}, - bayes_action::{BayesActionRequest, RetrBayesAction}, + bayes_action::{ActionTypeSlots, BayesActionRequest, RetrBayesAction}, }; use soul_mem_core::memory_note::situation_mem::SituationType; use soul_mem_core::memory_note::{MemoryId, MemoryType}; @@ -16,17 +16,11 @@ use soul_mem_runtime::working_memory::WorkingMemory; pub struct AssociateWithActionConfig { #[serde(default)] pub association: AssociationConfig, - #[serde(default = "default_action_top_k")] - pub action_top_k: usize, /// 抽象情境源在 Bayes 动作提取中的权重倍率(抽象优先;具体源权重为 1.0)。 #[serde(default = "default_abstract_source_priority")] pub abstract_source_priority: f64, } -fn default_action_top_k() -> usize { - 3 -} - fn default_abstract_source_priority() -> f64 { 2.0 } @@ -35,7 +29,6 @@ impl Default for AssociateWithActionConfig { fn default() -> Self { Self { association: AssociationConfig::default(), - action_top_k: default_action_top_k(), abstract_source_priority: default_abstract_source_priority(), } } @@ -51,7 +44,6 @@ impl AssociateWithActionConfig { association: self .association .into_request(Arc::clone(&working_mem), source), - action_top_k: self.action_top_k, abstract_source_priority: self.abstract_source_priority, } } @@ -61,7 +53,6 @@ pub struct RetrAssociateWithAction; pub struct AssociateWithActionRequest { association: AssociationRequest, - action_top_k: usize, abstract_source_priority: f64, } @@ -69,14 +60,9 @@ impl AssociateWithActionRequest { pub fn new(association: AssociationRequest) -> Self { Self { association, - action_top_k: 3, abstract_source_priority: default_abstract_source_priority(), } } - pub fn with_action_top_k(mut self, action_top_k: usize) -> Self { - self.action_top_k = action_top_k; - self - } pub fn with_abstract_source_priority(mut self, priority: f64) -> Self { self.abstract_source_priority = priority; self @@ -87,7 +73,8 @@ impl RetrRequest for AssociateWithActionRequest {} pub struct AssociateWithActionResult { pub memory: Vec<(MemoryId, f64)>, - pub action: Vec<(MemoryId, f64)>, + /// 动作推理结果:按 `ActionType` 每类至多一个(见 [`ActionTypeSlots`])。 + pub slots: ActionTypeSlots, } impl RetrStrategy for RetrAssociateWithAction { @@ -104,7 +91,7 @@ impl RetrStrategy for RetrAssociateWithAction { if association_res.is_empty() { return AssociateWithActionResult { memory: Vec::new(), - action: Vec::new(), + slots: ActionTypeSlots::default(), }; } @@ -125,20 +112,18 @@ impl RetrStrategy for RetrAssociateWithAction { if bayes_sources.is_empty() { return AssociateWithActionResult { memory: association_res, - action: Vec::new(), + slots: ActionTypeSlots::default(), }; } let normalized_bayes_sources = softmax(&bayes_sources); - let action_request = BayesActionRequest::new(working_mem, normalized_bayes_sources) - .with_top_k(request.action_top_k); - - let action_res = RetrBayesAction {}.retrieve(action_request); + let action_request = BayesActionRequest::new(working_mem, normalized_bayes_sources); + let slots = RetrBayesAction {}.retrieve(action_request); AssociateWithActionResult { memory: association_res, - action: action_res, + slots, } } } @@ -224,19 +209,18 @@ mod tests { use soul_mem_query::embedding::note::MemoryEmbedding; use soul_mem_query::embedding::note::MemoryEmbeddingVariant; - #[test] - fn test_default_action_top_k() { - assert_eq!(default_action_top_k(), 3); + /// 从席位结果里按 `MemoryId` 反查得分(跨三个席位,与类型无关)。 + fn seat_score(slots: &ActionTypeSlots, id: MemoryId) -> Option { + slots + .seated() + .into_iter() + .find(|(_, seat)| seat.id == id) + .map(|(_, seat)| seat.score) } #[test] fn test_associate_with_action_config_defaults() { - let config = AssociateWithActionConfig { - association: AssociationConfig::default(), - action_top_k: default_action_top_k(), - ..Default::default() - }; - assert_eq!(config.action_top_k, 3); + let config = AssociateWithActionConfig::default(); assert_eq!( config.abstract_source_priority, default_abstract_source_priority() @@ -330,44 +314,114 @@ mod tests { #[test] fn test_retr_associate_with_action_basic() { let (wm, source_id, _, _) = create_mock_working_memory_with_assoc_and_action(); - let config = AssociateWithActionConfig { - association: AssociationConfig::default(), - action_top_k: 3, - ..Default::default() - }; + let config = AssociateWithActionConfig::default(); let request = config.into_request(Arc::new(wm), vec![(source_id, 1.0)]); let result = RetrAssociateWithAction {}.retrieve(request); - assert!(!result.memory.is_empty() || !result.action.is_empty()); + assert!(!result.memory.is_empty() || !result.slots.is_empty()); } #[test] fn test_retr_associate_with_action_empty_source() { let (wm, _, _, _) = create_mock_working_memory_with_assoc_and_action(); - let config = AssociateWithActionConfig { - association: AssociationConfig::default(), - action_top_k: 3, - ..Default::default() - }; + let config = AssociateWithActionConfig::default(); let request = config.into_request(Arc::new(wm), vec![]); let result = RetrAssociateWithAction {}.retrieve(request); assert!(result.memory.is_empty()); - assert!(result.action.is_empty()); + assert!(result.slots.is_empty()); } #[test] - fn test_retr_associate_with_action_action_top_k() { - let (wm, source_id, _, _) = create_mock_working_memory_with_assoc_and_action(); - let config = AssociateWithActionConfig { - association: AssociationConfig::default(), - action_top_k: 1, - ..Default::default() - }; + fn test_retr_associate_with_action_slots_are_one_per_type() { + // 一个具体情境源挂两条不同 ActionType 的 Proc 边: + // 每个类型各占一席(Speak + Think),Skill 不产出,总席位 2。 + use chrono::{TimeZone, Utc}; + use soul_mem_core::memory_note::situation_mem::{Context, Environment, SpecificSituation}; + use soul_mem_query::embedding::Embeddable; + use soul_mem_query::embedding::embedding_model::bge::BgeSmallZh; + + let model = BgeSmallZh::default_cpu().unwrap(); + let wm = WorkingMemory::new(10); + let cluster = wm.memory_cluster(); + let source_id = MemoryId::new(); + let speak_id = MemoryId::new(); + let think_id = MemoryId::new(); + + let spec_mem = SpecificSituation::new( + "和对方在深夜聊了一整晚,分享了很多心里话".to_string(), + Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(), + Context::new( + None, + vec![], + vec![], + vec![], + Environment { + atmosphere: "安静".to_string(), + tone: "温暖".to_string(), + }, + vec![], + ), + ); + let source_note = MemoryNoteBuilder::new(MemoryType::Situation( + SituationType::SpecificSituation(spec_mem), + )) + .id(source_id) + .mem_links(vec![ + MemoryLink::new( + source_id, + speak_id, + MemoryLinkType::Proc(ProcMemLink::TrigToAction(TrigToAction::new(0.9))), + ), + MemoryLink::new( + source_id, + think_id, + MemoryLinkType::Proc(ProcMemLink::TrigToAction(TrigToAction::new(0.9))), + ), + ]) + .build() + .unwrap(); + let source_emb = source_note.embed(&model).unwrap(); + + cluster.write(|c| { + c.add_single_node(EmbeddedMemoryNote { + note: source_note, + embedding: source_emb, + }); + + for (aid, action_type, name) in [ + (speak_id, ActionType::new_speak(), "SpeakProc"), + (think_id, ActionType::new_think(), "ThinkProc"), + ] { + let note = MemoryNoteBuilder::new(MemoryType::Procedure(ProcMemory::new( + Action::new(name.into(), action_type), + ))) + .id(aid) + .build() + .unwrap(); + let emb = MemoryEmbedding::new( + EmbeddingVec::zero(128), + MemoryEmbeddingVariant::Procedure(), + ); + c.add_single_node(EmbeddedMemoryNote { + note, + embedding: emb, + }); + } + }); + + let config = AssociateWithActionConfig::default(); let request = config.into_request(Arc::new(wm), vec![(source_id, 1.0)]); let result = RetrAssociateWithAction {}.retrieve(request); - assert!(result.action.len() <= 1); + assert_eq!( + result.slots.len(), + 2, + "Speak 与 Think 各占一席,Skill 不产出" + ); + assert_eq!(result.slots.speak.as_ref().map(|s| s.id), Some(speak_id)); + assert_eq!(result.slots.think.as_ref().map(|s| s.id), Some(think_id)); + assert!(result.slots.skill.is_none()); } /// 构造含"具体情境→proc_specific"与"抽象情境→proc_abstract"两条 Proc 边的图。 @@ -444,9 +498,14 @@ mod tests { note: abs_note, embedding: abs_emb, }); - for (pid, name) in [(proc_spec, "proc_spec"), (proc_abs, "proc_abs")] { + // 两个 proc 刻意取不同类型(具体→Speak,抽象→Think): + // 每类型一席的贪心截断下,两者各自占席,分数才可直接比较。 + for (pid, name, action_type) in [ + (proc_spec, "proc_spec", ActionType::new_speak()), + (proc_abs, "proc_abs", ActionType::new_think()), + ] { let pnote = MemoryNoteBuilder::new(MemoryType::Procedure(ProcMemory::new( - Action::new(name.to_string(), ActionType::new_think()), + Action::new(name.to_string(), action_type), ))) .id(pid) .build() @@ -473,15 +532,9 @@ mod tests { let request = config.into_request(Arc::new(wm), vec![(abs_id, 1.0), (spec_id, 1.0)]); let result = RetrAssociateWithAction {}.retrieve(request); - let get = |id: MemoryId| { - result - .action - .iter() - .find(|(i, _)| *i == id) - .map(|(_, s)| *s) - }; - let abs_score = get(proc_abs).expect("抽象情境触发的动作应被检出"); - let spec_score = get(proc_spec).expect("具体情境触发的动作应参与(兜底源)"); + let abs_score = seat_score(&result.slots, proc_abs).expect("抽象情境触发的动作应被检出"); + let spec_score = + seat_score(&result.slots, proc_spec).expect("具体情境触发的动作应参与(兜底源)"); assert!( abs_score > spec_score, "抽象优先:抽象源加权后动作分应更高, abs={abs_score} spec={spec_score}" @@ -497,13 +550,13 @@ mod tests { let result = RetrAssociateWithAction {}.retrieve(request); assert!( - result.action.iter().any(|(id, _)| *id == proc_spec), + result.slots.seated().iter().any(|(_, s)| s.id == proc_spec), "具体兜底应检出具体情境触发的动作" ); assert!( - !result.action.iter().any(|(id, _)| *id == proc_abs), - "无抽象源时不应检出抽象触发的动作, action={:?}", - result.action + !result.slots.seated().iter().any(|(_, s)| s.id == proc_abs), + "无抽象源时不应检出抽象触发的动作, slots={:?}", + result.slots ); } @@ -517,15 +570,8 @@ mod tests { .with_abstract_source_priority(1.0); let result = RetrAssociateWithAction {}.retrieve(request); - let get = |id: MemoryId| { - result - .action - .iter() - .find(|(i, _)| *i == id) - .map(|(_, s)| *s) - }; - let abs_score = get(proc_abs).unwrap_or(0.0); - let spec_score = get(proc_spec).unwrap_or(0.0); + let abs_score = seat_score(&result.slots, proc_abs).unwrap_or(0.0); + let spec_score = seat_score(&result.slots, proc_spec).unwrap_or(0.0); assert!( (abs_score - spec_score).abs() < 1e-6, "同权时抽象与具体动作分应相等, abs={abs_score} spec={spec_score}" diff --git a/crates/soul-mem-algo/src/algo/retrieve/complex/default_pipeline.rs b/crates/soul-mem-algo/src/algo/retrieve/complex/default_pipeline.rs index 0c3c51e..9166fa4 100644 --- a/crates/soul-mem-algo/src/algo/retrieve/complex/default_pipeline.rs +++ b/crates/soul-mem-algo/src/algo/retrieve/complex/default_pipeline.rs @@ -5,6 +5,7 @@ use serde::Deserialize; use crate::algo::retrieve::{ RetrRequest, RetrStrategy, + bayes_action::ActionTypeSlots, complex::{AssociateWithActionConfig, RetrAssociateWithAction}, short_only::{RetrShortOnly, ShortOnlyConfig}, similarity::{RetrSimilarity, SimilarityConfig}, @@ -78,7 +79,9 @@ pub struct DefaultPipelineRequest { pub struct DefaultPipelineResult { pub association: Vec<(MemoryId, f64)>, - pub action: Vec<(MemoryId, f64)>, + /// 动作推理结果:按 `ActionType` 每类至多一个(Speak / Think 席位; + /// Skill 为占位席位,当前不产出)。 + pub action: ActionTypeSlots, pub short_history: Arc<[Information]>, pub short_mem: Arc, pub priority: u32, @@ -118,7 +121,7 @@ impl RetrStrategy for RetrDefaultPipeline { DefaultPipelineResult { association: merged_memory, - action: association_with_action_res.action, + action: association_with_action_res.slots, short_history: short_mem_res.0, short_mem: short_mem_res.1, priority: request.priority, @@ -254,7 +257,6 @@ mod tests { }, assoc_with_action: AssociateWithActionConfig { association: Default::default(), - action_top_k: 3, ..Default::default() }, } @@ -336,6 +338,15 @@ mod tests { assert_eq!(result.priority, 1); assert!(!result.short_history.is_empty()); assert!(!result.association.is_empty()); + // 动作按类型分席:每类至多一席,且 Skill 为占位席位(当前不产出) + assert!(result.action.len() <= 3); + assert!( + result + .action + .seated() + .iter() + .all(|(action_type, _)| !matches!(action_type, ActionType::Skill(_))) + ); let assoc_scores: Vec = result.association.iter().map(|(_, s)| *s).collect(); insta::assert_debug_snapshot!("full_pipeline_association_scores", assoc_scores); @@ -473,7 +484,6 @@ mod tests { }, assoc_with_action: AssociateWithActionConfig { association: Default::default(), - action_top_k: 3, ..Default::default() }, }; diff --git a/crates/soul-mem-algo/src/algo/retrieve/snapshots/soul_mem_algo__algo__retrieve__bayes_action__tests__multi_action_inference.snap b/crates/soul-mem-algo/src/algo/retrieve/snapshots/soul_mem_algo__algo__retrieve__bayes_action__tests__multi_action_inference.snap deleted file mode 100644 index 8120e92..0000000 --- a/crates/soul-mem-algo/src/algo/retrieve/snapshots/soul_mem_algo__algo__retrieve__bayes_action__tests__multi_action_inference.snap +++ /dev/null @@ -1,8 +0,0 @@ ---- -source: crates/soul-mem-algo/src/algo/retrieve/bayes_action.rs -expression: scores ---- -[ - 0.7, - 0.3, -] diff --git a/crates/soul-mem-algo/src/lib.rs b/crates/soul-mem-algo/src/lib.rs index 1a57407..c60c620 100644 --- a/crates/soul-mem-algo/src/lib.rs +++ b/crates/soul-mem-algo/src/lib.rs @@ -27,6 +27,12 @@ //! `assoc_with_action::merge_situation_sources`、`association` 里的排序截断), //! 且 NaN 策略不同(`total_cmp` vs `partial_cmp().unwrap_or(Equal)`)。 //! 改"分数怎么合并"必须同时看这三处。 +//! - **动作结果不是 top-k 列表而是按类型分席**:`bayes_action::RetrBayesAction` 返回 +//! [`algo::retrieve::bayes_action::ActionTypeSlots`](Speak / Think / Skill 各至多一个, +//! 贪心取组内最高分,同分按 `MemoryId` tiebreak)。动作得分只由 +//! `TrigToAction.prob × 源权重` 决定、**与动作内容无关**,所以同类型内多个候选共享 +//! 同一批源边时分数可能完全相同,此时胜出者仅是 tiebreak 的产物。Skill 席位当前 +//! 恒空(`SkillRecord` 是空占位,组内无判别依据)。 //! - `AssociationConfig` 的调参常数目前**只有值没有文档**(单位/含义/取值范围), //! 而本层质量几乎完全由这些数决定。新增或调整请照 //! [`algo::forget::decay_calculator`] 的风格补上说明。 diff --git a/crates/soul-mem-core/src/memory_note/proc_mem.rs b/crates/soul-mem-core/src/memory_note/proc_mem.rs index b51fb0f..d02c23c 100644 --- a/crates/soul-mem-core/src/memory_note/proc_mem.rs +++ b/crates/soul-mem-core/src/memory_note/proc_mem.rs @@ -1,7 +1,9 @@ use serde::{Deserialize, Serialize}; //动作类型 -#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Clone, Serialize, Deserialize)] +// `Hash` 供检索层把动作按类型分席(`HashMap` 分组键)使用, +// 不改变既有 wire 表示。 +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Clone, Serialize, Deserialize)] pub enum ActionType { Speak, //语气类,说话方式 Skill(SkillRecord), //技能类,例如使用外部工具 @@ -17,8 +19,16 @@ impl ActionType { pub fn new_think() -> Self { Self::Think } + /// 全部动作类型(枚举顺序:Speak → Think → Skill),供按类型遍历席位的调用方使用。 + pub fn all() -> [Self; 3] { + [ + Self::Speak, + Self::Think, + Self::Skill(SkillRecord::default()), + ] + } } -#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Clone, Serialize, Deserialize)] +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Default, Clone, Serialize, Deserialize)] pub struct SkillRecord { //TODO: 后续版本功能,仅做PlaceHolder } diff --git a/crates/soul-tune/src/engine/playtest/runner.rs b/crates/soul-tune/src/engine/playtest/runner.rs index acc2002..b6198b6 100644 --- a/crates/soul-tune/src/engine/playtest/runner.rs +++ b/crates/soul-tune/src/engine/playtest/runner.rs @@ -41,7 +41,6 @@ use soul_mem_llm::json::{extract_balanced_array, extract_balanced_object, strip_ pub struct PlayConfig { pub similarity_threshold: f32, pub max_results: usize, - pub action_top_k: usize, pub ppr_top_k: usize, pub damping_factor: f64, pub residue_threshold: f64, @@ -312,7 +311,6 @@ impl Default for PlayConfig { Self { similarity_threshold: QUERY_VALIDATION_FLOOR, max_results: 4, - action_top_k: 3, ppr_top_k: 8, damping_factor: 0.65, residue_threshold: 1e-5, @@ -962,22 +960,11 @@ impl PlayTestRunner { let mut per_query = Vec::new(); let mut merged_map: HashMap = HashMap::new(); // 动作节点按 ActionType 拆成三路独立通道(不参与记忆分数合并): - // Speak 语气 / Think 思维习惯各至多 1 条,其余行为类按 action_top_k + // Speak 语气 / Think 思维习惯各至多 1 条,其余行为类(含 proc_none)单独成路。 + // 每类型一席由算法层保证(`ActionTypeSlots`),这里不再需要反查节点类型。 let mut merged_speech: HashMap = HashMap::new(); let mut merged_think: HashMap = HashMap::new(); let mut merged_behavior: HashMap = HashMap::new(); - let action_type_map: HashMap = - self.wm.memory_cluster().read_or_compute(|c| { - c.graph() - .node_weights() - .filter_map(|n| match n.note().mem_type() { - MemoryType::Procedure(p) => { - Some((n.note().id(), p.get_action().get_action_type().clone())) - } - _ => None, - }) - .collect() - }); // priority 只作小偏移:分数接近时保护重要查询,分数差距大时由分数主导 let p_max = prepared @@ -1047,7 +1034,6 @@ impl PlayTestRunner { top_k: self.config.ppr_top_k, ..Default::default() }, - action_top_k: self.config.action_top_k, ..Default::default() }; let aa_req = aa_config.into_request( @@ -1087,29 +1073,40 @@ impl PlayTestRunner { .unwrap_or(std::cmp::Ordering::Equal) }); - let mut action_nodes: Vec = aa_result - .action + // 每个已占用席位:类型由算法层标注,无需再反查图 + let seated_actions: Vec<(ActionType, TracedNode)> = aa_result + .slots + .seated() .into_iter() - .map(|(id, score)| { + .map(|(action_type, seat)| { + let id = seat.id; let name = self.graph_names.get(&id).cloned().unwrap_or_default(); let stage = if sim_set.contains(&id) { HitStage::Both } else { HitStage::Action }; - TracedNode { - id, - name, - content: self - .id_names - .get(&id) - .map(|s| s.primary.clone()) - .unwrap_or_default(), - score, - stage, - } + ( + action_type, + TracedNode { + id, + name, + content: self + .id_names + .get(&id) + .map(|s| s.primary.clone()) + .unwrap_or_default(), + score: seat.score, + stage, + }, + ) }) .collect(); + + let mut action_nodes: Vec = seated_actions + .iter() + .map(|(_, node)| node.clone()) + .collect(); action_nodes.sort_by(|a, b| { b.score .partial_cmp(&a.score) @@ -1129,19 +1126,22 @@ impl PlayTestRunner { }; per_query.push(qt); - // 按 ActionType 分流:Speak→语气、Think(非 proc_none)→思维习惯、其余→行为 + // 按 ActionType 分流:Speak→语气、Think(非 proc_none)→思维习惯、 + // 其余(含 proc_none)→行为通道 let mut speech = Vec::new(); let mut think = Vec::new(); let mut behavior = Vec::new(); - for n in action_nodes { - match action_type_map.get(&n.id) { - Some(ActionType::Speak) => speech.push(n), - Some(ActionType::Think) - if self.graph_names.get(&n.id).map(|s| s.as_str()) != Some("proc_none") => + for (action_type, node) in seated_actions { + match action_type { + ActionType::Speak => speech.push(node), + ActionType::Think + if self.graph_names.get(&node.id).map(|s| s.as_str()) + != Some("proc_none") => { - think.push(n) + think.push(node) } - _ => behavior.push(n), + // proc_none(Think 类型但是"无动作"占位)与 Skill 都归行为通道 + _ => behavior.push(node), } } @@ -1172,9 +1172,9 @@ impl PlayTestRunner { let mut all_nodes = finish_merged(merged_map); all_nodes.truncate(self.config.merged_top_k); - // 行为类动作独立 top-k:即使分数低于记忆节点也保留进最终结果 + // 行为类动作独立单席:算法层已按类型截断,这里再兜一层防止多 Query 折叠后重复 let mut action_nodes = finish_merged(merged_behavior); - action_nodes.truncate(self.config.action_top_k); + action_nodes.truncate(1); // 语气 / 思维习惯单席:只取分数最高 1 条,避免多个 Speak 内容互相矛盾 let mut speech_nodes = finish_merged(merged_speech); speech_nodes.truncate(1); @@ -1531,10 +1531,11 @@ mod tests { // 常驻兜底:格蕾修图存在 Speak 与 Think proc,即使 Bayes 未命中也应各有 1 条 assert_eq!(trace.speech_nodes.len(), 1, "Speak 常驻兜底应生效"); assert_eq!(trace.think_nodes.len(), 1, "Think 常驻兜底应生效"); - // 行为类通道受 action_top_k 限制 + // 行为类通道单席(每类型一席由算法层保证,行为通道折叠后亦至多 1 条) assert!( - trace.action_nodes.len() <= runner.config.action_top_k, - "行为类动作数量应受 action_top_k 限制" + trace.action_nodes.len() <= 1, + "行为类动作至多 1 条,实际 {}", + trace.action_nodes.len() ); // 至少一路检出动作(Think 通道应包含学习/睡觉习惯) assert!( diff --git a/crates/soul-tune/src/engine/playtest/trace.rs b/crates/soul-tune/src/engine/playtest/trace.rs index f16b541..526f005 100644 --- a/crates/soul-tune/src/engine/playtest/trace.rs +++ b/crates/soul-tune/src/engine/playtest/trace.rs @@ -41,13 +41,13 @@ pub struct RetrievalTrace { pub mode: RetrieveMode, pub total_elapsed: Duration, pub merged_nodes: Vec, - /// 独立于记忆分数的动作节点(procedure top-k):按动作自身分数单独排名, + /// 独立于记忆分数的动作节点(按 `ActionType` 每类至多一个):按动作自身分数单独排名, /// 不参与 merged_nodes 的分数合并与截断,保证"瞬时行为倾向"始终进入最终结果。 pub action_nodes: Vec, - /// 说话方式/语气通道(ActionType::Speak,至多 1 条):持久语气属性,单独进入上下文, + /// 说话方式/语气通道(`ActionType::Speak` 席位,至多 1 条):持久语气属性,单独进入上下文, /// 避免多个 Speak 内容互相矛盾;未检出时为空。 pub speech_nodes: Vec, - /// 思维习惯/性格倾向通道(ActionType::Think 且非 proc_none,至多 1 条)。 + /// 思维习惯/性格倾向通道(`ActionType::Think` 席位且非 `proc_none`,至多 1 条)。 pub think_nodes: Vec, pub per_query: Vec, } diff --git a/crates/soul-tune/src/engine/retrieve/suite.rs b/crates/soul-tune/src/engine/retrieve/suite.rs index 88fd675..307829f 100644 --- a/crates/soul-tune/src/engine/retrieve/suite.rs +++ b/crates/soul-tune/src/engine/retrieve/suite.rs @@ -519,7 +519,6 @@ impl TestSuite for RetrieveSuite { }, assoc_with_action: AssociateWithActionConfig { association: Default::default(), - action_top_k: 3, ..Default::default() }, }; @@ -532,11 +531,13 @@ impl TestSuite for RetrieveSuite { priority, ); let pipeline_res = RetrDefaultPipeline {}.retrieve(pipeline_request); + // 动作按类型分席(每类至多一个),展平为 (id, 分, priority) 供动作评测 all_full_action.extend( pipeline_res .action + .seated() .into_iter() - .map(|(id, score)| (id, score, priority)), + .map(|(_, seat)| (seat.id, seat.score, priority)), ); let ids: Vec = pipeline_res.association.iter().map(|(id, _)| *id).collect(); diff --git a/docs/architecture/orchestration.md b/docs/architecture/orchestration.md index 870a031..e5bc17c 100644 --- a/docs/architecture/orchestration.md +++ b/docs/architecture/orchestration.md @@ -45,7 +45,7 @@ graph TD direction TB S1["① ShortOnly
提取窗口信息 + 摘要"] S2["② Similarity
query 向量与 Cluster 节点余弦相似度,阈值过滤"] - S3["③ AssociateWithAction
PPR 联想扩散 + softmax 归一化 + 贝叶斯动作推理 topK"] + S3["③ AssociateWithAction
PPR 联想扩散 + softmax 归一化 + 贝叶斯动作推理(按 ActionType 每类取 1)"] S1 --> S2 --> S3 Res["DefaultPipelineResult
{ association, action, short_history, short_mem, priority }"] S3 --> Res @@ -190,7 +190,7 @@ sequenceDiagram CL-->>P: top-N (MemoryId, score) P->>P: ③a Association:PPR 联想扩散(以 top-N 为源节点) P->>P: ③b softmax 归一化 - P->>P: ③c BayesAction:动作概率推理 topK + P->>P: ③c BayesAction:动作概率推理(每个 ActionType 贪心取 1) P-->>Svc: DefaultPipelineResult { association, action, short_history, short_mem, priority } Svc->>WM: record_retrieval / add_feedback 更新活跃记录 diff --git "a/docs/\346\243\200\347\264\242\347\256\227\346\263\225\346\224\271\350\277\233\350\275\250\350\277\271\346\212\245\345\221\212.md" "b/docs/\346\243\200\347\264\242\347\256\227\346\263\225\346\224\271\350\277\233\350\275\250\350\277\271\346\212\245\345\221\212.md" index e119742..2b7481a 100644 --- "a/docs/\346\243\200\347\264\242\347\256\227\346\263\225\346\224\271\350\277\233\350\275\250\350\277\271\346\212\245\345\221\212.md" +++ "b/docs/\346\243\200\347\264\242\347\256\227\346\263\225\346\224\271\350\277\233\350\275\250\350\277\271\346\212\245\345\221\212.md" @@ -422,7 +422,72 @@ pub struct RetrievalTrace { 2. **精度提升有 CPU 代价**:eb34772 引入逐节点字符串分后,suite 耗时约 4×(467s→1901s),与"大图需要索引(HNSW)"的预判一致。 3. **残留问题**:eb34772 的 10 个失败(椎叶䌷/绫地/琪亚娜等)在数据重合成后仍以低命中形态存在(常陆茉子 11%、格蕾修 60%),属于 Bayes 聚合精度的检索侧问题,是下一轮的明确候选。 -## 14. 复现方法 +## 14. 动作检索:由全局 top-k 改为按 ActionType 每类一席 + +> 测量状态:**待补**。本节只记录代码改动与预期影响;尚未在同一数据集/同一 LLM 后端上重测 +> (`cargo test -p soul-mem-algo` 的单元级覆盖见下方"验证")。数据集仍为 24 图 722 用例, +> 基线为 §12 的 a6e54af(动作 Hit 81.5%、R@3 0.584)。 + +**问题**:`a6e54af` 给动作开了独立 top-k 通道(`action_top_k=3`),但动作是**全局**按分排的 +混合列表——同一 `ActionType` 可以占掉多个名额,而另一个类型一个都进不去。行为指导按类型 +消费(说话方式 / 思维习惯 / 行为倾向),全局排序与消费结构不匹配。 + +**改进方向**:按 `ActionType` 分席,每类**贪心取最高分一个**;结果类型显式标注席位, +让"哪个动作属于哪个类型"由算法层给出,消费方不再反查记忆图。 + +**关键代码**(`bayes_action.rs`): + +```rust +/// 按 `ActionType` 分席的贪心选择:每个类型只取分数最高的那一个候选。 +fn best_per_action_type( + cluster: &MemoryCluster, + scores: &HashMap, +) -> ActionTypeSlots { + let mut best: HashMap = HashMap::new(); + for (id, score) in scores { + // ... 取 node 的 ActionType;Skill 无个体标识,跳过 + let better = match best.get(action_type) { + None => true, + Some(current) => *score > current.score || (*score == current.score && *id < current.id), + }; + if better { best.insert(action_type.clone(), ActionSeat { id: *id, score: *score }); } + } + // speak / think / skill 三席 +} +``` + +**返回类型**:`RetrBayesAction` 的 `Return` 由 `Vec<(MemoryId, f64)>` 变为 +`ActionTypeSlots { speak, think, skill }`(`Option`,空席位即无候选)。 +`DefaultPipelineResult.action` **字段名不变**,类型随之改变; +`AssociateWithActionResult` 新增同类型的 `slots` 字段(原 `action` 改名为 `slots`)。 +`BayesActionConfig.top_k` 与 `AssociateWithActionConfig.action_top_k` 一并删除——每类恰好一席, +不再是可调 top-k。 + +**为什么**:席位就是消费结构(语气 / 思维习惯 / 行为倾向),每类一席让"该类型是否被触发" +成为可判定信号,而不是被另一个类型的多个动作挤掉。 + +**已知代价与风险**(**未测量,落地后需重跑确认**): + +1. **R@3 / R@5 退化为 R@1**:结果至多 3 条(当前实际至多 2 条:Skill 席位恒空), + 于是 `metrics/ranking.rs::compute_action_metrics` 的 `Recall@3`、`Recall@5` 与 `Recall@1` + 恒等——动作指标的 k 曲线失去区分度,只读 R@1 即可。 +2. **同分 tiebreak 决定胜出者**:动作分只由 `TrigToAction.prob × 源权重` 决定, + **与动作内容无关**;同类型多个候选共享同一批源边时分值可完全相同,此时由 + `MemoryId` 的 `Ord` 决定谁进席位。这放大了"动作分不区分内容"这一既有性质, + 是后续值得单独处理的算法问题。 +3. **playtest 提示词变短**:行为倾向从最多 3 条降到最多 1 条,回复风格可能漂移。 +4. **`proc_none` 占席**:`proc_none` 是 `ActionType::Think`、语义等同 null。它照常参与 + Think 席位竞争,胜出时 Think 席返回它本身;playtest 侧仍按旧规则把它从"思维习惯" + 通道剔到行为通道,故不会出现"空动作占据思维习惯"的提示词。 + +**验证**(单元级,未含端到端数据): + +- 每类型一席:三类型各一候选 → 占 2 席(Speak/Think),Skill 恒空; +- 贪心:同类型 0.7 与 0.3 → 只留 0.7; +- tiebreak:同类型同分 → 取 `MemoryId` 较小者; +- 席位标注:`seated()` 返回 `(ActionType, ActionSeat)`,可反查类型。 + +## 15. 复现方法 ```powershell # 逐 HEAD:checkout → 清 embcache → build → suite full → playtest From 08924eb6aaaf9545334faff2330349d273f66386 Mon Sep 17 00:00:00 2001 From: dynamder Date: Wed, 30 Sep 2026 22:46:27 +0800 Subject: [PATCH 18/30] chore(gitignore): ignore gh cache directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 忽略本地 .gh-cache 目录,避免 GitHub CLI 缓存被误提交 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 754eb4a..60e5c24 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ .LSOverride .opencode +/.gh-cache /mutants.out/ /mutants.out.old/ graph_stats.json From b19ee467906e233dcf57097171aac6197947d816 Mon Sep 17 00:00:00 2001 From: dynamder Date: Wed, 30 Sep 2026 22:47:05 +0800 Subject: [PATCH 19/30] chore(reasonix): drop tracked desktop topic state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 .reasonix/ 下三个 desktop-topic 状态文件(本地工具状态,不属于仓库内容) - 该目录已从工作区删除,删除后 .reasonix 不再被跟踪 --- .reasonix/desktop-topic-created-at.json | 3 --- .reasonix/desktop-topic-title-sources.json | 4 ---- .reasonix/desktop-topic-titles.json | 4 ---- 3 files changed, 11 deletions(-) delete mode 100644 .reasonix/desktop-topic-created-at.json delete mode 100644 .reasonix/desktop-topic-title-sources.json delete mode 100644 .reasonix/desktop-topic-titles.json diff --git a/.reasonix/desktop-topic-created-at.json b/.reasonix/desktop-topic-created-at.json deleted file mode 100644 index 161cc54..0000000 --- a/.reasonix/desktop-topic-created-at.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "topic_20260804-131041_14c3e615fe73731a": 1785849041942 -} \ No newline at end of file diff --git a/.reasonix/desktop-topic-title-sources.json b/.reasonix/desktop-topic-title-sources.json deleted file mode 100644 index 6aece6a..0000000 --- a/.reasonix/desktop-topic-title-sources.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "legacy_20260804-130356_912796400-deepseek-v4-flash_d4e51e713ae2": "manual", - "topic_20260804-131041_14c3e615fe73731a": "auto" -} \ No newline at end of file diff --git a/.reasonix/desktop-topic-titles.json b/.reasonix/desktop-topic-titles.json deleted file mode 100644 index b4ee6cd..0000000 --- a/.reasonix/desktop-topic-titles.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "legacy_20260804-130356_912796400-deepseek-v4-flash_d4e51e713ae2": "探索项目,现在soul-tune的p…", - "topic_20260804-131041_14c3e615fe73731a": "新的会话" -} \ No newline at end of file From 2e69f4043092908c8c8e931d1a5ad2e6c5c9c2d8 Mon Sep 17 00:00:00 2001 From: dynamder Date: Thu, 1 Oct 2026 00:03:46 +0800 Subject: [PATCH 20/30] =?UTF-8?q?feat(core):=20=E6=96=B0=E5=A2=9E=20Memory?= =?UTF-8?q?Note=20=E7=9A=84=20render=20=E5=AF=B9=E5=A4=96=E5=A5=91?= =?UTF-8?q?=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Render trait,输出 RenderedMemoryNote 的 kind、score、content、context 四个字 段 - kind 用单层扁平枚举区分具体与抽象情景,序列化名对齐 proto - content 覆盖语义、具体情景、抽象情景代表字段、程序性记忆四种推导规则 - context 仅具体情景给出,为 None 时序列化保留键并输出 null - render 契约测试 13 项,锁定键集合、kind 拼写与 context 的 proto 形状 --- Cargo.lock | 1 + crates/soul-mem-core/Cargo.toml | 4 + crates/soul-mem-core/src/lib.rs | 16 + crates/soul-mem-core/src/memory_note.rs | 6 + crates/soul-mem-core/src/render.rs | 213 +++++++++++ crates/soul-mem-core/src/render/tests.rs | 458 +++++++++++++++++++++++ 6 files changed, 698 insertions(+) create mode 100644 crates/soul-mem-core/src/render.rs create mode 100644 crates/soul-mem-core/src/render/tests.rs diff --git a/Cargo.lock b/Cargo.lock index cd7d36e..79840ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7412,6 +7412,7 @@ version = "0.1.0" dependencies = [ "chrono", "serde", + "serde_json", "thiserror 2.0.20", "uuid", ] diff --git a/crates/soul-mem-core/Cargo.toml b/crates/soul-mem-core/Cargo.toml index 1ee122d..4ed635a 100644 --- a/crates/soul-mem-core/Cargo.toml +++ b/crates/soul-mem-core/Cargo.toml @@ -9,3 +9,7 @@ serde = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } thiserror = { workspace = true } + +[dev-dependencies] +# 仅用于 render 契约测试:断言序列化后的键集合与 kind 拼写,防止对外协议被无意改动 +serde_json = { workspace = true } diff --git a/crates/soul-mem-core/src/lib.rs b/crates/soul-mem-core/src/lib.rs index ac10838..407a6f1 100644 --- a/crates/soul-mem-core/src/lib.rs +++ b/crates/soul-mem-core/src/lib.rs @@ -29,6 +29,22 @@ //! - **访问器风格不统一**:一部分是 `get_xxx()`(且返回 `&Option` / `&Vec`), //! 另一部分是裸名词方法(`id()` / `tags()`,返回 `&[T]`)。**新代码请用后者。** //! - `default_missing_degree` / `default_last_forget_time` 在两个模块里各有一份。 +//! +//! # 对外渲染契约 +//! +//! 内部类型是给存储与算法用的,不能直接当作对外协议。对外的稳定形态由 +//! [`render`] 定义: +//! +//! | 类型 | 含义 | +//! |---|---| +//! | [`Render`](render::Render) | 内部类型 → 对外表示的 trait,当前由 `MemoryNote` 实现 | +//! | [`RenderedMemoryNote`](render::RenderedMemoryNote) | 单个节点的对外表示:类型 + 分数 + 内容 + 上下文(仅具体情景带上下文) | +//! | [`MemoryKind`](render::MemoryKind) | 对外类型判别字段,与 orchestration 的 proto 枚举对应 | +//! +//! 分数**不在** [`MemoryNote`](memory_note::MemoryNote) 上,而是由检索层在调用 +//! [`Render::render`](render::Render::render) 时传入;取值规则与「本层不校验分数」 +//! 的理由见 [`render`] 的模块文档。 pub mod memory_links; pub mod memory_note; +pub mod render; diff --git a/crates/soul-mem-core/src/memory_note.rs b/crates/soul-mem-core/src/memory_note.rs index c3a9238..e8a78ee 100644 --- a/crates/soul-mem-core/src/memory_note.rs +++ b/crates/soul-mem-core/src/memory_note.rs @@ -23,6 +23,11 @@ //! //! 用 `MemoryNoteBuilder`,其 `build()` 返回 `Result`(会校验时间顺序等约束)。 //! 注意同层的 `MemoryLinkBuilder::build()` 返回的是裸值——两者契约不同,别照抄。 +//! +//! # 对外暴露 +//! +//! 把节点交给外部服务时**不要**直接序列化本类型。用 [`crate::render::Render`], +//! 它产出稳定的对外表示(类型 + 分数 + 内容 + 上下文),内部字段重构不会波及协议。 use std::fmt::Display; @@ -111,6 +116,7 @@ impl MemoryNote { pub fn last_accessed_time(&self) -> DateTime { self.last_accessed_time } + /// 类型特定载荷。对外暴露请走 [`crate::render::Render::render`]。 pub fn mem_type(&self) -> &MemoryType { &self.mem_type } diff --git a/crates/soul-mem-core/src/render.rs b/crates/soul-mem-core/src/render.rs new file mode 100644 index 0000000..4b8a4ff --- /dev/null +++ b/crates/soul-mem-core/src/render.rs @@ -0,0 +1,213 @@ +//! 对外渲染契约:把记忆节点转成可跨进程/跨语言传递的形态。 +//! +//! SoulMem 对外以服务形式提供(zenoh pub/sub、gRPC),输出是「检索到的记忆集合」。 +//! 内部类型([`MemoryNote`] / [`MemoryType`])是为存储与算法优化的:变体带类型特定载荷、 +//! 字段分散在 `memory_note/` 下的各个模块里,而且**不含分数**(分数由检索层算)。 +//! 直接把内部类型序列化出去,等于让内部重构直接变成对外协议的破坏性变更。 +//! +//! [`Render`] 就是这层稳定契约。它定义在本 crate(`soul-mem-core` 无任何内部依赖), +//! 因此服务层无需依赖算法 crate 就能拿到输出类型。 +//! +//! # 输出形状 +//! +//! [`RenderedMemoryNote`] 只有四个字段:类型、分数、内容、上下文。 +//! +//! | 字段 | 序列化名 | 含义 | +//! |---|---|---| +//! | [`MemoryKind`] | `kind` | 节点类型判别字段,与 proto 枚举一一对应 | +//! | `score` | `score` | 检索分数,由调用方提供(见下) | +//! | `content` | `content` | 记忆正文,取值规则见下表 | +//! | `context` | `context` | 具体情景的结构化上下文;其余类型为 `null`(见下) | +//! +//! # 内容取值规则 +//! +//! | [`MemoryType`] 变体 | [`MemoryKind`] | `content` 取值 | `context` | +//! |---|---|---|---| +//! | [`MemoryType::Semantic`] | [`MemoryKind::Semantic`] | [`SemMemory::content`](crate::memory_note::sem_mem::SemMemory::content),`description` 非空时追加 `(description)` | `None` | +//! | [`MemoryType::Situation`] 的 `SpecificSituation` | [`MemoryKind::SpecificSituation`] | [`SpecificSituation::get_narrative`](crate::memory_note::situation_mem::SpecificSituation::get_narrative) | `Some`([`Context`]) | +//! | [`MemoryType::Situation`] 的 `AbstractSituation` | [`MemoryKind::AbstractSituation`] | 代表字段,见下 | `None` | +//! | [`MemoryType::Procedure`] | [`MemoryKind::Procedure`] | [`Action::get_content`](crate::memory_note::proc_mem::Action::get_content) | `None` | +//! +//! 抽象情景没有叙事,用其**代表字段**代替: +//! +//! | `AbstractSituation` 变体 | `content` 取值 | +//! |---|---| +//! | `Location` | `name` | +//! | `Participant` | `name` | +//! | `Environment` | `"{atmosphere} {tone}"`(trim 后) | +//! | `Event` | `action` | +//! +//! 取代表字段而不是结构化全文是有意的:抽象情景是「地点/人物/氛围/事件」的**概念**, +//! 拼上 `coordinates`、`action_intensity` 这类字段会产出对外部消费者无意义的噪声。 +//! +//! # `context`:只服务具体情景 +//! +//! [`Context`] 的六个字段(location / participants / emotions / sensory_data / +//! environment / event)只有**具体情景**拿得出来,因此只有 +//! [`MemoryKind::SpecificSituation`] 填 `Some`,其余三种类型一律 `None`。 +//! +//! **抽象情景的结构化内容刻意不进本字段。** 它自己是 `Location` / `Participant` / +//! `Environment` / `Event` 四者之一,语义与「一条具体情景的上下文」不同,塞进 +//! [`Context`] 的对应槽位会让那些字段变成「抽象时只有一格有值」的双重语义。 +//! 现阶段需要它的调用方直接读 [`MemoryNote::mem_type`];将来外部接口真要暴露抽象 +//! 情景的结构化内容时,再按 proto 的 `oneof` 语义单独加字段。 +//! +//! `context` 为 `None` 时序列化**保留键并输出 `null`**(不用 +//! `skip_serializing_if`):对外契约的键集合恒定,比"有时有、有时没有"更好断言、 +//! 也更好演进。 +//! +//! # 分数由调用方提供,本层不校验 +//! +//! [`MemoryNote`] 不持有分数:检索管线的产物是 `(MemoryId, f64)` 配对, +//! 分数是检索过程的中间结果。因此 [`Render::render`] 把 `score` 作为入参。 +//! +//! 本层**原样透传** `score`(含 `0.0` / `1.0` / 负数 / NaN),不做钳制也不做校验: +//! 「分数必须落在 0 到 1 之间」是检索管线的契约,在这里再钳一次只会把管线的 bug +//! 掩盖成一个看起来正常的值。 +//! +//! # 空内容不在此处丢弃 +//! +//! `content` 是 `String` 而不是 `Option`:**推导内容**属于本层, +//! **判定可否丢弃**属于装配层(一条空记忆该不该出现在「相关记忆」段落里, +//! 是呈现策略)。本层如实返回空串,由调用方按 `content.is_empty()` 决定丢弃。 + +use serde::{Deserialize, Serialize}; + +use crate::memory_note::situation_mem::{AbstractSituation, Context, SituationType}; +use crate::memory_note::{MemoryNote, MemoryType}; + +#[cfg(test)] +mod tests; + +/// 记忆节点的对外类型判别字段。 +/// +/// 与 `MemoryType` 的分层不同:`MemoryType` 把「情景」的抽象/具体之分藏在 +/// `SituationType` 内层,而对外协议需要**单层扁平**枚举,所以这里拆成两个变体。 +/// +/// 序列化名是本契约的一部分(proto 枚举名与之对应): +/// `"semantic"` / `"specific_situation"` / `"abstract_situation"` / `"procedure"`。 +/// 改动拼写等于破坏对外兼容性。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MemoryKind { + /// 语义记忆(概念、实体) + Semantic, + /// 具体情景记忆(叙述 + 时间 + 上下文) + SpecificSituation, + /// 抽象情景记忆(地点 / 参与者 / 环境 / 事件) + AbstractSituation, + /// 程序性记忆(动作 / 行为倾向) + Procedure, +} + +impl MemoryKind { + /// 人类可读的中文类型名(展示/日志用,**不要**当作协议字段)。 + pub fn label(&self) -> &'static str { + match self { + Self::Semantic => "语义", + Self::SpecificSituation => "情境", + Self::AbstractSituation => "抽象情境", + Self::Procedure => "流程", + } + } +} + +/// 单个记忆节点的对外表示:类型 + 分数 + 内容 + 上下文。 +/// +/// 由 [`Render::render`] 产出。字段刻意保持最小——每加一个字段都是对 +/// 所有调用方的破坏性变更,需要时再加。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RenderedMemoryNote { + /// 节点类型判别字段(序列化名 `kind`:proto 字段名,不是 Rust 保留字) + pub kind: MemoryKind, + /// 检索分数,由调用方传入,本层原样透传 + pub score: f64, + /// 记忆正文,取值规则见[模块文档](self);空串表示无正文,由调用方决定是否丢弃 + pub content: String, + /// 具体情景的结构化上下文;其余三种类型为 `None`(序列化为 `null`),见[模块文档](self) + pub context: Option, +} + +impl RenderedMemoryNote { + /// 节点类型的中文名,等价于 `self.kind.label()` + pub fn label(&self) -> &'static str { + self.kind.label() + } +} + +/// 把内部类型渲染为对外契约类型的 trait。 +/// +/// 当前只有 [`MemoryNote`] 一个实现。将来若 [`MemoryLink`](crate::memory_links::MemoryLink) +/// 也要对外暴露,**不要**把它塞进本 trait 的关联类型:边的输出是另一个结构体, +/// 用同一 trait 的另一个 `impl` 即可。 +/// +/// # 为什么 `score` 是入参而不是关联类型 +/// +/// 分数不属于节点本身(见[模块文档](self))。若把它改成关联类型,就得为「带分数的节点」 +/// 造一个包装类型,而那属于检索层的重构范围,不该由契约决定。 +pub trait Render { + /// 渲染产物类型 + type Output; + + /// 渲染为对外表示。 + /// + /// `score` 由调用方(检索层)提供,本层不做范围校验。 + fn render(&self, score: f64) -> Self::Output; +} + +impl Render for MemoryNote { + type Output = RenderedMemoryNote; + + fn render(&self, score: f64) -> Self::Output { + let (kind, content, context) = match self.mem_type() { + MemoryType::Semantic(sem) => { + let content = if sem.description.trim().is_empty() { + sem.content.clone() + } else { + format!("{}({})", sem.content, sem.description) + }; + (MemoryKind::Semantic, content, None) + } + MemoryType::Situation(SituationType::SpecificSituation(specific)) => ( + MemoryKind::SpecificSituation, + specific.get_narrative().clone(), + Some(specific.get_context().clone()), + ), + // 抽象情景用代表字段;结构化内容不进 `context`,见模块文档 + MemoryType::Situation(SituationType::AbstractSituation(abstract_situation)) => ( + MemoryKind::AbstractSituation, + abstract_text(abstract_situation), + None, + ), + MemoryType::Procedure(proc) => ( + MemoryKind::Procedure, + proc.get_action().get_content().to_string(), + None, + ), + }; + + RenderedMemoryNote { + kind, + score, + content, + context, + } + } +} + +/// 抽象情景的代表文本:取最能指代该节点的单个字段。 +/// +/// 没有叙事可用,因此不拼装多个字段——`Environment` 是唯一需要拼接的变体 +/// (氛围与色调共同构成"环境"),拼接后 trim 掉两侧空白。 +fn abstract_text(situation: &AbstractSituation) -> String { + match situation { + AbstractSituation::Location(location) => location.name.clone(), + AbstractSituation::Participant(participant) => participant.name.clone(), + AbstractSituation::Environment(environment) => { + format!("{} {}", environment.atmosphere, environment.tone) + .trim() + .to_string() + } + AbstractSituation::Event(event) => event.action.clone(), + } +} diff --git a/crates/soul-mem-core/src/render/tests.rs b/crates/soul-mem-core/src/render/tests.rs new file mode 100644 index 0000000..c385404 --- /dev/null +++ b/crates/soul-mem-core/src/render/tests.rs @@ -0,0 +1,458 @@ +//! [`Render`] 契约测试:类型映射、内容取值规则、`context` 语义、序列化形状。 +//! +//! 单独成文件而非内联在 `render.rs` 里,是为了让契约断言(键集合、kind 拼写、 +//! `context` 的 null 约定)与实现分开维护——改动实现时这些断言应当**仍然失败**, +//! 那正是它们的作用。 + +use chrono::{TimeZone, Utc}; +use serde_json::json; + +use super::{MemoryKind, Render, RenderedMemoryNote}; +use crate::memory_note::proc_mem::{Action, ActionType, ProcMemory}; +use crate::memory_note::sem_mem::{ConceptType, SemMemory}; +use crate::memory_note::situation_mem::{ + AbstractSituation, Context, Emotion, Environment, Event, Location, Participant, SensoryData, + SituationType, SpecificSituation, +}; +use crate::memory_note::{MemoryId, MemoryNote, MemoryNoteBuilder, MemoryType}; + +/// 构造节点;仅供本模块测试使用,字段默认值足够。 +fn note(mem_type: MemoryType) -> MemoryNote { + MemoryNoteBuilder::new(mem_type) + .build() + .expect("test fixture: default timestamps satisfy builder invariants") +} + +/// 六个字段全非空的上下文,用于断言 `context` 原样透传。 +fn full_context() -> Context { + Context::new( + Some(Location { + name: "红魔馆".to_string(), + coordinates: "0,0".to_string(), + }), + vec![Participant { + name: "蕾米莉亚".to_string(), + role: "大小姐".to_string(), + }], + vec![Emotion { + name: "安心".to_string(), + intensity: 0.8, + }], + vec![SensoryData { + name: "茶香".to_string(), + intensity: 0.5, + }], + Environment { + atmosphere: "安静".to_string(), + tone: "温暖".to_string(), + }, + vec![Event { + action: "斟茶".to_string(), + action_intensity: 0.4, + initiator: "我".to_string(), + target: "蕾米莉亚".to_string(), + }], + ) +} + +fn specific_situation(narrative: &str) -> SpecificSituation { + SpecificSituation::new( + narrative.to_string(), + Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0) + .single() + .expect("test fixture: fixed timestamp is unambiguous"), + full_context(), + ) +} + +/// serde_json 保留大整数原样:`to_value(&struct)` 会把 `f32` 写成 `1e+2`, +/// `json!` 字面量却是 `100.0`,直接比较会假失败。先经由字符串往返统一数值表示。 +fn lenient_json(value: &T) -> serde_json::Value { + let text = serde_json::to_string(value) + .expect("test fixture: RenderedMemoryNote is JSON-serializable"); + serde_json::from_str(&text).expect("test fixture: serde_json output is valid JSON") +} + +/// 语义记忆 → `Semantic`,内容取 `content`,`description` 非空时追加括号 +#[test] +fn render_semantic_appends_description_when_present() { + let with_desc = note(MemoryType::Semantic(SemMemory::new( + "酒馆".to_string(), + ConceptType::Entity, + "人们喝酒聊天的地方".to_string(), + ))); + let rendered = with_desc.render(0.75); + assert_eq!(rendered.kind, MemoryKind::Semantic); + assert_eq!(rendered.content, "酒馆(人们喝酒聊天的地方)"); + assert_eq!(rendered.score, 0.75); + + let no_desc = note(MemoryType::Semantic(SemMemory::new( + "酒馆".to_string(), + ConceptType::Entity, + String::new(), + ))); + assert_eq!(no_desc.render(0.75).content, "酒馆"); +} + +/// `description` 全为空白时视为空,不产出空括号 +#[test] +fn render_semantic_treats_blank_description_as_empty() { + let mem = note(MemoryType::Semantic(SemMemory::new( + "酒馆".to_string(), + ConceptType::Entity, + " ".to_string(), + ))); + + assert_eq!(mem.render(0.1).content, "酒馆"); +} + +/// 具体情景 → `SpecificSituation`,内容取 `narrative`,并带上完整 `context` +#[test] +fn render_specific_situation_carries_narrative_and_context() { + let context = full_context(); + let mem = note(MemoryType::Situation(SituationType::SpecificSituation( + SpecificSituation::new( + "傍晚我在庭院里为大小姐斟茶".to_string(), + Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0) + .single() + .expect("test fixture: fixed timestamp is unambiguous"), + context.clone(), + ), + ))); + + let rendered = mem.render(0.4); + + assert_eq!(rendered.kind, MemoryKind::SpecificSituation); + assert_eq!(rendered.content, "傍晚我在庭院里为大小姐斟茶"); + assert_eq!( + rendered.context, + Some(context), + "具体情景的 context 应原样透传(六个字段都不丢)" + ); +} + +/// 抽象情景 → `AbstractSituation`,内容取代表字段 +#[test] +fn render_abstract_situation_uses_representative_field() { + let cases = [ + ( + AbstractSituation::Location(Location { + name: "红魔馆".to_string(), + coordinates: "0,0".to_string(), + }), + "红魔馆", + ), + ( + AbstractSituation::Participant(Participant { + name: "蕾米莉亚".to_string(), + role: "大小姐".to_string(), + }), + "蕾米莉亚", + ), + ( + AbstractSituation::Environment(Environment { + atmosphere: "安静".to_string(), + tone: "温暖".to_string(), + }), + "安静 温暖", + ), + ( + AbstractSituation::Event(Event { + action: "斟茶".to_string(), + action_intensity: 0.4, + initiator: "我".to_string(), + target: "蕾米莉亚".to_string(), + }), + "斟茶", + ), + ]; + + for (variant, expected) in cases { + let mem = note(MemoryType::Situation(SituationType::AbstractSituation( + variant, + ))); + let rendered = mem.render(0.1); + + assert_eq!(rendered.kind, MemoryKind::AbstractSituation); + assert_eq!(rendered.content, expected); + assert_eq!( + rendered.context, None, + "抽象情景的结构化内容刻意不进 context(见模块文档)" + ); + } +} + +/// 环境代表文本在字段为空时 trim 成空串,而不是留下一个空格 +#[test] +fn render_abstract_environment_trims_blank_fields() { + let mem = note(MemoryType::Situation(SituationType::AbstractSituation( + AbstractSituation::Environment(Environment { + atmosphere: String::new(), + tone: String::new(), + }), + ))); + + assert!( + mem.render(0.1).content.is_empty(), + "两个字段都为空时应产出空串" + ); +} + +/// 程序性记忆 → `Procedure`,内容取 `Action::get_content` +#[test] +fn render_procedure_uses_action_content() { + let mem = note(MemoryType::Procedure(ProcMemory::new(Action::new( + "轻声细语地回应".to_string(), + ActionType::new_speak(), + )))); + + let rendered = mem.render(0.2); + + assert_eq!(rendered.kind, MemoryKind::Procedure); + assert_eq!(rendered.content, "轻声细语地回应"); + assert_eq!(rendered.context, None); +} + +/// 只有具体情景带 `context`,其余三种类型一律为 `None` +#[test] +fn render_context_present_only_for_specific_situation() { + let cases = [ + MemoryType::Semantic(SemMemory::new( + "c".to_string(), + ConceptType::Entity, + String::new(), + )), + MemoryType::Situation(SituationType::AbstractSituation( + AbstractSituation::Participant(Participant { + name: "p".to_string(), + role: "r".to_string(), + }), + )), + MemoryType::Procedure(ProcMemory::new(Action::new( + "a".to_string(), + ActionType::new_think(), + ))), + ]; + + for mem_type in cases { + let rendered = note(mem_type).render(0.5); + assert_eq!( + rendered.context, None, + "{:?} 不应携带 context", + rendered.kind + ); + } +} + +/// `score` 原样透传:含边界值,不做钳制也不做校验 +#[test] +fn render_passes_score_through_verbatim() { + let mem = note(MemoryType::Semantic(SemMemory::new( + "概念".to_string(), + ConceptType::Abstract, + String::new(), + ))); + + for score in [0.0_f64, 1.0, f64::MIN_POSITIVE, -0.5, 2.0] { + assert_eq!( + mem.render(score).score, + score, + "render 不应改写 score(含越界值)" + ); + } +} + +/// 序列化形状即对外契约:键恰好是 kind / score / content / context 四项 +#[test] +fn rendered_note_serializes_to_exactly_four_keys() { + let mem = note(MemoryType::Semantic(SemMemory::new( + "酒馆".to_string(), + ConceptType::Entity, + String::new(), + ))); + + let value = lenient_json(&mem.render(0.75)); + + assert_eq!( + value, + json!({"kind": "semantic", "score": 0.75, "content": "酒馆", "context": null}) + ); + + let object = value + .as_object() + .expect("test fixture: RenderedMemoryNote serializes to a JSON object"); + let mut keys: Vec<&str> = object.keys().map(String::as_str).collect(); + keys.sort_unstable(); + assert_eq!( + keys, + vec!["content", "context", "kind", "score"], + "对外契约的键集合被改动了" + ); +} + +/// `context` 为 `None` 时保留键并输出 `null`(不用 skip_serializing_if) +#[test] +fn rendered_note_keeps_context_key_as_null() { + for mem_type in [ + MemoryType::Semantic(SemMemory::new( + "c".to_string(), + ConceptType::Entity, + String::new(), + )), + MemoryType::Procedure(ProcMemory::new(Action::new( + "a".to_string(), + ActionType::new_speak(), + ))), + ] { + let value = lenient_json(¬e(mem_type).render(0.5)); + let object = value + .as_object() + .expect("test fixture: RenderedMemoryNote serializes to a JSON object"); + + assert!( + object.contains_key("context"), + "无 context 时键仍须存在,只是值为 null" + ); + assert_eq!(object.get("context"), Some(&serde_json::Value::Null)); + } +} + +/// `context` 的序列化形状对齐 proto 的 `Context` 消息,六个字段用 snake_case +#[test] +fn rendered_context_serializes_into_proto_shaped_object() { + let mem = note(MemoryType::Situation(SituationType::SpecificSituation( + specific_situation("傍晚我在庭院里为大小姐斟茶"), + ))); + + let value = lenient_json(&mem.render(0.9)); + let context = value + .get("context") + .and_then(serde_json::Value::as_object) + .expect("test fixture: specific situation must carry a context object"); + + let mut keys: Vec<&str> = context.keys().map(String::as_str).collect(); + keys.sort_unstable(); + assert_eq!( + keys, + vec![ + "emotions", + "environment", + "event", + "location", + "participants", + "sensory_data" + ], + "context 的字段集合应与 proto 的 Context 消息一致" + ); + + assert_eq!( + value, + json!({ + "kind": "specific_situation", + "score": 0.9, + "content": "傍晚我在庭院里为大小姐斟茶", + "context": { + "location": {"name": "红魔馆", "coordinates": "0,0"}, + "participants": [{"name": "蕾米莉亚", "role": "大小姐"}], + "emotions": [{"name": "安心", "intensity": 0.8}], + "sensory_data": [{"name": "茶香", "intensity": 0.5}], + "environment": {"atmosphere": "安静", "tone": "温暖"}, + "event": [{ + "action": "斟茶", + "action_intensity": 0.4, + "initiator": "我", + "target": "蕾米莉亚" + }] + } + }) + ); +} + +/// `kind` 的序列化拼写与 proto 枚举一一对应,改动即破坏对外兼容性 +#[test] +fn rendered_note_serializes_kind_spelling() { + let cases = [ + ( + MemoryType::Semantic(SemMemory::new( + "c".to_string(), + ConceptType::Entity, + String::new(), + )), + "semantic", + ), + ( + MemoryType::Situation(SituationType::SpecificSituation(specific_situation("n"))), + "specific_situation", + ), + ( + MemoryType::Situation(SituationType::AbstractSituation( + AbstractSituation::Participant(Participant { + name: "p".to_string(), + role: "r".to_string(), + }), + )), + "abstract_situation", + ), + ( + MemoryType::Procedure(ProcMemory::new(Action::new( + "a".to_string(), + ActionType::new_think(), + ))), + "procedure", + ), + ]; + + for (mem_type, expected) in cases { + let value = lenient_json(¬e(mem_type).render(0.5)); + assert_eq!( + value.get("kind").and_then(serde_json::Value::as_str), + Some(expected), + "kind 拼写是契约的一部分,不应改动" + ); + } +} + +/// 四种 `kind` 的中文名两两不同且非空(展示用,不参与协议) +#[test] +fn memory_kind_labels_are_distinct_and_non_empty() { + let kinds = [ + MemoryKind::Semantic, + MemoryKind::SpecificSituation, + MemoryKind::AbstractSituation, + MemoryKind::Procedure, + ]; + + let mut labels: Vec<&str> = Vec::new(); + for kind in kinds { + let label = kind.label(); + assert!(!label.is_empty(), "{kind:?} 的中文名不能为空"); + assert!(!labels.contains(&label), "中文名重复: {label}"); + labels.push(label); + } + assert_eq!(labels.len(), kinds.len()); +} + +/// 渲染结果只依赖类型与内容,与 `MemoryId` 无关 +#[test] +fn render_ignores_memory_id() { + let make = |id: MemoryId| { + MemoryNoteBuilder::new(MemoryType::Semantic(SemMemory::new( + "同样的内容".to_string(), + ConceptType::Entity, + String::new(), + ))) + .id(id) + .build() + .expect("test fixture: default timestamps satisfy builder invariants") + }; + + let expected = RenderedMemoryNote { + kind: MemoryKind::Semantic, + score: 0.5, + content: "同样的内容".to_string(), + context: None, + }; + + assert_eq!(make(MemoryId::new()).render(0.5), expected); + assert_eq!(make(MemoryId::new()).render(0.5), expected); +} From 16c9119ade2f58ba7a1c07785eb1367012e13ab2 Mon Sep 17 00:00:00 2001 From: dynamder Date: Thu, 1 Oct 2026 00:10:22 +0800 Subject: [PATCH 21/30] =?UTF-8?q?feat(core):=20=E5=A2=9E=E5=BC=BArender?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/soul-mem-core/src/render/tests.rs | 35 +++++++++++++++++++----- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/crates/soul-mem-core/src/render/tests.rs b/crates/soul-mem-core/src/render/tests.rs index c385404..b3aafca 100644 --- a/crates/soul-mem-core/src/render/tests.rs +++ b/crates/soul-mem-core/src/render/tests.rs @@ -23,6 +23,16 @@ fn note(mem_type: MemoryType) -> MemoryNote { .expect("test fixture: default timestamps satisfy builder invariants") } +/// 直接构造渲染结果(字段皆公开),用于覆盖与具体节点无关的展示方法。 +fn render_of(kind: MemoryKind) -> RenderedMemoryNote { + RenderedMemoryNote { + kind, + score: 0.0, + content: String::new(), + context: None, + } +} + /// 六个字段全非空的上下文,用于断言 `context` 原样透传。 fn full_context() -> Context { Context::new( @@ -412,24 +422,35 @@ fn rendered_note_serializes_kind_spelling() { } } -/// 四种 `kind` 的中文名两两不同且非空(展示用,不参与协议) +/// 四种 `kind` 的中文名两两不同且非空,且结构体上的同名方法与之等价 +/// (展示用,不参与协议) #[test] fn memory_kind_labels_are_distinct_and_non_empty() { let kinds = [ - MemoryKind::Semantic, - MemoryKind::SpecificSituation, - MemoryKind::AbstractSituation, - MemoryKind::Procedure, + (MemoryKind::Semantic, render_of(MemoryKind::Semantic)), + ( + MemoryKind::SpecificSituation, + render_of(MemoryKind::SpecificSituation), + ), + ( + MemoryKind::AbstractSituation, + render_of(MemoryKind::AbstractSituation), + ), + (MemoryKind::Procedure, render_of(MemoryKind::Procedure)), ]; let mut labels: Vec<&str> = Vec::new(); - for kind in kinds { + for (kind, rendered) in kinds { let label = kind.label(); assert!(!label.is_empty(), "{kind:?} 的中文名不能为空"); assert!(!labels.contains(&label), "中文名重复: {label}"); labels.push(label); + + // `RenderedMemoryNote::label` 必须与 `kind.label()` 一致: + // 两个方法同名同义,分叉会让展示层与协议层对不上 + assert_eq!(rendered.label(), label, "结构体方法应与 kind.label() 等价"); } - assert_eq!(labels.len(), kinds.len()); + assert_eq!(labels.len(), 4); } /// 渲染结果只依赖类型与内容,与 `MemoryId` 无关 From f350e860430520a82be90818b6f39e95eaaf7b18 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 07:14:50 +0000 Subject: [PATCH 22/30] chore(deps): bump uuid from 1.23.1 to 1.26.1 Bumps [uuid](https://github.com/uuid-rs/uuid) from 1.23.1 to 1.26.1. - [Release notes](https://github.com/uuid-rs/uuid/releases) - [Commits](https://github.com/uuid-rs/uuid/compare/v1.23.1...v1.26.1) --- updated-dependencies: - dependency-name: uuid dependency-version: 1.26.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Cargo.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 79840ff..681c1d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8128,7 +8128,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.52.0", @@ -9046,9 +9046,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom 0.4.2", "js-sys", From e683c93583c24059bac5a3254972cff90934de48 Mon Sep 17 00:00:00 2001 From: dynamder Date: Thu, 1 Oct 2026 17:03:26 +0800 Subject: [PATCH 23/30] =?UTF-8?q?ci(mutants):=20=E8=AE=A9=E6=9D=80?= =?UTF-8?q?=E7=81=AD=E7=8E=87=E9=97=A8=E7=A6=81=E8=A3=81=E5=86=B3=20cargo-?= =?UTF-8?q?mutants=20=E7=9A=84=E9=80=80=E5=87=BA=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cargo-mutants 只要有一个 MISSED 就退 2,job 在门禁之前就被判红,--threshold 90 永远无法生效 - mutants_gate.py 新增 --cargo-mutants-exit-code:只把 0/2/3 当作结果,其余按工具故障硬失败 - 删掉 if: hashFiles(...) 守卫:if 里不含状态检查函数时会被隐式加上 success(),上一步失败即跳过 - 没有变异体可测(退出码 0 且报告缺失或为空)仍判 PASS --- .github/workflows/mutants.yml | 44 ++++++++++++++++++++++++++++------- scripts/mutants_gate.py | 44 +++++++++++++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 11 deletions(-) diff --git a/.github/workflows/mutants.yml b/.github/workflows/mutants.yml index e6d9d34..27937b0 100644 --- a/.github/workflows/mutants.yml +++ b/.github/workflows/mutants.yml @@ -55,11 +55,23 @@ jobs: - name: Install cargo-mutants run: cargo install cargo-mutants --version 27.1.0 + # 归一化规则见下方注释与 scripts/mutants_gate.py 的 --cargo-mutants-exit-code: + # 这一步只如实带出 cargo-mutants 的退出码,是否合格全部由门禁裁决。 - name: Run cargo mutants (full) - run: cargo mutants --workspace + id: mutants + run: | + set +e + cargo mutants --workspace + code=$? + echo "exit_code=$code" >> "$GITHUB_OUTPUT" + if [ "$code" -ne 0 ]; then + echo "::warning::cargo mutants 退出码 $code(0=全部杀灭 2=有 MISSED 3=有 TIMEOUT;其余为工具故障)" + fi - name: Check kill rate - run: python3 scripts/mutants_gate.py --out-dir mutants.out --threshold 90 + run: >- + python3 scripts/mutants_gate.py --out-dir mutants.out --threshold 90 + --cargo-mutants-exit-code "${{ steps.mutants.outputs.exit_code }}" - name: Upload mutants report if: always() @@ -95,16 +107,30 @@ jobs: run: | git -c core.quotepath=false diff "${{ github.event.pull_request.base.sha }}...${{ github.sha }}" > "$RUNNER_TEMP/pr.diff" + # 退出码语义(cargo-mutants 27.1.0)里只有三个是「结果」:0=全部杀灭、2=有 MISSED、 + # 3=有 TIMEOUT;其余(1 用法错误 / 4 基线失败 / 5 --in-diff 不匹配 / 6 diff 非法 / + # 70 内部错误)是工具故障。裸跑 `cargo mutants` 会让 exit 2 在门禁之前就把 job 判红, + # 于是 `--threshold 90` 永远无法生效——哪怕杀灭率 98% 也红;而 `continue-on-error: true` + # 又会连工具故障一起吞掉。所以这里只如实带出退出码,把裁决权完全交给门禁: + # 工具故障由门禁按「结果不可读」硬失败(exit 2),结果类退出码则按杀灭率判定。 - name: Run cargo mutants (PR diff) - run: cargo mutants --workspace --in-diff "$RUNNER_TEMP/pr.diff" + id: mutants + run: | + set +e + cargo mutants --workspace --in-diff "$RUNNER_TEMP/pr.diff" + code=$? + echo "exit_code=$code" >> "$GITHUB_OUTPUT" + if [ "$code" -ne 0 ]; then + echo "::warning::cargo mutants 退出码 $code(0=全部杀灭 2=有 MISSED 3=有 TIMEOUT;其余为工具故障)" + fi - # cargo-mutants 在「没有变异体可测」时(例如 PR 只往 .rs 里加文档注释)**不会创建** - # mutants.out/,而 mutants_gate.py 把「输出缺失」判为硬错误(exit 2)—— 那个判定要保留, - # 它用来抓 cargo-mutants 崩溃。所以这里只在确实有产物时才跑门禁;上一步失败时 job 已经 - # 红了,不存在漏判。 + # 不加 `if: hashFiles(...)` 守卫:`if` 里不含状态检查函数时会被隐式加上 `success()`, + # 而「没有变异体可测」(例如 PR 只往 .rs 里加文档注释)这一情形由脚本按 + # 「退出码 0 且报告缺失/为空」自行判 PASS,见 mutants_gate.py。 - name: Check kill rate - if: hashFiles('mutants.out/**') != '' - run: python3 scripts/mutants_gate.py --out-dir mutants.out --threshold 90 + run: >- + python3 scripts/mutants_gate.py --out-dir mutants.out --threshold 90 + --cargo-mutants-exit-code "${{ steps.mutants.outputs.exit_code }}" - name: Upload mutants report if: always() diff --git a/scripts/mutants_gate.py b/scripts/mutants_gate.py index 448b97d..bb062b4 100644 --- a/scripts/mutants_gate.py +++ b/scripts/mutants_gate.py @@ -4,10 +4,22 @@ Kill rate = caught / (caught + missed + timeout). Unviable mutants are excluded because they cannot be killed by any test. +`--cargo-mutants-exit-code` 接收 `cargo mutants` 自己的退出码,用来把「结果」 +与「工具故障」分开(cargo-mutants 27.1.0 的取值): + + 0 全部可杀灭变异体都被杀灭 + 2 存在 MISSED + 3 存在 TIMEOUT + 1/4/5/6/70 用法错误 / 基线失败 / --in-diff 不匹配 / diff 非法 / 内部错误 + +只有 0/2/3 允许走到杀灭率判定;其余一律按工具故障硬失败(exit 2),因为此时 +mutants.out 可能缺失或残缺。反过来,退出码 0 且报告缺失或为空时判 PASS: +那是「本次没有变异体可测」(例如 PR 只往 .rs 里加文档注释),不是故障。 + Exit codes: 0 - kill rate meets the threshold, or no viable mutants were generated 1 - kill rate is below the threshold - 2 - mutants output is missing or malformed + 2 - cargo-mutants reported a tool failure, or its output is missing or malformed """ import argparse @@ -15,6 +27,9 @@ import sys from pathlib import Path +# cargo-mutants 的「结果」类退出码;其余取值都意味着 mutants.out 不可信。 +RESULT_EXIT_CODES = (0, 2, 3) + def load_summary(out_dir: Path): outcomes = out_dir / "outcomes.json" @@ -41,11 +56,36 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--out-dir", type=Path, default=Path("mutants.out")) parser.add_argument("--threshold", type=float, default=90.0) + parser.add_argument( + "--cargo-mutants-exit-code", + type=int, + default=None, + help=( + "cargo mutants 自己的退出码;给出后,0/2/3 之外的取值直接判为工具故障" + "(默认不检查,只按报告判定)" + ), + ) args = parser.parse_args() + code = args.cargo_mutants_exit_code + if code is not None and code not in RESULT_EXIT_CODES: + print( + f"ERROR: cargo-mutants exited with {code}, which is not a mutation result " + f"(expected one of {RESULT_EXIT_CODES}); treating it as a tool failure", + file=sys.stderr, + ) + return 2 + try: summary = load_summary(args.out_dir) - except (FileNotFoundError, ValueError, json.JSONDecodeError) as exc: + except FileNotFoundError as exc: + # 没有变异体可测时 cargo-mutants 既不创建 mutants.out/ 也返回 0。 + if code == 0: + print("cargo-mutants reported no mutants to test (exit 0); nothing to gate. PASS") + return 0 + print(f"ERROR: cannot read mutants results: {exc}", file=sys.stderr) + return 2 + except (ValueError, json.JSONDecodeError) as exc: print(f"ERROR: cannot read mutants results: {exc}", file=sys.stderr) return 2 From a9a6c39f2a6074dec77424e1403c88877928c337 Mon Sep 17 00:00:00 2001 From: dynamder Date: Thu, 1 Oct 2026 17:03:53 +0800 Subject: [PATCH 24/30] =?UTF-8?q?refactor(soul-tune):=20bin=20=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=20lib=20=E7=9A=84=20mod=20=E6=A0=91=EF=BC=8C=E6=B6=88?= =?UTF-8?q?=E9=99=A4=E9=87=8D=E5=A4=8D=E5=8F=98=E5=BC=82=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 同一批源文件被 lib.rs 与 main.rs 两个 crate root 声明时,cargo-mutants 按 root 各扫一遍,engine 下每个变异体都生成并测试两遍(实测 249 报成 498) - main.rs 改为 use soul_tune::{base, engine},不再自己 mod base; mod engine; - 顺带删除 main.rs 的 allow(dead_code):bin 不再编译 engine 树,全量 clippy 零警告 - PR-diff job 里 soul-tune 变异体从 18 个降到 9 个(每个约 9 分钟),job 时长预计减半 --- crates/soul-tune/src/lib.rs | 4 +++- crates/soul-tune/src/main.rs | 14 ++++++++------ crates/soul-tune/src/tests_cli.rs | 2 +- crates/soul-tune/src/tests_playtest_mock.rs | 2 +- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/crates/soul-tune/src/lib.rs b/crates/soul-tune/src/lib.rs index a370598..35adbe5 100644 --- a/crates/soul-tune/src/lib.rs +++ b/crates/soul-tune/src/lib.rs @@ -1,6 +1,8 @@ //! soul-tune 库目标:暴露 headless 测试核心(engine + base)。 //! -//! 二进制目标(main.rs)保持独立的 `mod` 声明与 headless CLI,二者互不影响。 +//! 二进制目标(main.rs)是 headless CLI,直接复用本库的 `engine` / `base`, +//! 不再自己声明一份 mod 树:同一批源文件被两个 crate root 各声明一次, +//! 会让 cargo-mutants 把 engine/** 下的变异体生成并测试两遍。 //! 该库目标供 crates/soul-tune-api(FRB 桥接层)复用真实测试逻辑。 pub mod base; diff --git a/crates/soul-tune/src/main.rs b/crates/soul-tune/src/main.rs index ccd2a10..ebaeeb6 100644 --- a/crates/soul-tune/src/main.rs +++ b/crates/soul-tune/src/main.rs @@ -1,8 +1,10 @@ -// bin 是独立 headless CLI(与 lib 各自声明 mod 树):engine 树多数项仅供 -// lib 目标(soul-tune-api/UI)消费,bin 视角视为 dead,此处统一放行。 -#![allow(dead_code)] -mod base; -mod engine; +// bin 是 headless CLI,直接复用 lib 目标的 mod 树。 +// +// 这里刻意**不再**自己 `mod base; mod engine;`:同一批源文件若被 lib.rs 与 main.rs +// 两个 crate root 各声明一次,cargo-mutants 就会按 crate root 各扫一遍, +// engine/** 下的每个变异体都被生成并测试两遍(27.1.0 实测:249 个变异体报成 498 个), +// 既把杀灭率灌水,又白烧掉一半机时。 +use soul_tune::{base, engine}; #[cfg(test)] mod tests_cli; @@ -649,7 +651,7 @@ fn run_headless_playtest(args: &[String]) -> color_eyre::Result<()> { } // 统一模型来源解析:复用运行中的 llama-server → 自动拉起本地缓存模型 → 报错 - let resolution = crate::engine::llm::resolve_llm(); + let resolution = engine::llm::resolve_llm(); let (mut llm, model_desc) = match resolution.server { Some(s) => { let desc = resolution diff --git a/crates/soul-tune/src/tests_cli.rs b/crates/soul-tune/src/tests_cli.rs index 5f7d882..1794872 100644 --- a/crates/soul-tune/src/tests_cli.rs +++ b/crates/soul-tune/src/tests_cli.rs @@ -1,4 +1,4 @@ -use crate::base::{AlgoType, ForgetMode, RetrieveFlavor, RetrieveMode}; +use soul_tune::base::{AlgoType, ForgetMode, RetrieveFlavor, RetrieveMode}; #[test] fn test_algo_parsing_retrieve_embedding() { diff --git a/crates/soul-tune/src/tests_playtest_mock.rs b/crates/soul-tune/src/tests_playtest_mock.rs index e25c853..03b9297 100644 --- a/crates/soul-tune/src/tests_playtest_mock.rs +++ b/crates/soul-tune/src/tests_playtest_mock.rs @@ -1,4 +1,4 @@ -use crate::engine::llm::LlmBackend; +use soul_tune::engine::llm::LlmBackend; struct MockLlm { query_response: String, From c305d78df7f6af081a012afba559bbd98cd56da3 Mon Sep 17 00:00:00 2001 From: dynamder Date: Thu, 1 Oct 2026 17:04:09 +0800 Subject: [PATCH 25/30] =?UTF-8?q?test(algo):=20=E8=A1=A5=20ActionTypeSlots?= =?UTF-8?q?=20=E8=B4=AA=E5=BF=83=E4=B8=8E=20tiebreak=20=E7=9A=84=E9=A1=BA?= =?UTF-8?q?=E5=BA=8F=E6=97=A0=E5=85=B3=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 test_greedy_and_tiebreak_are_order_independent:每轮重建工作记忆采样 HashMap 迭代顺序,64 轮覆盖不同分取最高与全同分取最小 id - test_slots_are_one_per_action_type 补非空态断言,覆盖 is_empty 的 true 分支 - mutants.toml 记录 < 到 <= 为等价变异体:HashMap 键唯一,二者仅在两侧相等时有别;手工变异实测 10 个测试全过 - 修复前这组算子变异体只能靠当次哈希种子碰运气,现由新测试稳定杀灭 --- .cargo/mutants.toml | 17 +++ .../src/algo/retrieve/bayes_action.rs | 123 ++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 0f0b0b0..3968e97 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -43,6 +43,15 @@ exclude_re = [ # 对 f32/f64 与 F::zero() 同为 0.0。 # 现有断言 zero().into_inner() == 0.0 对两者都成立。 'UnitMeasure for OrdFloat>::zero', + # best_per_action_type 的 tiebreak `*id < current.id` 改成 `<=` 是等价的: + # `*id` 与 `current.id` 都是 `scores: &HashMap` 的键,而 `current` + # 由同一次遍历里更早的一个键插入,二者必不相等(HashMap 的键唯一,且 `MemoryId` + # 的 Eq/Ord/Hash 全部派生自内部的 `Uuid`);`<` 与 `<=` 只在两侧相等时才有区别。 + # 结论:没有测试能区分该变异体。 + # 同一位点的其余算子(`>` → `==` / `>=`、`<` → `==`)**不排除**:它们只在 + # 「遍历顺序」上与原实现不同,由 test_greedy_and_tiebreak_are_order_independent + # 反复重建 HashMap 来杀灭。 + 'replace < with <= in best_per_action_type', # ---------------------------------------------------------------- 调优参数 # similarity_fetch 的 ef = k * 2:HNSW 搜索广度调优参数(ef 越大召回越准越慢), @@ -112,4 +121,12 @@ exclude_re = [ # 把 `exclude_re` 设为 `["e"]`(匹配所有名字)仍会剩下恰好这些 24 行。 # 若那几个变异体确实杀不掉,应改用源码内的 `#[mutants::skip]` 标注, # 并把 `exclude_re` 的覆盖缺口反馈上游。 +# +# 本仓库当前唯一命中该缺口的**存活**变异体是 `crates/soul-tune/src/engine/retrieve/suite.rs` +# 里 `::run_case` 的 +# `delete field association from struct AssociateWithActionConfig expression`: +# 它删掉 `association: Default::default()`,而 `..Default::default()` 恰好给出 +# 同一个 `AssociationConfig::default()`(见 `AssociateWithActionConfig::default`), +# 属**等价变异体**,任何测试都无法区分。它被接受为一次固定 MISSED—— +# 90% 门槛留有余量,不值得为此给 test harness 引入 `mutants` crate 依赖。 # --------------------------------------------------------------------------- diff --git a/crates/soul-mem-algo/src/algo/retrieve/bayes_action.rs b/crates/soul-mem-algo/src/algo/retrieve/bayes_action.rs index e2620ee..533c8cf 100644 --- a/crates/soul-mem-algo/src/algo/retrieve/bayes_action.rs +++ b/crates/soul-mem-algo/src/algo/retrieve/bayes_action.rs @@ -480,6 +480,9 @@ mod tests { )); assert_eq!(result.len(), 2, "Speak 与 Think 各占一席"); + // 非空这一侧必须被观测到:只断言 `len()` 时,`is_empty()` 恒返回 true 也能存活 + // (`test_retr_bayes_action_empty_source` 只覆盖了空态那一侧)。 + assert!(!result.is_empty(), "两席被占用时 is_empty() 必须为 false"); assert_eq!(result.speak.as_ref().map(|s| s.id), Some(types[0].0)); assert_eq!(result.think.as_ref().map(|s| s.id), Some(types[1].0)); assert!( @@ -637,6 +640,126 @@ mod tests { assert_eq!(seat.id, candidates[0], "同分时应取 MemoryId 较小者"); } + /// 「一个情境源 + 若干**同类型**(Think)动作候选」的最小夹具。 + /// + /// 只有同类型才会争同一个席位;候选分数等于 `TrigToAction` 权重。 + struct ThinkCandidates { + wm: WorkingMemory, + source_id: MemoryId, + } + + /// 按 `(候选 id, 链接 `TrigToAction` 权重)` 造夹具;权重相同即同分。 + fn think_candidates(scored: &[(MemoryId, f64)]) -> ThinkCandidates { + let wm = WorkingMemory::new(10); + let cluster = wm.memory_cluster(); + let source_id = MemoryId::new(); + + cluster.write(|c| { + let links: Vec = scored + .iter() + .map(|(id, weight)| { + MemoryLink::new( + source_id, + *id, + MemoryLinkType::Proc(ProcMemLink::TrigToAction(TrigToAction::new(*weight))), + ) + }) + .collect(); + let source_note = MemoryNoteBuilder::new(MemoryType::Semantic(SemMemory { + content: "source".into(), + aliases: vec![], + concept_type: ConceptType::Entity, + description: String::new(), + })) + .id(source_id) + .mem_links(links) + .build() + .unwrap(); + let source_emb = MemoryEmbedding::new( + EmbeddingVec::zero(128), + MemoryEmbeddingVariant::Semantic(SemanticEmbedding::new( + EmbeddingVec::zero(128), + EmbeddingVec::zero(128), + EmbeddingVec::zero(128), + )), + ); + c.add_single_node(EmbeddedMemoryNote { + note: source_note, + embedding: source_emb, + }); + + for (id, _) in scored { + let note = MemoryNoteBuilder::new(MemoryType::Procedure(ProcMemory::new( + Action::new("Candidate".into(), ActionType::new_think()), + ))) + .id(*id) + .build() + .unwrap(); + c.add_single_node(EmbeddedMemoryNote { + note, + embedding: MemoryEmbedding::new( + EmbeddingVec::zero(128), + MemoryEmbeddingVariant::Procedure(), + ), + }); + } + }); + + ThinkCandidates { wm, source_id } + } + + /// 贪心比较与同分 tiebreak 的判定必须与 `scores` 的迭代顺序无关。 + /// + /// `best_per_action_type` 遍历的是一张 `HashMap`,其迭代顺序每次运行都不同;单次断言 + /// 只能靠运气命中「顺序敏感」的变异体——例如把 `*score > current.score` 改成 `==` 后, + /// 只有在最高分**恰好先被遍历到**时才不改变胜者,因此能否杀灭取决于当次哈希种子。 + /// 这里反复重建工作记忆(每轮都是全新的 `HashMap` 与新的哈希种子),把「靠运气通过」 + /// 的概率压到可忽略:两个场景各有 3 个候选,坏顺序恰好出现的概率约 1/3,64 轮后约 + /// 3^-64。 + #[test] + fn test_greedy_and_tiebreak_are_order_independent() { + const ROUNDS: usize = 64; + + for _ in 0..ROUNDS { + // (1) 分数不同:分数最高者必须胜出,且与其 id 大小无关 + let mut distinct = [MemoryId::new(), MemoryId::new(), MemoryId::new()]; + distinct.sort(); + let fixture = + think_candidates(&[(distinct[0], 0.3), (distinct[1], 0.5), (distinct[2], 0.7)]); + let source_id = fixture.source_id; + let winner = RetrBayesAction {} + .retrieve(BayesActionRequest::new( + Arc::new(fixture.wm), + vec![(source_id, 1.0)], + )) + .think + .expect("Think 席位应被占据") + .id; + assert_eq!( + winner, distinct[2], + "不同分时应取分数最高者,与遍历顺序无关" + ); + + // (2) 全同分:`MemoryId` 较小者必须胜出 + let mut tied = [MemoryId::new(), MemoryId::new(), MemoryId::new()]; + tied.sort(); + let fixture = think_candidates(&[(tied[0], 0.5), (tied[1], 0.5), (tied[2], 0.5)]); + let source_id = fixture.source_id; + let winner = RetrBayesAction {} + .retrieve(BayesActionRequest::new( + Arc::new(fixture.wm), + vec![(source_id, 1.0)], + )) + .think + .expect("Think 席位应被占据") + .id; + assert_eq!( + winner, tied[0], + "同分时应取 MemoryId 较小者,与遍历顺序无关" + ); + } + } + #[test] fn test_multi_source_action_aggregation() { let wm = WorkingMemory::new(10); From 1200368180511bb939b3f9a04aa74ba550ee1f9f Mon Sep 17 00:00:00 2001 From: dynamder Date: Thu, 1 Oct 2026 17:04:23 +0800 Subject: [PATCH 26/30] =?UTF-8?q?test(soul-tune):=20=E6=8A=BD=E5=87=BA?= =?UTF-8?q?=E5=8A=A8=E4=BD=9C=E5=88=86=E6=B5=81=E4=B8=8E=20PPR=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=9E=84=E9=80=A0=E6=88=90=E5=8F=AF=E6=B5=8B=E7=BA=AF?= =?UTF-8?q?=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - split_action_channels 与具名结构体 ActionChannels:proc_none 与 Skill 归行为通道的分派不再藏在完整管线里 - associate_with_action_config:PPR 阻尼、残差阈值、top_k 不再只是闭环里的一段字面量 - 两处抽取均为行为不变(原 match 与字面量逐行搬移),编译与 clippy 零警告 - 新增两个 hermetic 单测;手工变异验证 5 个变异体全部被杀 --- .../soul-tune/src/engine/playtest/runner.rs | 153 +++++++++++++++--- 1 file changed, 128 insertions(+), 25 deletions(-) diff --git a/crates/soul-tune/src/engine/playtest/runner.rs b/crates/soul-tune/src/engine/playtest/runner.rs index b6198b6..b36d10a 100644 --- a/crates/soul-tune/src/engine/playtest/runner.rs +++ b/crates/soul-tune/src/engine/playtest/runner.rs @@ -155,6 +155,61 @@ fn finish_merged(merged: HashMap) -> Vec AssociateWithActionConfig { + AssociateWithActionConfig { + association: AssociationConfig { + damping_factor: config.damping_factor, + residue_threshold: config.residue_threshold, + top_k: config.ppr_top_k, + ..Default::default() + }, + ..Default::default() + } +} + +/// `ActionType` 三通道分流结果。 +struct ActionChannels { + /// `ActionType::Speak` 席位(至多 1 条):语气 / 说话方式。 + speech: Vec, + /// `ActionType::Think` 席位且节点名不是 `proc_none`(至多 1 条):思维习惯。 + think: Vec, + /// 其余:`proc_none`(Think 类型但语义为"无动作"占位)与 Skill。 + behavior: Vec, +} + +/// 把算法层「每类型一席」的动作按 `ActionType` 拆成三条通道。 +/// +/// `proc_none` 在图里同样是 `Think` 类型,语义上却等同"没有采取特定行动", +/// 因此必须靠节点名把它挑出来;只按类型分流会把它混进思维习惯通道。 +fn split_action_channels( + graph_names: &HashMap, + seated_actions: Vec<(ActionType, TracedNode)>, +) -> ActionChannels { + let mut channels = ActionChannels { + speech: Vec::new(), + think: Vec::new(), + behavior: Vec::new(), + }; + for (action_type, node) in seated_actions { + match action_type { + ActionType::Speak => channels.speech.push(node), + ActionType::Think + if graph_names.get(&node.id).map(|s| s.as_str()) != Some("proc_none") => + { + channels.think.push(node) + } + // proc_none(Think 类型但是"无动作"占位)与 Skill 都归行为通道 + _ => channels.behavior.push(node), + } + } + channels +} + /// 一条已完成生成后校验的查询:嵌入结果随查询缓存,检索阶段直接复用,避免二次嵌入。 /// `dropped = true` 表示校验未通过(top-1 低于兜底分或嵌入失败),仅保留用于 trace 标记。 #[derive(Debug, Clone)] @@ -1027,15 +1082,7 @@ impl PlayTestRunner { .collect(); let ppr_start = Instant::now(); - let aa_config = AssociateWithActionConfig { - association: AssociationConfig { - damping_factor: self.config.damping_factor, - residue_threshold: self.config.residue_threshold, - top_k: self.config.ppr_top_k, - ..Default::default() - }, - ..Default::default() - }; + let aa_config = associate_with_action_config(&self.config); let aa_req = aa_config.into_request( self.wm.clone(), sim_nodes.iter().map(|n| (n.id, n.score as f32)).collect(), @@ -1128,22 +1175,11 @@ impl PlayTestRunner { // 按 ActionType 分流:Speak→语气、Think(非 proc_none)→思维习惯、 // 其余(含 proc_none)→行为通道 - let mut speech = Vec::new(); - let mut think = Vec::new(); - let mut behavior = Vec::new(); - for (action_type, node) in seated_actions { - match action_type { - ActionType::Speak => speech.push(node), - ActionType::Think - if self.graph_names.get(&node.id).map(|s| s.as_str()) - != Some("proc_none") => - { - think.push(node) - } - // proc_none(Think 类型但是"无动作"占位)与 Skill 都归行为通道 - _ => behavior.push(node), - } - } + let ActionChannels { + speech, + think, + behavior, + } = split_action_channels(&self.graph_names, seated_actions); let mut merged: Vec = Vec::new(); for n in sim_nodes.into_iter().chain(ppr_nodes) { @@ -1556,4 +1592,71 @@ mod tests { "动作内容应来自 proc 节点" ); } + + #[test] + fn test_associate_with_action_config_carries_ppr_params() { + let config = PlayConfig { + ppr_top_k: 3, + damping_factor: 0.2, + residue_threshold: 0.5, + ..PlayConfig::default() + }; + let aa = associate_with_action_config(&config); + + assert_eq!( + aa.association.damping_factor, 0.2, + "阻尼必须来自 PlayConfig" + ); + assert_eq!( + aa.association.residue_threshold, 0.5, + "残差阈值必须来自 PlayConfig" + ); + assert_eq!(aa.association.top_k, 3, "PPR top_k 必须来自 PlayConfig"); + } + + #[test] + fn test_split_action_channels_routes_by_type_and_proc_none() { + use soul_mem_core::memory_note::proc_mem::SkillRecord; + + let speak = MemoryId::new(); + let habit = MemoryId::new(); + let none = MemoryId::new(); + let skill = MemoryId::new(); + let names: HashMap = HashMap::from([ + (speak, "proc_greet".to_string()), + (habit, "proc_learn_from_movies".to_string()), + (none, "proc_none".to_string()), + (skill, "proc_skill".to_string()), + ]); + let node = |id: MemoryId| TracedNode { + id, + name: names.get(&id).cloned().unwrap_or_default(), + content: "content".to_string(), + score: 1.0, + stage: HitStage::Action, + }; + + let channels = split_action_channels( + &names, + vec![ + (ActionType::new_speak(), node(speak)), + (ActionType::new_think(), node(habit)), + (ActionType::new_think(), node(none)), + (ActionType::new_skill(SkillRecord {}), node(skill)), + ], + ); + let ids = |nodes: &[TracedNode]| nodes.iter().map(|n| n.id).collect::>(); + + assert_eq!(ids(&channels.speech), vec![speak], "Speak 应进语气通道"); + assert_eq!( + ids(&channels.think), + vec![habit], + "只有非 proc_none 的 Think 应进思维习惯通道" + ); + assert_eq!( + ids(&channels.behavior), + vec![none, skill], + "proc_none 与 Skill 应进行为通道" + ); + } } From b3a5a125b3f5d75d0d89932810bfc05ce9ac4526 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 08:01:16 +0000 Subject: [PATCH 27/30] chore(deps): bump paw-rs from 0.2.2 to 0.3.0 Bumps [paw-rs](https://github.com/dynamder/paw-rs) from 0.2.2 to 0.3.0. - [Changelog](https://github.com/dynamder/paw-rs/blob/main/release.toml) - [Commits](https://github.com/dynamder/paw-rs/compare/v0.2.2...v0.3.0) --- updated-dependencies: - dependency-name: paw-rs dependency-version: 0.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Cargo.lock | 42 ++++++++++++++++++------------------- crates/soul-tune/Cargo.toml | 2 +- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4ba3c22..13fd996 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -242,7 +242,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -253,7 +253,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1313,7 +1313,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1993,7 +1993,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2375,7 +2375,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4134,7 +4134,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4975,7 +4975,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5451,9 +5451,9 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "paw-core" -version = "0.2.2" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b92c9a46281392b74e18910810c3cedc14b64077d73cf29d1ad25c2819212668" +checksum = "0e3816f07541018365cc570831278ab9fab446fc0e2168d2fa4c173f15fa37f2" dependencies = [ "dirs", "reqwest 0.12.28", @@ -5467,9 +5467,9 @@ dependencies = [ [[package]] name = "paw-llamacpp" -version = "0.2.2" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df6382967fe930f6ff987f09a8971971689db3470c4d9d897ad10354b5b5a87f" +checksum = "dff584f9b3a0128957dabbe6a38adf594f96fe1effff6b0e16a2fb18b3903502" dependencies = [ "encoding_rs", "llama-cpp-2", @@ -5478,9 +5478,9 @@ dependencies = [ [[package]] name = "paw-rs" -version = "0.2.2" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872d145d24151d904990b6dff13511c93400ffa388623a1639a7e56d2ea58a77" +checksum = "daaba5cb54617934a395bb325241bc157aa60dd488fda53a69e5158af7332058" dependencies = [ "clap", "hf-hub 1.0.0", @@ -5959,7 +5959,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.11.0", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -6850,7 +6850,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6909,7 +6909,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7085,7 +7085,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7353,7 +7353,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8128,10 +8128,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9414,7 +9414,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/soul-tune/Cargo.toml b/crates/soul-tune/Cargo.toml index 305c0df..0009bcc 100644 --- a/crates/soul-tune/Cargo.toml +++ b/crates/soul-tune/Cargo.toml @@ -21,7 +21,7 @@ candle-core = { workspace = true, optional = true } candle-nn = { workspace = true, optional = true } candle-transformers = { workspace = true, optional = true } reqwest = { version = "0.12", default-features = false, features = ["blocking", "json"] } -paw-rs = { version = "0.2", default-features = false, features = ["llamacpp"] } +paw-rs = { version = "0.3", default-features = false, features = ["llamacpp"] } tokio.workspace = true dotenvy = "0.15.7" chrono.workspace = true From a1dc44b1e8dac221805a0d856fef41cb9636765a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:05:01 +0000 Subject: [PATCH 28/30] chore(deps): bump chrono from 0.4.44 to 0.4.45 Bumps [chrono](https://github.com/chronotope/chrono) from 0.4.44 to 0.4.45. - [Release notes](https://github.com/chronotope/chrono/releases) - [Changelog](https://github.com/chronotope/chrono/blob/main/CHANGELOG.md) - [Commits](https://github.com/chronotope/chrono/compare/v0.4.44...v0.4.45) --- updated-dependencies: - dependency-name: chrono dependency-version: 0.4.45 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4ba3c22..072e405 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1153,9 +1153,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -3716,7 +3716,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -8128,7 +8128,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.52.0", From 9c1b63d7e8519a72c0cd02ee1371b5e819303d04 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:05:27 +0000 Subject: [PATCH 29/30] chore(deps): bump tokenizers from 0.22.2 to 0.23.2 Bumps [tokenizers](https://github.com/huggingface/tokenizers) from 0.22.2 to 0.23.2. - [Release notes](https://github.com/huggingface/tokenizers/releases) - [Commits](https://github.com/huggingface/tokenizers/compare/v0.22.2...v0.23.2) --- updated-dependencies: - dependency-name: tokenizers dependency-version: 0.23.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Cargo.lock | 71 ++++++++++++++++++++++++++++--------- crates/soul-tune/Cargo.toml | 2 +- 2 files changed, 56 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4ba3c22..93686fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -242,7 +242,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -253,7 +253,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1313,7 +1313,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1713,6 +1713,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "daachorse" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5614204febbc33cc07a2806aa6440b904ac012b68eecc37f4493ea4a76455a3d" + [[package]] name = "darling" version = "0.20.11" @@ -1993,7 +1999,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2375,7 +2381,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4134,7 +4140,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4975,7 +4981,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5959,7 +5965,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.11.0", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -6850,7 +6856,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6909,7 +6915,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7085,7 +7091,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7353,7 +7359,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7502,7 +7508,7 @@ dependencies = [ "soul-mem-query", "soul-mem-runtime", "tempfile", - "tokenizers 0.22.2", + "tokenizers 0.23.2", "tokio", ] @@ -8128,10 +8134,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8428,6 +8434,39 @@ dependencies = [ "esaxx-rs", "getrandom 0.3.4", "hf-hub 0.4.3", + "itertools 0.14.0", + "log", + "macro_rules_attribute", + "monostate", + "onig", + "paste", + "rand 0.9.4", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.20", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + +[[package]] +name = "tokenizers" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7afbf6e88718afcc138bad01d6ccc3051dbbc3b2ce9793d8b8a3aeb610969cfc" +dependencies = [ + "ahash 0.8.12", + "compact_str", + "daachorse", + "dary_heap", + "derive_builder", + "esaxx-rs", + "getrandom 0.3.4", "indicatif 0.18.4", "itertools 0.14.0", "log", @@ -9414,7 +9453,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/soul-tune/Cargo.toml b/crates/soul-tune/Cargo.toml index 305c0df..dd6879f 100644 --- a/crates/soul-tune/Cargo.toml +++ b/crates/soul-tune/Cargo.toml @@ -26,7 +26,7 @@ tokio.workspace = true dotenvy = "0.15.7" chrono.workspace = true jieba-rs.workspace = true -tokenizers = { version = "0.22", optional = true } +tokenizers = { version = "0.23", optional = true } [features] default = ["llamacpp"] From 0c1449d4ed8988d3d26cb594b4f968d03308dcad Mon Sep 17 00:00:00 2001 From: dynamder Date: Tue, 6 Oct 2026 22:31:05 +0800 Subject: [PATCH 30/30] =?UTF-8?q?fix(storage):=20=E5=87=BA=E8=BE=B9?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=94=B9=E4=B8=BA=E5=B7=AE=E5=88=86=E5=88=A0?= =?UTF-8?q?=E9=99=A4=EF=BC=8C=E9=81=BF=E5=85=8D=E5=85=A8=E9=87=8F=E9=87=8D?= =?UTF-8?q?=E5=BB=BA=E4=B8=A2=E8=BE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - write_one 只删不在本次 payload 里的旧出边,payload 内的边就地 upsert 覆盖 - 先全删再按同一 record id 重建会撞上 SurrealDB 3.2.4 同事务内删后重建静默丢行, 使 fetch → 改 → upsert 这条 read-modify-write 路径每写一次丢一批边 - 语义不变:payload 的 links 仍是该节点出边的完整真相源,空 links 即清空出边 - 补 4 条回归测试:同 id 边重声明、部分字段更新、差分删除、空出边只清自己 --- .../src/storage/surreal/repository.rs | 168 +++++++++++++++++- 1 file changed, 162 insertions(+), 6 deletions(-) diff --git a/crates/soul-mem-runtime/src/storage/surreal/repository.rs b/crates/soul-mem-runtime/src/storage/surreal/repository.rs index fb20a83..f91a0f4 100644 --- a/crates/soul-mem-runtime/src/storage/surreal/repository.rs +++ b/crates/soul-mem-runtime/src/storage/surreal/repository.rs @@ -2,7 +2,8 @@ //! //! - 连接:嵌入式 SurrealKv(`connect`,生产)或 Mem(`connect_mem`,测试)。 //! - Schema:`include_str!("schema.surql")` 幂等初始化。 -//! - 写:`split_embedded` → 事务内全量 CONTENT 替换 note 行 + 重建出边(先删后写)。 +//! - 写:`split_embedded` → 事务内全量 CONTENT 替换 note 行 + 差分同步出边 +//! (只删 payload 里没有的旧边,其余就地覆盖;不能全删后按同 record id 写回,见 `write_one`)。 //! 不用 MERGE——深合并会残留外部标签 enum 的旧变体键(详见 `MemoryRepository::upsert_notes`)。 //! - 读:`memory_id` 过滤取回 NoteRow,`variant_emb` 还原嵌入,`memory_link` 边合并链接。 //! - 召回:查询嵌入 flatten 成槽位 → 每槽位 `<|k, EF|>` KNN → union 候选 → fetch_notes @@ -84,7 +85,10 @@ impl SurrealRepository { } } - /// 事务内写一条:note 行(SDK content 全量替换)+ 重建出边(先删旧再写新)。 + /// 事务内写一条:note 行(SDK content 全量替换)+ 差分同步出边。 + /// + /// 出边的语义仍是「payload 的 `note.links` 是该节点出边的完整真相源」; + /// 变化只在实现:只删 payload 里没有的旧边,payload 内的边就地覆盖(原因见函数内注释)。 async fn write_one( txn: &surrealdb::method::Transaction, embedded: EmbeddedMemoryNote, @@ -104,11 +108,24 @@ impl SurrealRepository { txn.upsert::>(("memory_note", note_key.as_str())) .content(note_sur_value) .await?; - // 重建出边:先删旧(in = 本 note),再写新(按端点删边无 SDK 等价,手写 query)。 - // 语义:边表始终以 payload 的 note.links 为完整真相源做全量同步; - // payload 的 links 即该 note 的完整边集(links 为空即表达「无出边」,同步后清空,非数据丢失)。 - txn.query("DELETE memory_link WHERE `in` = $rid") + // 差分同步出边(按端点删边无 SDK 等价,手写 query): + // 只删「本次 payload 里没有的」旧边,payload 内的边走下面的 upsert 就地覆盖。 + // 语义不变:payload 的 links 仍是该 note 的完整边集(links 为空即表达「无出边」, + // 差分删除后自然清空,非数据丢失)。 + // + // 为什么不是「先按 `in` 全删、再把 payload 逐条 upsert 回来」:SurrealDB 3.2.4 + // 在同一事务内删除某记录、随后以**同一 record id** 重建时,提交后该记录不存在 + // 且不报错——实测 UPSERT 与 CREATE 同样丢,加不加 `link_id` 唯一索引同样丢, + // 按记录 id 删也一样;只有「不删」「删后写不同 id」「删与写分属两个事务」三种 + // 对照形态正常。全删再写回于是让「同一条边被再次声明」直接消失: + // fetch → 改 → upsert 这条文档明确支持的 read-modify-write 路径每写一次就 + // 丢一批边(回归测试见本文件 `upsert_rewrite_keeps_declared_links` 等)。 + // 差分删除避开该行为,同时保持单事务原子性,并保住 LinkId 身份与边自身 + // 累积的遗忘状态(重建 LinkId 会把 intensity/missing_degree 清零)。 + let keep: Vec = link_sdks.iter().map(|(key, _)| key.clone()).collect(); + txn.query("DELETE memory_link WHERE `in` = $rid AND link_id NOT IN $keep") .bind(("rid", note_rid)) + .bind(("keep", keep)) .await?; for (lr_key, lr_sdk) in &link_sdks { txn.upsert::>(("memory_link", lr_key.as_str())) @@ -504,6 +521,19 @@ mod tests { r } + /// `memory_link` 里 `in = ` 的行数——直接看边表真相,绕开 `fetch_notes` 的边合并, + /// 免得「边确实没了」和「合并逻辑没捞到」混为一谈。 + async fn raw_out_link_count(repo: &SurrealRepository, id: MemoryId) -> usize { + let mut res = repo + .db + .query("SELECT VALUE id FROM memory_link WHERE `in` = $rid") + .bind(("rid", id.to_record_id())) + .await + .unwrap(); + let rows: Vec = res.take(0).unwrap(); + rows.len() + } + #[tokio::test] async fn upsert_fetch_roundtrip_semantic() { let repo = repo().await; @@ -539,6 +569,132 @@ mod tests { assert_eq!(links[0].to(), b_id); } + /// 回归:整簇写回时,同一个 LinkId 的边被再次声明必须仍在。 + /// + /// `write_one` 曾「先按 `in` 全删、再按同一 record id upsert 回来」,撞上 SurrealDB + /// 「同一事务内删后重建同一 record id 会静默丢行」,于是 fetch → 原样写回就整批丢边。 + #[tokio::test] + async fn upsert_rewrite_keeps_declared_links() { + let repo = repo().await; + let a = sem_note(0.3, 1.0); + let b = sem_note(0.2, 0.5); + let a_id = a.note().id(); + let b_id = b.note().id(); + let (a, _link_id) = with_link(a, b_id); + + repo.upsert_notes(vec![a, b]).await.unwrap(); + assert_eq!(raw_out_link_count(&repo, a_id).await, 1, "前置:边已写入"); + + // fetch → 原样写回:存储层文档承诺的 read-modify-write 最低要求 + let fetched = repo.fetch_notes(&[a_id]).await.unwrap(); + assert_eq!(fetched[0].note().links().len(), 1, "前置:读回带边"); + repo.upsert_notes(fetched).await.unwrap(); + + assert_eq!( + raw_out_link_count(&repo, a_id).await, + 1, + "同 id 的边被再次声明后必须仍在" + ); + assert_eq!( + repo.fetch_notes(&[a_id]).await.unwrap()[0] + .note() + .links() + .len(), + 1 + ); + } + + /// 回归:只改 note 的非边字段再写回,出边不能被连坐清掉。 + #[tokio::test] + async fn upsert_partial_update_keeps_links() { + let repo = repo().await; + let a = sem_note(0.3, 1.0); + let b = sem_note(0.2, 0.5); + let a_id = a.note().id(); + let b_id = b.note().id(); + let (a, _link_id) = with_link(a, b_id); + repo.upsert_notes(vec![a, b]).await.unwrap(); + + let mut fetched = repo.fetch_notes(&[a_id]).await.unwrap().remove(0); + fetched.note.retrieval_increment(); + fetched.note.set_missing_degree(0.42); + repo.upsert_notes(vec![fetched]).await.unwrap(); + + let again = repo.fetch_notes(&[a_id]).await.unwrap(); + assert_eq!(again[0].note().missing_degree(), 0.42, "覆盖字段生效"); + assert_eq!(again[0].note().links().len(), 1, "部分字段更新不得丢边"); + assert_eq!(raw_out_link_count(&repo, a_id).await, 1); + } + + /// 差分删除:payload 里没有的出边要被清掉,payload 里的必须留下(且身份不变)。 + #[tokio::test] + async fn upsert_removes_only_links_absent_from_payload() { + let repo = repo().await; + let a = sem_note(0.3, 1.0); + let b = sem_note(0.2, 0.5); + let c = sem_note(0.1, 0.9); + let a_id = a.note().id(); + let b_id = b.note().id(); + let c_id = c.note().id(); + let (a, link_ab) = with_link(a, b_id); + let (a, link_ac) = with_link(a, c_id); + repo.upsert_notes(vec![a, b, c]).await.unwrap(); + assert_eq!(raw_out_link_count(&repo, a_id).await, 2, "前置:两条出边"); + + // fetch → 只摘掉 a→c → 写回 + let mut fetched = repo.fetch_notes(&[a_id]).await.unwrap().remove(0); + fetched.note.links_mut().retain(|l| l.id() != link_ac); + repo.upsert_notes(vec![fetched]).await.unwrap(); + + let links = repo.fetch_notes(&[a_id]).await.unwrap()[0] + .note() + .links() + .clone(); + assert_eq!(links.len(), 1, "被摘掉的边应被差分删除"); + assert_eq!(links[0].id(), link_ab, "留下的边必须保持原 LinkId"); + assert_eq!(raw_out_link_count(&repo, a_id).await, 1); + } + + /// 空出边即表达「无出边」:清空自己的出边,但不得连坐别的节点的边。 + #[tokio::test] + async fn upsert_empty_links_clears_own_edges_only() { + let repo = repo().await; + let a = sem_note(0.3, 1.0); + let b = sem_note(0.2, 0.5); + let c = sem_note(0.1, 0.9); + let a_id = a.note().id(); + let b_id = b.note().id(); + let c_id = c.note().id(); + let (a, _link_ab) = with_link(a, b_id); + let (b, _link_bc) = with_link(b, c_id); + repo.upsert_notes(vec![a, b, c]).await.unwrap(); + assert_eq!(raw_out_link_count(&repo, a_id).await, 1); + assert_eq!(raw_out_link_count(&repo, b_id).await, 1); + + // 同 id 重建一个无出边的 a(其余字段照抄),写回 + repo.upsert_notes(vec![rebuild_with_id(sem_note(0.3, 1.0), a_id)]) + .await + .unwrap(); + + assert_eq!( + raw_out_link_count(&repo, a_id).await, + 0, + "payload 无出边即同步为空" + ); + assert_eq!( + raw_out_link_count(&repo, b_id).await, + 1, + "不得连坐其它节点的出边" + ); + assert_eq!( + repo.fetch_notes(&[a_id]).await.unwrap()[0] + .note() + .links() + .len(), + 0 + ); + } + #[tokio::test] async fn fetch_neighbors_breadth() { let repo = repo().await;