mirror of
https://github.com/ruvnet/RuVector.git
synced 2026-10-20 19:39:01 +00:00
fix(python): resume missing immutable portfolio artifacts safely (#1205)
* fix(python): recover only missing immutable portfolio release artifacts * docs(python): record tested public tutorial gist and immutable revision * fix: unblock Python recovery with targeted dependency and CI repairs * fix: patch Quinn advisories and preserve frozen inventory on Windows * ci: verify release inventory checkout policy changes * fix: replace unmaintained YAML parser and stabilize serving acceptance
This commit is contained in:
parent
31549f7989
commit
25ed48e9e3
32 changed files with 2376 additions and 83 deletions
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Preserve the byte-pinned release inventory on every checkout.
|
||||
python/portfolio/frozen-release-0.2.0.json text eol=lf
|
||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
|
|
@ -140,7 +140,8 @@ jobs:
|
|||
# faster test discovery and execution.
|
||||
test:
|
||||
name: Tests (${{ matrix.name }})
|
||||
runs-on: ubuntu-22.04
|
||||
# ORT prebuilt binaries require glibc 2.38+ and GCC 13 libstdc++.
|
||||
runs-on: ${{ matrix.name == 'core-and-rest' && 'ubuntu-24.04' || 'ubuntu-22.04' }}
|
||||
# `core-and-rest` is the catch-all shard and compiles ~50 crates; on a
|
||||
# cold cache the build alone has hit ~90min, so headroom matters more
|
||||
# than tight feedback for this job. Faster shards still finish in ~10–20m.
|
||||
|
|
|
|||
220
.github/workflows/python-portfolio-recovery.yml
vendored
Normal file
220
.github/workflows/python-portfolio-recovery.yml
vendored
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
name: Python portfolio recovery
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.gitattributes'
|
||||
- '.github/workflows/python-portfolio-recovery.yml'
|
||||
- 'python/portfolio/resume_release.py'
|
||||
- 'python/portfolio/frozen-release-0.2.0.json'
|
||||
- 'python/portfolio/tests/test_resume_release.py'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish:
|
||||
description: 'Resume missing original 0.2.0 files after owner review; stop on any failure'
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: python-portfolio-recovery-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
contract:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- run: python -m pip install pytest==9.1.1
|
||||
- run: python -m pytest python/portfolio/tests/test_resume_release.py -q
|
||||
prepare:
|
||||
if: github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')
|
||||
needs: contract
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
ref: fccf36e9a76d35bce6750dfd3b25802cae3d683b
|
||||
path: frozen-source
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Check original source, artifact and successful build gates
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import json, subprocess
|
||||
def api(path):
|
||||
return json.loads(subprocess.check_output(['gh', 'api', 'repos/ruvnet/RuVector/' + path]))
|
||||
r = api('actions/runs/38169271248')
|
||||
a = api('actions/artifacts/11713787141')
|
||||
assert r['head_sha'] == 'fccf36e9a76d35bce6750dfd3b25802cae3d683b'
|
||||
assert r['event'] == 'push' and r['path'] == '.github/workflows/python-portfolio.yml'
|
||||
assert r['status'] == 'completed'
|
||||
assert a['name'] == 'portfolio-dist' and not a['expired']
|
||||
assert a['workflow_run']['id'] == r['id'] and a['workflow_run']['head_sha'] == r['head_sha']
|
||||
assert a['digest'] == 'sha256:b4c92cae86c753305abef7aad395fdd9d91c7e84c3c887342defa7a3e4607241'
|
||||
jobs = api('actions/runs/38169271248/jobs?per_page=100')
|
||||
assert jobs['total_count'] == len(jobs['jobs'])
|
||||
for name in ('test-build', 'native-audit'):
|
||||
selected = [j for j in jobs['jobs'] if j['name'] == name]
|
||||
assert len(selected) == 1 and selected[0]['conclusion'] == 'success'
|
||||
print('Original artifact identity and build gates verified')
|
||||
PY
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
artifact-ids: '11713787141'
|
||||
merge-multiple: true
|
||||
github-token: ${{ github.token }}
|
||||
run-id: '38169271248'
|
||||
repository: ruvnet/RuVector
|
||||
path: original-dist
|
||||
- name: Compare all exact files before selecting missing originals
|
||||
run: python python/portfolio/resume_release.py --dist original-dist --inventory python/portfolio/frozen-release-0.2.0.json --missing-dir missing-dist --receipt preflight.json
|
||||
- name: Install original wheels and run frozen contracts and README examples
|
||||
shell: bash
|
||||
run: |
|
||||
python -m venv consumer
|
||||
if [[ "$RUNNER_OS" == Windows ]]; then
|
||||
PY=consumer/Scripts/python.exe
|
||||
else
|
||||
PY=consumer/bin/python
|
||||
fi
|
||||
"$PY" -m pip install --upgrade pip==26.2.1
|
||||
"$PY" -m pip install --index-url https://pypi.org/simple --only-binary=:all: original-dist/*.whl pytest==9.1.1 pip-audit==2.10.1
|
||||
"$PY" -c 'import importlib.metadata as m; assert m.version("ruvector") == "0.1.3"'
|
||||
"$PY" -m pytest frozen-source/python/portfolio/tests -q
|
||||
"$PY" frozen-source/python/portfolio/check_examples.py
|
||||
"$PY" -m pip_audit --local
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
if: always()
|
||||
with:
|
||||
name: recovery-preflight-${{ matrix.os }}
|
||||
path: preflight.json
|
||||
if-no-files-found: ignore
|
||||
frozen-native-audit:
|
||||
if: github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')
|
||||
needs: contract
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
ref: fccf36e9a76d35bce6750dfd3b25802cae3d683b
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
|
||||
with:
|
||||
toolchain: stable
|
||||
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- run: cargo install cargo-audit --version 0.22.2 --locked
|
||||
- run: python python/portfolio/audit_native.py /tmp/frozen-native.lock
|
||||
|
||||
publish:
|
||||
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && inputs.publish
|
||||
needs: [prepare, frozen-native-audit]
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi
|
||||
concurrency:
|
||||
group: python-portfolio-pypi
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
id-token: write
|
||||
env:
|
||||
HAS_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN != '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
artifact-ids: '11713787141'
|
||||
merge-multiple: true
|
||||
github-token: ${{ github.token }}
|
||||
run-id: '38169271248'
|
||||
repository: ruvnet/RuVector
|
||||
path: original-dist
|
||||
- name: Reconcile registry again under the publication lock
|
||||
id: select
|
||||
run: |
|
||||
python python/portfolio/resume_release.py --dist original-dist --inventory python/portfolio/frozen-release-0.2.0.json --missing-dir missing-dist --receipt preflight.json
|
||||
python - <<'PY'
|
||||
import json, os
|
||||
d = json.load(open('preflight.json'))
|
||||
with open(os.environ['GITHUB_OUTPUT'], 'a') as out:
|
||||
out.write('missing=' + str(len(d['missing'])) + '\n')
|
||||
PY
|
||||
- name: Publish missing originals using Trusted Publishing
|
||||
if: steps.select.outputs.missing != '0' && env.HAS_PYPI_TOKEN != 'true'
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33
|
||||
with:
|
||||
packages-dir: missing-dist/
|
||||
attestations: true
|
||||
- name: Publish missing originals using the configured token
|
||||
if: steps.select.outputs.missing != '0' && env.HAS_PYPI_TOKEN == 'true'
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33
|
||||
with:
|
||||
packages-dir: missing-dist/
|
||||
user: __token__
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
attestations: false
|
||||
- name: Verify every expected file after upload
|
||||
run: python python/portfolio/resume_release.py --dist original-dist --inventory python/portfolio/frozen-release-0.2.0.json --verify-only --receipt published.json
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
if: always()
|
||||
with:
|
||||
name: recovery-publication-receipts
|
||||
path: |
|
||||
preflight.json
|
||||
published.json
|
||||
if-no-files-found: ignore
|
||||
verify-published:
|
||||
needs: publish
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
ref: fccf36e9a76d35bce6750dfd3b25802cae3d683b
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Fresh PyPI-only installation and frozen acceptance
|
||||
shell: bash
|
||||
run: |
|
||||
python -m venv consumer
|
||||
if [[ "$RUNNER_OS" == Windows ]]; then
|
||||
PY=consumer/Scripts/python.exe
|
||||
else
|
||||
PY=consumer/bin/python
|
||||
fi
|
||||
python -c 'import json,pathlib; m=json.loads(pathlib.Path("python/portfolio/manifest.json").read_text()); pathlib.Path("requirements.txt").write_text("\n".join(p+"=="+m["version"] for p in m["packages"])+"\n")'
|
||||
"$PY" -m pip install --index-url https://pypi.org/simple --only-binary=:all: --no-cache-dir -r requirements.txt pytest==9.1.1
|
||||
"$PY" -c 'import importlib.metadata as m; assert m.version("ruvector") == "0.1.3"'
|
||||
"$PY" -m pytest python/portfolio/tests -q
|
||||
"$PY" python/portfolio/check_examples.py
|
||||
3
.github/workflows/python-portfolio.yml
vendored
3
.github/workflows/python-portfolio.yml
vendored
|
|
@ -92,6 +92,9 @@ jobs:
|
|||
- run: cargo install cargo-audit --version 0.22.2 --locked
|
||||
- run: python python/portfolio/audit_native.py /tmp/portfolio-native.lock
|
||||
publish:
|
||||
concurrency:
|
||||
group: python-portfolio-pypi
|
||||
cancel-in-progress: false
|
||||
needs: [test-build, native-audit]
|
||||
if: needs.test-build.outputs.release == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
81
Cargo.lock
generated
81
Cargo.lock
generated
|
|
@ -159,6 +159,16 @@ dependencies = [
|
|||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotate-snippets"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96401ca08501972288ecbcde33902fce858bf73fbcbdf91dab8c3a9544e106bb"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"unicode-width 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.21"
|
||||
|
|
@ -274,6 +284,12 @@ dependencies = [
|
|||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arraydeque"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
|
|
@ -3968,6 +3984,16 @@ dependencies = [
|
|||
"bitflags 2.13.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "granit-parser"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e20f99e46474f56bd905c56e817ebddcf377a611f94c53ac4649e4d3fa3c0cd0"
|
||||
dependencies = [
|
||||
"arraydeque",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grep-matcher"
|
||||
version = "0.1.8"
|
||||
|
|
@ -8085,14 +8111,15 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.15"
|
||||
version = "0.11.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
|
||||
checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand 0.9.4",
|
||||
"rand 0.10.1",
|
||||
"rand_pcg 0.10.2",
|
||||
"ring",
|
||||
"rustc-hash 2.1.2",
|
||||
"rustls",
|
||||
|
|
@ -8172,7 +8199,7 @@ dependencies = [
|
|||
"rand_isaac",
|
||||
"rand_jitter",
|
||||
"rand_os",
|
||||
"rand_pcg",
|
||||
"rand_pcg 0.1.2",
|
||||
"rand_xorshift 0.1.1",
|
||||
"winapi",
|
||||
]
|
||||
|
|
@ -8361,6 +8388,15 @@ dependencies = [
|
|||
"rand_core 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_xorshift"
|
||||
version = "0.1.1"
|
||||
|
|
@ -12064,8 +12100,8 @@ dependencies = [
|
|||
"rvagent-backends",
|
||||
"rvagent-core",
|
||||
"serde",
|
||||
"serde-saphyr",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha3",
|
||||
"smallvec",
|
||||
"tempfile",
|
||||
|
|
@ -12417,6 +12453,20 @@ dependencies = [
|
|||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde-saphyr"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8050abb251097357e24aff63ba2c52a6309ecb7d23a5474023df960a02694d8"
|
||||
dependencies = [
|
||||
"annotate-snippets",
|
||||
"encoding_rs_io",
|
||||
"granit-parser",
|
||||
"num-traits",
|
||||
"serde_core",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde-wasm-bindgen"
|
||||
version = "0.6.5"
|
||||
|
|
@ -12512,19 +12562,6 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap 2.12.1",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "seti-boundary-discovery"
|
||||
version = "0.1.0"
|
||||
|
|
@ -14572,12 +14609,6 @@ dependencies = [
|
|||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
|
|
|||
|
|
@ -217,17 +217,19 @@ impl ReplicaSet {
|
|||
|
||||
/// Promote a secondary to primary
|
||||
pub fn promote_to_primary(&mut self, id: &str) -> Result<()> {
|
||||
// Get the replica and verify it exists
|
||||
let mut replica = self
|
||||
// Release the shard guard before accessing the old primary: both
|
||||
// keys can share a shard and nested access would deadlock.
|
||||
let role = self
|
||||
.replicas
|
||||
.get_mut(id)
|
||||
.ok_or_else(|| ReplicationError::ReplicaNotFound(id.to_string()))?;
|
||||
.get(id)
|
||||
.ok_or_else(|| ReplicationError::ReplicaNotFound(id.to_string()))?
|
||||
.role;
|
||||
|
||||
if replica.role == ReplicaRole::Primary {
|
||||
if role == ReplicaRole::Primary {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if replica.role == ReplicaRole::Witness {
|
||||
if role == ReplicaRole::Witness {
|
||||
return Err(ReplicationError::InvalidState(
|
||||
"Cannot promote witness to primary".to_string(),
|
||||
));
|
||||
|
|
@ -245,8 +247,11 @@ impl ReplicaSet {
|
|||
}
|
||||
}
|
||||
|
||||
// Promote new primary
|
||||
replica.role = ReplicaRole::Primary;
|
||||
// Exclusive &mut self prevents membership changes during promotion.
|
||||
self.replicas
|
||||
.get_mut(id)
|
||||
.expect("validated replica remains in the set")
|
||||
.role = ReplicaRole::Primary;
|
||||
let mut primary = self.primary_id.write();
|
||||
*primary = Some(id.to_string());
|
||||
|
||||
|
|
@ -362,6 +367,48 @@ mod tests {
|
|||
assert_eq!(primary.role, ReplicaRole::Primary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_promotion_same_shard_completes() {
|
||||
// Find a real collision without depending on DashMap's random seed.
|
||||
let mut set = ReplicaSet::new("collision");
|
||||
set.replicas = Arc::new(DashMap::with_shard_amount(2));
|
||||
for i in 0..3 {
|
||||
set.add_replica(format!("r{i}"), "127.0.0.1:0", ReplicaRole::Secondary)
|
||||
.unwrap();
|
||||
}
|
||||
let mut pair = None;
|
||||
for i in 0..3 {
|
||||
let id = format!("r{i}");
|
||||
let guard = set.replicas.get_mut(&id).unwrap();
|
||||
for j in (i + 1)..3 {
|
||||
let candidate = format!("r{j}");
|
||||
if matches!(
|
||||
set.replicas.try_get_mut(&candidate),
|
||||
dashmap::try_result::TryResult::Locked
|
||||
) {
|
||||
pair = Some((id.clone(), candidate));
|
||||
break;
|
||||
}
|
||||
}
|
||||
drop(guard);
|
||||
if pair.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (old, new) = pair.expect("three keys in two shards must collide");
|
||||
set.promote_to_primary(&old).unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
set.promote_to_primary(&new).unwrap();
|
||||
assert_eq!(set.get_primary().unwrap().id, new);
|
||||
assert_eq!(set.get_replica(&old).unwrap().role, ReplicaRole::Secondary);
|
||||
set.promote_to_primary(&new).unwrap(); // idempotent
|
||||
tx.send(()).unwrap();
|
||||
});
|
||||
rx.recv_timeout(Duration::from_secs(5))
|
||||
.expect("promotion must not deadlock on colliding shard keys");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quorum() {
|
||||
let mut set = ReplicaSet::new("cluster-1");
|
||||
|
|
|
|||
|
|
@ -193,6 +193,11 @@ pub async fn run(
|
|||
.parse::<SocketAddr>()
|
||||
.context("Invalid address")?;
|
||||
|
||||
// Bind before announcing readiness; clients can connect upon the banner.
|
||||
// Report the actual port when zero was requested.
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
let addr = listener.local_addr()?;
|
||||
|
||||
println!();
|
||||
if mock_mode {
|
||||
println!(
|
||||
|
|
@ -221,7 +226,6 @@ pub async fn run(
|
|||
println!();
|
||||
|
||||
// Set up graceful shutdown
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -167,9 +167,11 @@ fn allow_mock_serves_labeled_placeholder_responses() {
|
|||
.unwrap();
|
||||
let mut server = Server(child);
|
||||
|
||||
let stdout = BufReader::new(server.0.stdout.take().unwrap());
|
||||
// Keep the pipe open until requests finish: dropping it at the banner
|
||||
// can make the child panic on its remaining stdout writes.
|
||||
let mut stdout = BufReader::new(server.0.stdout.take().unwrap());
|
||||
let mut banner = String::new();
|
||||
for line in stdout.lines() {
|
||||
for line in stdout.by_ref().lines() {
|
||||
let line = line.unwrap();
|
||||
banner.push_str(&line);
|
||||
banner.push('\n');
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
name = "rvagent-middleware"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.89"
|
||||
description = "rvAgent middleware — pipeline, todolist, filesystem, subagents, summarization, memory, skills, prompt caching, HITL, witness, tool sanitizer"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/ruvnet/RuVector"
|
||||
|
|
@ -16,7 +17,7 @@ rvagent-core = { version = "0.1.0", path = "../rvagent-core" }
|
|||
rvagent-backends = { version = "0.1.0", path = "../rvagent-backends" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = "0.9"
|
||||
serde-saphyr = { version = "=1.3.0", default-features = false, features = ["deserialize"] }
|
||||
tokio = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -98,6 +98,95 @@ fn truncate(s: &str, max_len: usize) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
// Retain only the scalar strings and string-keyed maps used by skill metadata.
|
||||
// Other YAML values are consumed without coercing numeric keys into strings.
|
||||
enum FrontmatterValue {
|
||||
String(String),
|
||||
Mapping(HashMap<String, FrontmatterValue>),
|
||||
Other,
|
||||
}
|
||||
|
||||
impl FrontmatterValue {
|
||||
fn as_str(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::String(value) => Some(value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn as_mapping(&self) -> Option<&HashMap<String, Self>> {
|
||||
match self {
|
||||
Self::Mapping(value) => Some(value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for FrontmatterValue {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
struct Visitor;
|
||||
impl<'de> serde::de::Visitor<'de> for Visitor {
|
||||
type Value = FrontmatterValue;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("YAML skill frontmatter")
|
||||
}
|
||||
|
||||
fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Self::Value, E> {
|
||||
Ok(FrontmatterValue::String(value.to_owned()))
|
||||
}
|
||||
|
||||
fn visit_string<E: serde::de::Error>(self, value: String) -> Result<Self::Value, E> {
|
||||
Ok(FrontmatterValue::String(value))
|
||||
}
|
||||
|
||||
fn visit_bool<E: serde::de::Error>(self, _: bool) -> Result<Self::Value, E> {
|
||||
Ok(FrontmatterValue::Other)
|
||||
}
|
||||
|
||||
fn visit_i64<E: serde::de::Error>(self, _: i64) -> Result<Self::Value, E> {
|
||||
Ok(FrontmatterValue::Other)
|
||||
}
|
||||
|
||||
fn visit_u64<E: serde::de::Error>(self, _: u64) -> Result<Self::Value, E> {
|
||||
Ok(FrontmatterValue::Other)
|
||||
}
|
||||
|
||||
fn visit_f64<E: serde::de::Error>(self, _: f64) -> Result<Self::Value, E> {
|
||||
Ok(FrontmatterValue::Other)
|
||||
}
|
||||
|
||||
fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
|
||||
Ok(FrontmatterValue::Other)
|
||||
}
|
||||
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(
|
||||
self,
|
||||
mut sequence: A,
|
||||
) -> Result<Self::Value, A::Error> {
|
||||
while sequence.next_element::<serde::de::IgnoredAny>()?.is_some() {}
|
||||
Ok(FrontmatterValue::Other)
|
||||
}
|
||||
|
||||
fn visit_map<A: serde::de::MapAccess<'de>>(
|
||||
self,
|
||||
mut map: A,
|
||||
) -> Result<Self::Value, A::Error> {
|
||||
let mut strings = HashMap::new();
|
||||
while let Some((key, value)) =
|
||||
map.next_entry::<FrontmatterValue, FrontmatterValue>()?
|
||||
{
|
||||
if let FrontmatterValue::String(key) = key {
|
||||
strings.insert(key, value);
|
||||
}
|
||||
}
|
||||
Ok(FrontmatterValue::Mapping(strings))
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_any(Visitor)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse skill metadata from YAML frontmatter in a SKILL.md file.
|
||||
///
|
||||
/// Returns `None` if the file is too large, has no frontmatter, or frontmatter is invalid.
|
||||
|
|
@ -135,19 +224,26 @@ pub fn parse_skill_metadata(
|
|||
return None;
|
||||
}
|
||||
|
||||
let frontmatter: serde_yaml::Value = serde_yaml::from_str(frontmatter_str).ok()?;
|
||||
// Pure-Rust YAML parsing; filesystem includes and property expansion stay disabled.
|
||||
let mut budget = serde_saphyr::Budget::default();
|
||||
budget.max_depth = 32;
|
||||
budget.max_documents = 1;
|
||||
budget.max_aliases = 64;
|
||||
budget.max_anchors = 64;
|
||||
budget.max_nodes = 4096;
|
||||
budget.max_events = 8192;
|
||||
budget.max_total_scalar_bytes = 64 * 1024;
|
||||
let mut options = serde_saphyr::Options::default();
|
||||
options.budget = Some(budget);
|
||||
// Match serde_yaml::Value: merge keys are data unless explicitly applied.
|
||||
options.merge_keys = serde_saphyr::MergeKeyPolicy::AsOrdinary;
|
||||
options.strict_booleans = true;
|
||||
let frontmatter: FrontmatterValue =
|
||||
serde_saphyr::from_str_with_options(frontmatter_str, options).ok()?;
|
||||
let map = frontmatter.as_mapping()?;
|
||||
|
||||
let name = map
|
||||
.get(&serde_yaml::Value::String("name".into()))?
|
||||
.as_str()?
|
||||
.trim()
|
||||
.to_string();
|
||||
let description = map
|
||||
.get(&serde_yaml::Value::String("description".into()))?
|
||||
.as_str()?
|
||||
.trim()
|
||||
.to_string();
|
||||
let name = map.get("name")?.as_str()?.trim().to_string();
|
||||
let description = map.get("description")?.as_str()?.trim().to_string();
|
||||
|
||||
// Validate skill name (warn but continue for backwards compatibility)
|
||||
if let Err(err) = validate_skill_name(&name, directory_name) {
|
||||
|
|
@ -161,7 +257,7 @@ pub fn parse_skill_metadata(
|
|||
|
||||
// Parse allowed-tools (space-delimited string, strip commas)
|
||||
let allowed_tools = map
|
||||
.get(&serde_yaml::Value::String("allowed-tools".into()))
|
||||
.get("allowed-tools")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| {
|
||||
s.split_whitespace()
|
||||
|
|
@ -172,21 +268,21 @@ pub fn parse_skill_metadata(
|
|||
.unwrap_or_default();
|
||||
|
||||
let license = map
|
||||
.get(&serde_yaml::Value::String("license".into()))
|
||||
.get("license")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string());
|
||||
|
||||
let compatibility = map
|
||||
.get(&serde_yaml::Value::String("compatibility".into()))
|
||||
.get("compatibility")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| truncate(s.trim(), MAX_SKILL_COMPATIBILITY_LENGTH));
|
||||
|
||||
let metadata = map
|
||||
.get(&serde_yaml::Value::String("metadata".into()))
|
||||
.get("metadata")
|
||||
.and_then(|v| v.as_mapping())
|
||||
.map(|m| {
|
||||
m.iter()
|
||||
.filter_map(|(k, v)| Some((k.as_str()?.to_string(), v.as_str()?.to_string())))
|
||||
.filter_map(|(k, v)| Some((k.to_string(), v.as_str()?.to_string())))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
|
@ -448,4 +544,62 @@ mod tests {
|
|||
assert_eq!(meta.metadata.get("version"), Some(&"1.0".to_string()));
|
||||
assert_eq!(meta.metadata.get("author"), Some(&"test".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_yaml_metadata_preserves_string_only_filtering() {
|
||||
let content = "---\nname: skill\ndescription: Test\nmetadata:\n 4: ignored\n good: value\n count: 4\n flag: true\n items: [one, two]\nunknown: { [a, b]: ignored }\n---\n";
|
||||
let meta = parse_skill_metadata(content, "path", "skill").unwrap();
|
||||
assert_eq!(
|
||||
meta.metadata,
|
||||
HashMap::from([("good".into(), "value".into())])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_yaml_alias_and_block_scalar() {
|
||||
let content = "---\nname: skill\ndescription: &description >-\n useful\n skill\nmetadata:\n copy: *description\n---\n";
|
||||
let meta = parse_skill_metadata(content, "path", "skill").unwrap();
|
||||
assert_eq!(meta.description, "useful skill");
|
||||
assert_eq!(meta.metadata["copy"], "useful skill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_yaml_rejects_duplicate_and_malformed_frontmatter() {
|
||||
for yaml in [
|
||||
"name: skill\nname: other\ndescription: Test",
|
||||
"name: [broken",
|
||||
"name: 5\ndescription: Test",
|
||||
] {
|
||||
assert!(
|
||||
parse_skill_metadata(&format!("---\n{yaml}\n---\n"), "path", "skill").is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_yaml_rejects_excessive_nesting_and_aliases() {
|
||||
let nested = format!(
|
||||
"---\nname: skill\ndescription: Test\nignored: {}0{}\n---\n",
|
||||
"[".repeat(40),
|
||||
"]".repeat(40)
|
||||
);
|
||||
assert!(parse_skill_metadata(&nested, "path", "skill").is_none());
|
||||
let aliases = format!(
|
||||
"---\nname: skill\ndescription: &a Test\nignored: [{}]\n---\n",
|
||||
vec!["*a"; 65].join(",")
|
||||
);
|
||||
assert!(parse_skill_metadata(&aliases, "path", "skill").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_yaml_merge_keys_remain_ordinary_data() {
|
||||
let content = "---\nbase: &base {name: skill, description: Test}\n<<: *base\n---\n";
|
||||
assert!(parse_skill_metadata(content, "path", "skill").is_none());
|
||||
let content = "---\nname: skill\ndescription: Test\nmetadata: {<<: {hidden: ignored}, visible: yes}\n---\n";
|
||||
let meta = parse_skill_metadata(content, "path", "skill").unwrap();
|
||||
assert_eq!(
|
||||
meta.metadata,
|
||||
HashMap::from([("visible".into(), "yes".into())])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
76
docs/python-portfolio/evidence/2026-10-12/README.md
Normal file
76
docs/python-portfolio/evidence/2026-10-12/README.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# Portfolio recovery evidence, 2026-10-12
|
||||
|
||||
This repair addresses the incomplete 0.2.0 release before adding another Python
|
||||
library. It does not rebuild or replace immutable published versions.
|
||||
|
||||
## Observed state
|
||||
|
||||
The original supervisor stopped after a publication failure. PR1188 merged as
|
||||
`fccf36e9a76d35bce6750dfd3b25802cae3d683b`. The actual main release
|
||||
[run38169271248](https://github.com/ruvnet/RuVector/actions/runs/38169271248)
|
||||
passed build and native audit, then stopped on PyPI HTTP429: too many new projects.
|
||||
|
||||
On October12, two original wheels are present and match their frozen SHA256:
|
||||
attention and cluster0.2.0. Their source archives and nine other complete target
|
||||
releases remain missing: 20 of22 distribution files are absent. This is not a
|
||||
completed portfolio release. The checked in inventory records exact registry
|
||||
filenames and hashes; no token or private customer data is included.
|
||||
|
||||
The original runtime is0.1.3. Main now requests0.1.4, so rebuilding0.2.0 from main
|
||||
would produce different dependency metadata. Recovery uses only the original
|
||||
archive, artifact11713787141, SHA256
|
||||
`b4c92cae86c753305abef7aad395fdd9d91c7e84c3c887342defa7a3e4607241`.
|
||||
The actual downloaded ZIP and all22 extracted file hashes were verified anew.
|
||||
|
||||
## Validation
|
||||
|
||||
| Check | Observed result |
|
||||
| --- | --- |
|
||||
| Recovery adversarial contracts |62 passed on Linux; Windows permission behavior simulated, not a native Windows run |
|
||||
| Original installed wheel contracts |103 passed, CPython3.12.3 Linuxx86_64 |
|
||||
| Original README examples |11 passed against installed original artifacts |
|
||||
| Published attention tutorial |Passed from fresh PyPI installation: attention0.2.0, runtime0.1.3, NumPy2.2.6 |
|
||||
| Workflow validation |actionlint1.7.7 passed; official release binary checksum verified; external shellcheck/pyflakes integrations disabled |
|
||||
| Python dependency audit |pip-audit2.10.1 reported no known issues in resolved dependencies; nine unpublished companion distributions could not be looked up |
|
||||
| Read-only source scan |Pinned Ruflo3.25.6 reported zero findings for the recovery helper; no advisory feed timestamp supplied |
|
||||
| Secret scan |detect-secrets1.5.0 flagged four public provenance hashes, manually confirmed false positives; no credentials present |
|
||||
| Independent review |Approved exact helper and workflow after correcting Windows mode handling and artifact download directory layout |
|
||||
| Live registry preflight |Two existing hashes match,20 missing original files selected, zero uploads |
|
||||
|
||||
The native audit from the original run is preserved as historical evidence.
|
||||
Recovery additionally gates upload on a fresh frozen-source native audit, fresh
|
||||
original artifact installation on Linux/macOS/Windows, and dependency checks.
|
||||
Hosted platform gates and post-publication PyPI consumer tests remain to run.
|
||||
|
||||
## Failure and promotion policy
|
||||
|
||||
PR and default dispatch checks cannot publish. An explicit main dispatch with
|
||||
`publish=true` enters the existing protected pypi environment only after
|
||||
platform and audit gates pass. Ordinary and recovery portfolio publishers share
|
||||
one concurrency group. Under that lock the registry is checked again; existing
|
||||
matching files are skipped by selection, never by trusting a duplicate-upload
|
||||
error. Hash conflicts, yanked files, metadata mismatches, unsafe paths and registry
|
||||
errors stop selection.
|
||||
|
||||
There is no established upload429 cooldown in official guidance. The24hour rule
|
||||
in [Trusted Publisher guidance](https://docs.pypi.org/trusted-publishers/troubleshooting/)
|
||||
concerns publisher registration, not this upload error. No rapid retry, alternate
|
||||
account or IP workaround is configured. A failed upload leaves a partial registry
|
||||
state and requires a new read-only inventory before another bounded attempt.
|
||||
|
||||
Disabling the recovery entry point rolls back execution without deleting or
|
||||
overwriting published versions. The exact file ledger and receipts stay available.
|
||||
Windows relies on the trusted hosted runner ACL rather than POSIX permission bits.
|
||||
Checksums detect changes; they do not replace protected source review.
|
||||
|
||||
## Candidate decision
|
||||
|
||||
[candidate-decision.json](candidate-decision.json) records the requested weighted
|
||||
ranking and fresh npm window September12 through October11. Filtered retrieval
|
||||
is the leading future extension of an existing package; no new library is
|
||||
selected while the original release is incomplete. Counts are downloads, not
|
||||
unique users, and usefulness/readiness scores are engineering estimates.
|
||||
|
||||
Public tested tutorial: https://gist.github.com/ruvnet/2491c7631bf3e186e815196f52fd8954
|
||||
|
||||
The posted code was fetched back from GitHub, matched byte for byte and ran successfully. See tutorial-ledger.json for its immutable revision and code digest.
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
{
|
||||
"date": "2026-10-12",
|
||||
"source_sha": "31549f798997cbb16b1ec872ca30b9e690bbd3ff",
|
||||
"download_window": {
|
||||
"start": "2026-09-12",
|
||||
"end": "2026-10-11"
|
||||
},
|
||||
"weights": {
|
||||
"customer_usefulness": 0.4,
|
||||
"npm_demand": 0.25,
|
||||
"implementation_readiness": 0.2,
|
||||
"maintenance_ease": 0.15
|
||||
},
|
||||
"method": "Scores 0..5 are prioritization estimates, not measured customer outcomes. Demand uses log(downloads+1) relative to the largest observed candidate package. Core downloads are a package proxy, not feature-specific demand. Downloads are not unique users.",
|
||||
"ranking": [
|
||||
{
|
||||
"candidate": "Filtered retrieval extension",
|
||||
"demand_package": "@ruvector/core",
|
||||
"npm_downloads": 332090,
|
||||
"source_paths": "crates/ruvector-py/src/lib.rs; crates/ruvector-core/src/advanced_features/filtered_search.rs",
|
||||
"scores": {
|
||||
"customer_usefulness": 5,
|
||||
"npm_demand": 4.859,
|
||||
"implementation_readiness": 5,
|
||||
"maintenance_ease": 4
|
||||
},
|
||||
"weighted_score": 4.815,
|
||||
"exclusion": "Extend existing core after recovery; nativeCollection already implementsmetadatafilteredretrieval"
|
||||
},
|
||||
{
|
||||
"candidate": "RVF import/export extension",
|
||||
"demand_package": "@ruvector/rvf",
|
||||
"npm_downloads": 480813,
|
||||
"source_paths": "crates/rvf/; native prerequisite PR1190",
|
||||
"scores": {
|
||||
"customer_usefulness": 5,
|
||||
"npm_demand": 5.0,
|
||||
"implementation_readiness": 2,
|
||||
"maintenance_ease": 4
|
||||
},
|
||||
"weighted_score": 4.25,
|
||||
"exclusion": "Export/metadataAPI proposal unmerged; verify versionidentity beforeextendingexistingRVF"
|
||||
},
|
||||
{
|
||||
"candidate": "Sparse/dense hybrid retrieval",
|
||||
"demand_package": "@ruvector/core",
|
||||
"npm_downloads": 332090,
|
||||
"source_paths": "crates/ruvector-core/src/advanced_features/hybrid_search.rs; sparse_vector.rs",
|
||||
"scores": {
|
||||
"customer_usefulness": 4,
|
||||
"npm_demand": 4.859,
|
||||
"implementation_readiness": 2,
|
||||
"maintenance_ease": 3
|
||||
},
|
||||
"weighted_score": 3.665,
|
||||
"exclusion": "Rustsourceexists; inspectbinding/capabilitymatrix beforeselection"
|
||||
},
|
||||
{
|
||||
"candidate": "KGE link prediction",
|
||||
"demand_package": "@ruvector/kge",
|
||||
"npm_downloads": 422,
|
||||
"source_paths": "crates/ruvector-kge-wasm/src/",
|
||||
"scores": {
|
||||
"customer_usefulness": 4,
|
||||
"npm_demand": 2.311,
|
||||
"implementation_readiness": 2,
|
||||
"maintenance_ease": 2
|
||||
},
|
||||
"weighted_score": 2.878,
|
||||
"exclusion": "422monthlydownloads; PythonAPI/exportreadiness unverified; avoid duplicatepackage"
|
||||
}
|
||||
],
|
||||
"selection": null,
|
||||
"decision": "REPAIR_FIRST_NO_NEW_LIBRARY",
|
||||
"reason": "Nine target companion0.2.0 releases absent and20originalfilesmissing. Newprojectupload429 unresolved; existingunmergedintegration/nativeAPIprerequisites alreadyconsumeattention. Repairfrozenportfolioanddeliverpublishedattentiontutorial; no newcandidatebranch/package."
|
||||
}
|
||||
26
docs/python-portfolio/evidence/2026-10-12/npm-demand.json
Normal file
26
docs/python-portfolio/evidence/2026-10-12/npm-demand.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"retrieved_at": "2026-10-12T06:47:51.149881+00:00",
|
||||
"counts": [
|
||||
{
|
||||
"downloads": 332090,
|
||||
"start": "2026-09-12",
|
||||
"end": "2026-10-11",
|
||||
"package": "@ruvector/core",
|
||||
"source_url": "https://api.npmjs.org/downloads/point/2026-09-12:2026-10-11/@ruvector/core"
|
||||
},
|
||||
{
|
||||
"downloads": 480813,
|
||||
"start": "2026-09-12",
|
||||
"end": "2026-10-11",
|
||||
"package": "@ruvector/rvf",
|
||||
"source_url": "https://api.npmjs.org/downloads/point/2026-09-12:2026-10-11/@ruvector/rvf"
|
||||
},
|
||||
{
|
||||
"downloads": 422,
|
||||
"start": "2026-09-12",
|
||||
"end": "2026-10-11",
|
||||
"package": "@ruvector/kge",
|
||||
"source_url": "https://api.npmjs.org/downloads/point/2026-09-12:2026-10-11/@ruvector/kge"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
{
|
||||
"archive_sha256": "b4c92cae86c753305abef7aad395fdd9d91c7e84c3c887342defa7a3e4607241",
|
||||
"files": {
|
||||
"ruvector_attention-0.2.0-py3-none-any.whl": "48c6557e4fc53c41aeb24b832e3e167afe88fde6c17affbb31cd6da148972bdc",
|
||||
"ruvector_attention-0.2.0.tar.gz": "767881ffe1e0023ed2866ccbb57fdab1c985149ec89d1c003e2e737d7327aa70",
|
||||
"ruvector_cluster-0.2.0-py3-none-any.whl": "99efcf69e29c9a11e47599ea37a725496e11fa88b169224885e23efa0d6ec3b8",
|
||||
"ruvector_cluster-0.2.0.tar.gz": "09e0db4fc6f19a6b0e45b7906c5647adb24917dd5a3b2fa59a282a3629333635",
|
||||
"ruvector_core-0.2.0-py3-none-any.whl": "c269a9cd41dbf6b91900e1709f31e1576560b51e70a553adfeb219d108c1cd89",
|
||||
"ruvector_core-0.2.0.tar.gz": "24a62d2a0b08f4a4a5f1470922f04c8727a75ea36fb4aea3691d087f0d3c5630",
|
||||
"ruvector_gnn-0.2.0-py3-none-any.whl": "4e5e013e3ba87205d2acaabaf1420ada8a37cb9bb8281168395c741c5c6eedcb",
|
||||
"ruvector_gnn-0.2.0.tar.gz": "46706f979d8d45a9d0eb19005f595aa2fe48d88804d04e99acbe22f139a6d100",
|
||||
"ruvector_graph-0.2.0-py3-none-any.whl": "2278acfa6255df9bd961c80ebb36fa28621975b36eb7fb41d992b7b5a921b8cc",
|
||||
"ruvector_graph-0.2.0.tar.gz": "5410b43357250b3719f8c7f700b4cad9e99242bc53532141085ebfb08565ccde",
|
||||
"ruvector_rerank-0.2.0-py3-none-any.whl": "5990bbcc2fabab087480740770f240f10bf65c2bf6400aba6911c27079a9cea4",
|
||||
"ruvector_rerank-0.2.0.tar.gz": "839921d2d7d14387cc572bb210cc356c23b84d2946706dc7432e87afb1bcf87c",
|
||||
"ruvector_router-0.2.0-py3-none-any.whl": "7066ec5b35e3cfb1f5dbb902b87bed18aa85ad10b76f085137e823b5ac0e4938",
|
||||
"ruvector_router-0.2.0.tar.gz": "10c79f3a0f7c86820e7128b9a1e3d426b66f22f248a7eb2a1c92ba891a02eac5",
|
||||
"ruvector_ruvllm-0.2.0-py3-none-any.whl": "ccad8d79a43811148908a97e161f2eebcadbe1ab8975aaa266a0824c3d3cc720",
|
||||
"ruvector_ruvllm-0.2.0.tar.gz": "860a882d5403789c9da73f51e9177a79139eb66617627cb454982da39e4fed4a",
|
||||
"ruvector_rvf-0.2.0-py3-none-any.whl": "7003a7866227bc4ad336c77f677456752c4bc0f6acf37843c6effd94749d496e",
|
||||
"ruvector_rvf-0.2.0.tar.gz": "4d2902065f1212ffbc4861bc316dbc9f7b928397e0840682e8d8ef246527624f",
|
||||
"ruvector_rvf_wasm-0.2.0-py3-none-any.whl": "17bb6bbdb86595c9644a2cac6cb2e631fa2341eb9f87235f2105324aef989e57",
|
||||
"ruvector_rvf_wasm-0.2.0.tar.gz": "566d5d46365188e1493cdace52020f5d9da38232b79580a6b946e549d8cd8028",
|
||||
"ruvector_sona-0.2.0-py3-none-any.whl": "bbf005d0438458a6d1dfdd78e9553eee9cda7933af059450ca7202188eafa4ce",
|
||||
"ruvector_sona-0.2.0.tar.gz": "23db0420e9b1eff2d8c01a61eb84dd1f97e46787a9b0a0ac73cf3908458cd273"
|
||||
},
|
||||
"missing": [
|
||||
"ruvector_attention-0.2.0.tar.gz",
|
||||
"ruvector_cluster-0.2.0.tar.gz",
|
||||
"ruvector_core-0.2.0-py3-none-any.whl",
|
||||
"ruvector_core-0.2.0.tar.gz",
|
||||
"ruvector_gnn-0.2.0-py3-none-any.whl",
|
||||
"ruvector_gnn-0.2.0.tar.gz",
|
||||
"ruvector_graph-0.2.0-py3-none-any.whl",
|
||||
"ruvector_graph-0.2.0.tar.gz",
|
||||
"ruvector_rerank-0.2.0-py3-none-any.whl",
|
||||
"ruvector_rerank-0.2.0.tar.gz",
|
||||
"ruvector_router-0.2.0-py3-none-any.whl",
|
||||
"ruvector_router-0.2.0.tar.gz",
|
||||
"ruvector_ruvllm-0.2.0-py3-none-any.whl",
|
||||
"ruvector_ruvllm-0.2.0.tar.gz",
|
||||
"ruvector_rvf-0.2.0-py3-none-any.whl",
|
||||
"ruvector_rvf-0.2.0.tar.gz",
|
||||
"ruvector_rvf_wasm-0.2.0-py3-none-any.whl",
|
||||
"ruvector_rvf_wasm-0.2.0.tar.gz",
|
||||
"ruvector_sona-0.2.0-py3-none-any.whl",
|
||||
"ruvector_sona-0.2.0.tar.gz"
|
||||
],
|
||||
"operation": "precheck-only",
|
||||
"promotion": "independent-review-and-protected-environment-required",
|
||||
"published": [
|
||||
"ruvector_attention-0.2.0-py3-none-any.whl",
|
||||
"ruvector_cluster-0.2.0-py3-none-any.whl"
|
||||
],
|
||||
"runtime": "0.1.3",
|
||||
"schema": 1,
|
||||
"source_artifact": 11713787141,
|
||||
"source_run": 38169271248,
|
||||
"source_sha": "fccf36e9a76d35bce6750dfd3b25802cae3d683b",
|
||||
"status": "validated",
|
||||
"version": "0.2.0"
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
[
|
||||
{
|
||||
"package": "ruvector",
|
||||
"latest": "0.1.4",
|
||||
"target": "0.1.4",
|
||||
"files": [
|
||||
{
|
||||
"name": "ruvector-0.1.4-cp39-abi3-macosx_10_12_x86_64.whl",
|
||||
"sha256": "3da743e96235ef90d2b95e10f13b42da81374f1844daf3d1fabb9ab011b8670d"
|
||||
},
|
||||
{
|
||||
"name": "ruvector-0.1.4-cp39-abi3-macosx_11_0_arm64.whl",
|
||||
"sha256": "28aeac25bb3151b4afb0b06bc07ec259137432387c7c2cb3316842bc55e82006"
|
||||
},
|
||||
{
|
||||
"name": "ruvector-0.1.4-cp39-abi3-manylinux_2_28_aarch64.whl",
|
||||
"sha256": "d6dabb8f3d5add92e8721adcfc28dcaaaadb70e62ac7e729c625ed3870497a7c"
|
||||
},
|
||||
{
|
||||
"name": "ruvector-0.1.4-cp39-abi3-manylinux_2_28_x86_64.whl",
|
||||
"sha256": "2189bf262fab1533f40ac654afa4bcb580b87ad2e33eee1decd0b7a42f469ebe"
|
||||
},
|
||||
{
|
||||
"name": "ruvector-0.1.4-cp39-abi3-win_amd64.whl",
|
||||
"sha256": "6032dd22b1f74d0d4fa8c23142f88ca9275bcb5f31cd114c3e875e164356d5ee"
|
||||
},
|
||||
{
|
||||
"name": "ruvector-0.1.4.tar.gz",
|
||||
"sha256": "614697f60b038486372fdd7f6feef819f89938b955b38e7fe5fb608d14406e5f"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"package": "ruvector-attention",
|
||||
"latest": "0.2.0",
|
||||
"target": "0.2.0",
|
||||
"files": [
|
||||
{
|
||||
"name": "ruvector_attention-0.2.0-py3-none-any.whl",
|
||||
"sha256": "48c6557e4fc53c41aeb24b832e3e167afe88fde6c17affbb31cd6da148972bdc"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"package": "ruvector-cluster",
|
||||
"latest": "0.2.0",
|
||||
"target": "0.2.0",
|
||||
"files": [
|
||||
{
|
||||
"name": "ruvector_cluster-0.2.0-py3-none-any.whl",
|
||||
"sha256": "99efcf69e29c9a11e47599ea37a725496e11fa88b169224885e23efa0d6ec3b8"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"package": "ruvector-core",
|
||||
"status": 404
|
||||
},
|
||||
{
|
||||
"package": "ruvector-gnn",
|
||||
"status": 404
|
||||
},
|
||||
{
|
||||
"package": "ruvector-graph",
|
||||
"latest": "0.1.0",
|
||||
"target": "0.2.0",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"package": "ruvector-rerank",
|
||||
"latest": "0.1.0",
|
||||
"target": "0.2.0",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"package": "ruvector-router",
|
||||
"status": 404
|
||||
},
|
||||
{
|
||||
"package": "ruvector-ruvllm",
|
||||
"status": 404
|
||||
},
|
||||
{
|
||||
"package": "ruvector-rvf",
|
||||
"status": 404
|
||||
},
|
||||
{
|
||||
"package": "ruvector-rvf-wasm",
|
||||
"status": 404
|
||||
},
|
||||
{
|
||||
"package": "ruvector-sona",
|
||||
"status": 404
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"date": "2026-10-12",
|
||||
"package": "ruvector-attention",
|
||||
"version": "0.2.0",
|
||||
"native": "ruvector==0.1.3",
|
||||
"numpy": "2.2.6",
|
||||
"gist_url": "https://gist.github.com/ruvnet/2491c7631bf3e186e815196f52fd8954",
|
||||
"gist_id": "2491c7631bf3e186e815196f52fd8954",
|
||||
"gist_revision": "7c09fe56274683c0b838ed0a2b176abdd86ff079",
|
||||
"script_sha256": "0d9961c2c93a1cec8debcd744aeacc141c662042042e7646e1f8a1a14a61ad0f",
|
||||
"status": "published package tutorial; tested on Linux; broader portfolio incomplete",
|
||||
"source_sha": "fccf36e9a76d35bce6750dfd3b25802cae3d683b",
|
||||
"recovery_pr": "https://github.com/ruvnet/RuVector/pull/1205",
|
||||
"acceptance": {
|
||||
"platform": "Linuxx86_64",
|
||||
"python": "3.10.22",
|
||||
"result": "PASS native attention matches independent reference and invalidinputs rejected"
|
||||
}
|
||||
}
|
||||
47
docs/python-portfolio/recovery-2026-10-12.svg
Normal file
47
docs/python-portfolio/recovery-2026-10-12.svg
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1040 330" role="img" aria-labelledby="recovery-title recovery-desc">
|
||||
<title id="recovery-title">Recover the original RuVector Python portfolio release</title>
|
||||
<desc id="recovery-desc">The frozen 0.2.0 release and its checksum ledger are compared with the current PyPI inventory. Only missing original files may advance through reviewed protected publishing. A mismatch or HTTP 429 stops promotion. Animation is decorative; all information remains visible when reduced motion is enabled.</desc>
|
||||
<style>
|
||||
.label{font:600 17px system-ui,sans-serif;fill:#edf5ff}
|
||||
.detail{font:14px system-ui,sans-serif;fill:#b8cadf}
|
||||
.flow{stroke:#55d8c4;stroke-width:2;stroke-dasharray:6 8;animation:flow 1.8s linear infinite}
|
||||
.pulse{animation:pulse 3s ease-in-out infinite}
|
||||
@keyframes flow{to{stroke-dashoffset:-28}}
|
||||
@keyframes pulse{0%,100%{opacity:.55}50%{opacity:1}}
|
||||
@media(prefers-reduced-motion:reduce){.flow,.pulse{animation:none}}
|
||||
</style>
|
||||
<rect width="1040" height="330" rx="20" fill="#101a2b"/>
|
||||
<text x="32" y="46" class="label" style="font-size:25px">Recover the original release</text>
|
||||
<text x="32" y="73" class="detail">Companions 0.2.0 · frozen native runtime 0.1.3 · one publisher owner</text>
|
||||
<path d="M206 160H248 M408 160H450 M610 160H652 M812 160H854" class="flow"/>
|
||||
<g fill="#192a41" stroke="#46708a">
|
||||
<rect x="32" y="112" width="174" height="97" rx="12"/>
|
||||
<rect x="248" y="112" width="160" height="97" rx="12"/>
|
||||
<rect x="450" y="112" width="160" height="97" rx="12"/>
|
||||
<rect x="652" y="112" width="160" height="97" rx="12"/>
|
||||
<rect x="854" y="112" width="154" height="97" rx="12"/>
|
||||
</g>
|
||||
<g class="label">
|
||||
<text x="47" y="142">Frozen artifact</text>
|
||||
<text x="264" y="142">Compare PyPI</text>
|
||||
<text x="466" y="142">Missing files</text>
|
||||
<text x="668" y="142">Review gate</text>
|
||||
<text x="870" y="142">Verify</text>
|
||||
</g>
|
||||
<g class="detail">
|
||||
<text x="47" y="171">22 original files</text>
|
||||
<text x="47" y="193">SHA256 ledger</text>
|
||||
<text x="264" y="171">Name + version</text>
|
||||
<text x="264" y="193">Existing hashes</text>
|
||||
<text x="466" y="171">Original bytes</text>
|
||||
<text x="466" y="193">No overwrite</text>
|
||||
<text x="668" y="171">Protected PyPI</text>
|
||||
<text x="668" y="193">Required checks</text>
|
||||
<text x="870" y="171">Fresh inventory</text>
|
||||
<text x="870" y="193">Consumer tests</text>
|
||||
</g>
|
||||
<rect x="32" y="239" width="976" height="57" rx="10" fill="#2c2634" stroke="#bf7c91"/>
|
||||
<circle cx="54" cy="267" r="6" fill="#ffb2c4" class="pulse"/>
|
||||
<text x="73" y="262" class="label" style="fill:#ffcbda">Mismatch or HTTP 429: stop promotion</text>
|
||||
<text x="73" y="284" class="detail">Reinspect live state before retrying. Publication and installation verification are separate milestones.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
80
docs/python-portfolio/resume-release-adr.md
Normal file
80
docs/python-portfolio/resume-release-adr.md
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# ADR: Recover the frozen Python portfolio release
|
||||
|
||||
Status: Proposed
|
||||
Date: October 12, 2026
|
||||
Scope: RuVector companion distributions at version 0.2.0
|
||||
|
||||

|
||||
|
||||
## Context
|
||||
|
||||
The initial portfolio release partially reached PyPI. A new-project HTTP 429 interrupted publication. Package names, versions and actual published files must be inspected again before any upload. A successful wheel upload does not imply its source distribution or another package was uploaded.
|
||||
|
||||
The original companion artifacts require `ruvector==0.1.3`. Current main changes the same companion version to depend on runtime 0.1.4. Rebuilding 0.2.0 from current main would silently mix release identities and cannot repair the original release faithfully.
|
||||
|
||||
The stopped historical supervisor had a deadline ending October 11 at 22:35 UTC. Its receipt is useful evidence, not proof that no other publisher is running. Recovery must establish one current publisher owner from live process state, workflow runs, private Slack claims and existing receipts.
|
||||
|
||||
## Decision
|
||||
|
||||
Recover only the original immutable artifacts. Do not add another Python library while this portfolio delivery is unfinished.
|
||||
|
||||
The frozen release identity is:
|
||||
|
||||
| Field | Exact value |
|
||||
| --- | --- |
|
||||
| Source commit | `fccf36e9a76d35bce6750dfd3b25802cae3d683b` |
|
||||
| Companion version | `0.2.0` |
|
||||
| Required native runtime | `0.1.3` |
|
||||
| GitHub Actions run | `38169271248` |
|
||||
| Artifact ID | `11713787141` |
|
||||
| Artifact archive SHA256 | `b4c92cae86c753305abef7aad395fdd9d91c7e84c3c887342defa7a3e4607241` |
|
||||
| Expected companion artifacts | 22 files across 11 distributions |
|
||||
|
||||
The [frozen inventory](../../python/portfolio/frozen-release-0.2.0.json) records each expected filename, byte size and SHA256. It provides release identity; it does not authorize publication.
|
||||
|
||||
A recovery implementation must:
|
||||
|
||||
1. Confirm no live publisher already owns this release; claim the scoped branch and package work in the existing private Slack thread.
|
||||
2. Download the original artifact by immutable run and artifact identity. Verify the archive checksum and all 22 member checksums against the committed inventory. Reject path traversal, extra members, missing members and mismatched bytes.
|
||||
3. Fetch the current PyPI JSON for every expected distribution/version. An existing expected filename must match its frozen SHA256. Unexpected release filenames or mismatches stop recovery.
|
||||
4. Stage only files missing from PyPI. Stage nothing when all expected files are already present. Avoid `skip-existing` as a substitute for preflight identity verification.
|
||||
5. Require independent review and repository release checks before using the protected `pypi` environment. Publish through the established GitHub Actions identity without broadening secrets, changing protection or bypassing approval.
|
||||
6. Stop on HTTP 429. There is no confirmed upload cooldown here. Do not infer that the separate documented Trusted Publisher registration limit applies to this upload. Reinspect state and receipts before a later authorized attempt.
|
||||
7. Requery PyPI after publication, verify every expected filename and hash, and execute installed-artifact consumer tests. Record implementation, checks, merge, publication and verification separately.
|
||||
|
||||
A transport failure or ambiguous upload response requires read-only reconciliation first. A manifest cannot establish whether a network side effect completed.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Frozen artifact and ledger"] --> B["Archive and file verification"]
|
||||
C["Current PyPI inventory"] --> D["Identity and missing file check"]
|
||||
B --> D
|
||||
D --> E{"Safe to publish?"}
|
||||
E -->|Mismatch or 429| F["Stop and record blocker"]
|
||||
E -->|Reviewed missing files| G["Protected publishing"]
|
||||
G --> H["PyPI and consumer verification"]
|
||||
```
|
||||
|
||||
## Tutorial acceptance
|
||||
|
||||
The companion [attention tutorial](../../python/portfolio/tutorials/README.md) uses only published `ruvector-attention==0.2.0`, runtime `ruvector==0.1.3` and NumPy 2.2.6. Its exact code passed a clean PyPI install on CPython 3.10.22, Linux x86_64 and glibc 2.39 on October 12, 2026. It checks the native weighted sum against a float64 reference and rejects invalid dimensions and nonfinite queries.
|
||||
|
||||
This smoke test does not prove all portfolio files are published, benchmark performance or establish fresh macOS/Windows execution. The example does not download models, generate text or train parameters.
|
||||
|
||||
## Consequences and alternatives
|
||||
|
||||
Frozen-file recovery preserves original published wheel metadata and avoids duplicate version content. Original artifacts must remain available and independently verified. If the archive becomes unavailable or its checksum differs, recovery stops; a reviewed new version is preferable to rebuilding historical bytes.
|
||||
|
||||
Rebuilding from current main was rejected because the runtime dependency changed without changing the companion version. Blindly repeating the supervisor was rejected because it could duplicate a publisher and obscure partial side effects. Ignoring HTTP 429 or modifying credentials was rejected because neither repairs the release gate.
|
||||
|
||||
Rollback means stopping promotion and preserving the recorded inventory. PyPI files are not overwritten. Any later defect requires a reviewed release correction under package policy; successful publication is not treated as reversible file replacement.
|
||||
|
||||
## Evidence and references
|
||||
|
||||
- [Original portfolio PR #1188](https://github.com/ruvnet/RuVector/pull/1188)
|
||||
- [Frozen source](https://github.com/ruvnet/RuVector/tree/fccf36e9a76d35bce6750dfd3b25802cae3d683b/python/portfolio)
|
||||
- [Original artifact run](https://github.com/ruvnet/RuVector/actions/runs/38169271248)
|
||||
- [Security boundaries](../../python/portfolio/SECURITY.md)
|
||||
- [Frozen file inventory](../../python/portfolio/frozen-release-0.2.0.json)
|
||||
57
npm/package-lock.json
generated
57
npm/package-lock.json
generated
|
|
@ -2183,9 +2183,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
|
||||
"integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==",
|
||||
"version": "1.32.1",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz",
|
||||
"integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9 || ^2.0.5",
|
||||
|
|
@ -5747,14 +5747,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.18.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
|
||||
"integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
|
||||
"version": "1.20.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
|
||||
"integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"form-data": "^4.0.6",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
|
|
@ -6718,14 +6718,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/compression": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
|
||||
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz",
|
||||
"integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"compressible": "~2.0.18",
|
||||
"debug": "2.6.9",
|
||||
"destroy": "1.2.0",
|
||||
"negotiator": "~0.6.4",
|
||||
"on-headers": "~1.1.0",
|
||||
"safe-buffer": "5.2.1",
|
||||
|
|
@ -6733,6 +6734,10 @@
|
|||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/compression/node_modules/debug": {
|
||||
|
|
@ -8104,9 +8109,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-copy": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz",
|
||||
"integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==",
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.1.0.tgz",
|
||||
"integrity": "sha512-IuCOCDFM1F/s4LtG7Bjr3yUUwPHybyhqN74xk2cMYoP51vVeErMy8+6HGFchi/+45lW8uzhs6lBeEqks2GWt6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
|
|
@ -9210,9 +9215,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz",
|
||||
"integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==",
|
||||
"version": "10.7.4",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz",
|
||||
"integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
|
|
@ -12887,9 +12892,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
|
||||
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
|
|
@ -12897,6 +12902,10 @@
|
|||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
|
|
@ -12967,9 +12976,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
|
||||
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.1",
|
||||
|
|
@ -19720,7 +19729,7 @@
|
|||
"version": "0.3.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"@modelcontextprotocol/sdk": "^1.32.1",
|
||||
"@ruvector/attention": "^0.1.3",
|
||||
"@ruvector/core": "^0.1.25",
|
||||
"@ruvector/gnn": "^0.1.22",
|
||||
|
|
@ -20582,7 +20591,7 @@
|
|||
"version": "0.1.4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"@modelcontextprotocol/sdk": "^1.32.1",
|
||||
"@ruvector/rvf": "^0.1.2",
|
||||
"express": "^4.18.0",
|
||||
"zod": "^3.22.0"
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@
|
|||
"tar": "7.5.22",
|
||||
"tsup": {
|
||||
"esbuild": "^0.28.1"
|
||||
}
|
||||
},
|
||||
"qs": "^6.16.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@
|
|||
"node": ">=18.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.30.0"
|
||||
"@modelcontextprotocol/sdk": "^1.32.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@
|
|||
"directory": "npm/packages/ruvector"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"@modelcontextprotocol/sdk": "^1.32.1",
|
||||
"@ruvector/attention": "^0.1.3",
|
||||
"@ruvector/core": "^0.1.25",
|
||||
"@ruvector/gnn": "^0.1.22",
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
"node": ">=18.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"@modelcontextprotocol/sdk": "^1.32.1",
|
||||
"@ruvector/rvf": "^0.3.4",
|
||||
"express": "^4.18.0",
|
||||
"zod": "^3.22.0"
|
||||
|
|
|
|||
55
npm/tests/prepare-security-consumer.py
Normal file
55
npm/tests/prepare-security-consumer.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"""Project the changed packages' exact lock closure for a scripts-disabled smoke test.
|
||||
Usage: python3 npm/tests/prepare-security-consumer.py /new/temporary/directory
|
||||
Then: cd /new/temporary/directory && npm ci --ignore-scripts && node consumer.cjs
|
||||
This validates only the affected dependency closure, not the full workspace.
|
||||
"""
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
npm = Path(__file__).resolve().parents[1]
|
||||
packages = json.loads((npm / "package-lock.json").read_text())["packages"]
|
||||
names = ("@modelcontextprotocol/sdk", "axios", "compression", "fast-copy",
|
||||
"ip-address", "proxy-addr", "qs")
|
||||
target = Path(sys.argv[1])
|
||||
target.mkdir(parents=True, exist_ok=False)
|
||||
selected = {}
|
||||
pending = ["node_modules/" + name for name in names]
|
||||
|
||||
def resolve(at, name):
|
||||
parts = at.split("/")
|
||||
for i in range(len(parts), -1, -1):
|
||||
if i and parts[i - 1] == "node_modules":
|
||||
continue
|
||||
candidate = "/".join(parts[:i] + ["node_modules", name])
|
||||
if candidate in packages:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
while pending:
|
||||
key = pending.pop()
|
||||
if key in selected:
|
||||
continue
|
||||
entry = packages[key]
|
||||
if entry.get("link"):
|
||||
raise ValueError("Workspace link outside dependency test scope")
|
||||
selected[key] = entry
|
||||
for group in ("dependencies", "optionalDependencies", "peerDependencies"):
|
||||
for name in entry.get(group, {}):
|
||||
resolved = resolve(key, name)
|
||||
if resolved:
|
||||
pending.append(resolved)
|
||||
elif group == "dependencies":
|
||||
raise ValueError(f"Missing required dependency: {key} -> {name}")
|
||||
|
||||
dependencies = {name: packages["node_modules/" + name]["version"] for name in names}
|
||||
manifest = {"name": "ruvector-security-consumer", "version": "1.0.0",
|
||||
"private": True, "dependencies": dependencies,
|
||||
"overrides": {"qs": dependencies["qs"]}}
|
||||
lock = {"name": manifest["name"], "version": "1.0.0", "lockfileVersion": 3,
|
||||
"requires": True, "packages": {"": manifest, **dict(sorted(selected.items()))}}
|
||||
(target / "package.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
||||
(target / "package-lock.json").write_text(json.dumps(lock, indent=2) + "\n")
|
||||
shutil.copyfile(npm / "tests/security-dependency-consumer.cjs", target / "consumer.cjs")
|
||||
print(f"Projected {len(selected)} exact dependency records; install scripts remain disabled.")
|
||||
49
npm/tests/security-dependency-consumer.cjs
Normal file
49
npm/tests/security-dependency-consumer.cjs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
const assert = require('node:assert/strict');
|
||||
const http = require('node:http');
|
||||
const zlib = require('node:zlib');
|
||||
const axios = require('axios');
|
||||
const compression = require('compression');
|
||||
const qs = require('qs');
|
||||
const proxyaddr = require('proxy-addr');
|
||||
const {Address4, Address6} = require('ip-address');
|
||||
const copy = require('fast-copy').default;
|
||||
const {McpServer} = require('@modelcontextprotocol/sdk/server/mcp.js');
|
||||
const {Client} = require('@modelcontextprotocol/sdk/client/index.js');
|
||||
const {InMemoryTransport} = require('@modelcontextprotocol/sdk/inMemory.js');
|
||||
const {z} = require('zod');
|
||||
(async () => {
|
||||
assert.deepEqual(qs.parse('a[b]=1&__proto__[polluted]=yes'), {a:{b:'1'}});
|
||||
assert.equal({}.polluted, undefined);
|
||||
const source = {nested:{v:3}, array:[1,2]};
|
||||
const cloned = copy(source); cloned.nested.v=4;
|
||||
assert.equal(source.nested.v,3); assert.notEqual(cloned.array,source.array);
|
||||
assert.equal(new Address4('127.0.0.1').correctForm(),'127.0.0.1');
|
||||
assert.equal(new Address6('::1').correctForm(),'::1');
|
||||
const trusted=proxyaddr.compile('loopback');
|
||||
assert.equal(trusted('127.0.0.1'),true); assert.equal(trusted('8.8.8.8'),false);
|
||||
const middleware=compression({threshold:0});
|
||||
const payload='RuVector compression and axios contract '.repeat(200);
|
||||
const server=http.createServer((req,res)=>middleware(req,res,()=>{res.setHeader('content-type','text/plain');res.end(payload);}));
|
||||
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
|
||||
try {
|
||||
const url='http://127.0.0.1:'+server.address().port;
|
||||
const result=await axios.get(url,{proxy:false,timeout:3000});
|
||||
assert.equal(result.data,payload);
|
||||
const raw=await axios.get(url,{proxy:false,timeout:3000,decompress:false,responseType:'arraybuffer',headers:{'accept-encoding':'gzip'}});
|
||||
assert.equal(raw.headers['content-encoding'],'gzip');
|
||||
assert.equal(zlib.gunzipSync(raw.data).toString(),payload);
|
||||
} finally { await new Promise(resolve=>server.close(resolve)); }
|
||||
const sdkServer=new McpServer({name:'ruvector-contract',version:'1.0.0'});
|
||||
sdkServer.registerTool('echo',{inputSchema:{text:z.string().max(32)}},async({text})=>({content:[{type:'text',text}]}));
|
||||
const sdkClient=new Client({name:'contract-client',version:'1.0.0'});
|
||||
const [a,b]=InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await sdkServer.connect(a); await sdkClient.connect(b);
|
||||
const tools=await sdkClient.listTools(); assert.equal(tools.tools[0].name,'echo');
|
||||
const answer=await sdkClient.callTool({name:'echo',arguments:{text:'native-safe'}});
|
||||
assert.equal(answer.content[0].text,'native-safe');
|
||||
const invalid=await sdkClient.callTool({name:'echo',arguments:{text:'x'.repeat(33)}});
|
||||
assert.equal(invalid.isError,true);
|
||||
} finally {await sdkClient.close(); await sdkServer.close();}
|
||||
console.log('PASS: 7 dependency consumers; MCP tool roundtrip and validation, HTTP compression/axios, query pollution, copy isolation, IP/proxy trust');
|
||||
})().catch(error=>{console.error(error);process.exitCode=1;});
|
||||
|
|
@ -4,6 +4,19 @@
|
|||
|
||||
Python access to the ten most downloaded RuVector npm packages. Eight new companion distributions join the existing `ruvector` SDK and `ruvector-graph`. Previously released clustering and reranking remain available. Every companion has its own introduction, animated diagram, runnable getting started example and explicit capability limits.
|
||||
|
||||
## Release status, October 12, 2026
|
||||
|
||||
The 0.2.0 portfolio is **partially published**. Attention and cluster wheels are
|
||||
available; both source archives and nine target releases are still missing.
|
||||
The original files pin native `ruvector==0.1.3`. Current source targets 0.1.4
|
||||
and must not replace immutable 0.2.0 artifacts. See the
|
||||
[verified inventory and recovery evidence](../../docs/python-portfolio/evidence/2026-10-12/README.md)
|
||||
and [recovery design](../../docs/python-portfolio/resume-release-adr.md).
|
||||
|
||||
The published attention package has a
|
||||
[tested synthetic-data tutorial](tutorials/README.md). Other implemented tracks
|
||||
below describe source capabilities and do not establish PyPI availability.
|
||||
|
||||
## Which packages are most popular?
|
||||
|
||||
Measured npm downloads for September 11 through October 10, 2026. These are package retrievals, not unique users. Platform binary variants are excluded. RVF WASM remains a separate distribution because it provides a distinct portable execution backend. [Raw evidence and all source URLs](npm-popularity-2026-10-11.json).
|
||||
|
|
@ -26,7 +39,8 @@ Measured npm downloads for September 11 through October 10, 2026. These are pack
|
|||
```sh
|
||||
python -m venv .venv
|
||||
# Activate the environment using your platform's normal command.
|
||||
python -m pip install ruvector-core==0.2.0 ruvector-router==0.2.0
|
||||
python -m pip install ruvector-attention==0.2.0 ruvector==0.1.3 numpy==2.2.6
|
||||
# Run the published-package example in tutorials/attention_quickstart.py.
|
||||
```
|
||||
|
||||
Choose a task and open its package guide:
|
||||
|
|
|
|||
78
python/portfolio/evidence/2026-10-13-release-repair.md
Normal file
78
python/portfolio/evidence/2026-10-13-release-repair.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# Python portfolio recovery evidence — 2026-10-13
|
||||
|
||||
Owner: Codex /root. PR: https://github.com/ruvnet/RuVector/pull/1205.
|
||||
Repair the existing interrupted 0.2.0 release before selecting a new package.
|
||||
|
||||
## Immutable inputs
|
||||
|
||||
Source: fccf36e9a76d35bce6750dfd3b25802cae3d683b.
|
||||
Run: 38169271248; artifact: 11713787141.
|
||||
Artifact SHA256: b4c92cae86c753305abef7aad395fdd9d91c7e84c3c887342defa7a3e4607241.
|
||||
Inventory SHA256: ca4c4b47df73a26619c048ed1eb02013bfd96484aeb88e20fe16464ef1e0a32d.
|
||||
|
||||
At 11:25 UTC only attention and cluster 0.2.0 wheels matched the 22-file
|
||||
inventory on PyPI; 20 files were missing. No upload was attempted during these
|
||||
repairs. The native dependency remains exactly ruvector 0.1.3.
|
||||
This checkpoint does not claim publication.
|
||||
|
||||
## Repair evidence
|
||||
|
||||
- Run https://github.com/ruvnet/RuVector/actions/runs/38390477781:
|
||||
62 recovery contracts, frozen native audit, Linux and macOS installed-artifact
|
||||
acceptance passed. Windows stopped on inventory hash mismatch.
|
||||
- A scoped LF checkout rule preserves the byte-pinned inventory. A clean Git
|
||||
index checkout with core.autocrlf=true retained its exact SHA256. The strict
|
||||
hash check remains unchanged.
|
||||
- PRs now run all three preparation platforms and the frozen audit. Publication
|
||||
still requires explicit main dispatch, passing prerequisites and protected
|
||||
pypi environment.
|
||||
- Seven targeted npm upgrades clear npm audit. Seven functional consumer groups
|
||||
pass against a clean 144-record affected dependency closure. Reproduce with
|
||||
npm/tests/prepare-security-consumer.py and security-dependency-consumer.cjs.
|
||||
Full workspace installation is not claimed: unrelated lock drift remains.
|
||||
- Quinn 0.11.18 replaces 0.11.15 for five RustSec advisories with only required
|
||||
RNG changes. The original SDK all-target normal/build closure contains
|
||||
neither Quinn nor Reqwest. Local Cargo 1.90 hit an internal resolver panic
|
||||
before compilation; hosted current-stable checks remain required.
|
||||
- Replication releases its DashMap guard before accessing another entry.
|
||||
All 27 tests pass; the new same-shard regression fails within five seconds
|
||||
against the original implementation.
|
||||
- Server readiness follows socket binding. The strict test retains its child
|
||||
stdout pipe through requests. All six tests pass; the affected HTTP case
|
||||
passed 50 fresh-process runs.
|
||||
- Only core-and-rest CI moves to Ubuntu 24.04 for ORT ABI requirements.
|
||||
Hosted whole-shard validation remains required.
|
||||
|
||||
An independent review agent approved these code, dependency, checkout and
|
||||
workflow changes. No assertions or release protections were weakened.
|
||||
Additional failures must be repaired and reviewed before promotion.
|
||||
|
||||
## Additional gate repair
|
||||
|
||||
Head fbf30da67aa3da87db6c8c34a6124f5a499d904d passed Windows
|
||||
recovery preflight and both hosted Cargo vulnerability audits.
|
||||
The remaining cargo-deny failure identified unmaintained unsafe-libyaml.
|
||||
|
||||
Only rvagent-middleware used serde_yaml directly. It now uses pinned
|
||||
serde-saphyr 1.3.0 (MIT OR Apache-2.0) with only deserialization enabled.
|
||||
The crate explicitly declares Rust 1.89; the frozen Python SDK is unaffected.
|
||||
A private visitor retains string-only metadata without numeric-key coercion.
|
||||
Existing 4KiB frontmatter bounds are supplemented by depth, alias, node,
|
||||
event and scalar expansion limits. The exact-source parser harness passed
|
||||
18 existing and five new compatibility/adversarial tests; this excludes
|
||||
three middleware integration tests, so full hosted crate CI remains required.
|
||||
An independent review approved the parser, feature restrictions and MSRV
|
||||
declaration. No advisory exception was added.
|
||||
|
||||
## Promotion and rollback
|
||||
|
||||
Do not merge or publish with failed or pending required checks. Reconcile PyPI
|
||||
under the publication lock; upload only byte-identical missing originals,
|
||||
stop on any failure and never blindly retry an uncertain upload. Then verify
|
||||
all 22 hashes and fresh PyPI-only installation on Linux, macOS and Windows.
|
||||
Existing tutorial: https://gist.github.com/ruvnet/2491c7631bf3e186e815196f52fd8954.
|
||||
|
||||
Code rollback is a reviewed revert of PR1205. PyPI files cannot be overwritten.
|
||||
On post-release failure stop promotion, assess yanking with repository owners,
|
||||
and release a separately reviewed patch version. Never replace frozen 0.2.0
|
||||
files with rebuilt artifacts.
|
||||
120
python/portfolio/frozen-release-0.2.0.json
Normal file
120
python/portfolio/frozen-release-0.2.0.json
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
{
|
||||
"version": "0.2.0",
|
||||
"runtime": "0.1.3",
|
||||
"source_sha": "fccf36e9a76d35bce6750dfd3b25802cae3d683b",
|
||||
"run_id": 38169271248,
|
||||
"artifact_id": 11713787141,
|
||||
"archive_sha256": "b4c92cae86c753305abef7aad395fdd9d91c7e84c3c887342defa7a3e4607241",
|
||||
"files": [
|
||||
{
|
||||
"filename": "ruvector_attention-0.2.0-py3-none-any.whl",
|
||||
"sha256": "48c6557e4fc53c41aeb24b832e3e167afe88fde6c17affbb31cd6da148972bdc",
|
||||
"bytes": 3629
|
||||
},
|
||||
{
|
||||
"filename": "ruvector_attention-0.2.0.tar.gz",
|
||||
"sha256": "767881ffe1e0023ed2866ccbb57fdab1c985149ec89d1c003e2e737d7327aa70",
|
||||
"bytes": 2865
|
||||
},
|
||||
{
|
||||
"filename": "ruvector_cluster-0.2.0-py3-none-any.whl",
|
||||
"sha256": "99efcf69e29c9a11e47599ea37a725496e11fa88b169224885e23efa0d6ec3b8",
|
||||
"bytes": 3994
|
||||
},
|
||||
{
|
||||
"filename": "ruvector_cluster-0.2.0.tar.gz",
|
||||
"sha256": "09e0db4fc6f19a6b0e45b7906c5647adb24917dd5a3b2fa59a282a3629333635",
|
||||
"bytes": 3269
|
||||
},
|
||||
{
|
||||
"filename": "ruvector_core-0.2.0-py3-none-any.whl",
|
||||
"sha256": "c269a9cd41dbf6b91900e1709f31e1576560b51e70a553adfeb219d108c1cd89",
|
||||
"bytes": 4099
|
||||
},
|
||||
{
|
||||
"filename": "ruvector_core-0.2.0.tar.gz",
|
||||
"sha256": "24a62d2a0b08f4a4a5f1470922f04c8727a75ea36fb4aea3691d087f0d3c5630",
|
||||
"bytes": 3384
|
||||
},
|
||||
{
|
||||
"filename": "ruvector_gnn-0.2.0-py3-none-any.whl",
|
||||
"sha256": "4e5e013e3ba87205d2acaabaf1420ada8a37cb9bb8281168395c741c5c6eedcb",
|
||||
"bytes": 4094
|
||||
},
|
||||
{
|
||||
"filename": "ruvector_gnn-0.2.0.tar.gz",
|
||||
"sha256": "46706f979d8d45a9d0eb19005f595aa2fe48d88804d04e99acbe22f139a6d100",
|
||||
"bytes": 3366
|
||||
},
|
||||
{
|
||||
"filename": "ruvector_graph-0.2.0-py3-none-any.whl",
|
||||
"sha256": "2278acfa6255df9bd961c80ebb36fa28621975b36eb7fb41d992b7b5a921b8cc",
|
||||
"bytes": 4028
|
||||
},
|
||||
{
|
||||
"filename": "ruvector_graph-0.2.0.tar.gz",
|
||||
"sha256": "5410b43357250b3719f8c7f700b4cad9e99242bc53532141085ebfb08565ccde",
|
||||
"bytes": 3319
|
||||
},
|
||||
{
|
||||
"filename": "ruvector_rerank-0.2.0-py3-none-any.whl",
|
||||
"sha256": "5990bbcc2fabab087480740770f240f10bf65c2bf6400aba6911c27079a9cea4",
|
||||
"bytes": 4063
|
||||
},
|
||||
{
|
||||
"filename": "ruvector_rerank-0.2.0.tar.gz",
|
||||
"sha256": "839921d2d7d14387cc572bb210cc356c23b84d2946706dc7432e87afb1bcf87c",
|
||||
"bytes": 3344
|
||||
},
|
||||
{
|
||||
"filename": "ruvector_router-0.2.0-py3-none-any.whl",
|
||||
"sha256": "7066ec5b35e3cfb1f5dbb902b87bed18aa85ad10b76f085137e823b5ac0e4938",
|
||||
"bytes": 4368
|
||||
},
|
||||
{
|
||||
"filename": "ruvector_router-0.2.0.tar.gz",
|
||||
"sha256": "10c79f3a0f7c86820e7128b9a1e3d426b66f22f248a7eb2a1c92ba891a02eac5",
|
||||
"bytes": 3579
|
||||
},
|
||||
{
|
||||
"filename": "ruvector_ruvllm-0.2.0-py3-none-any.whl",
|
||||
"sha256": "ccad8d79a43811148908a97e161f2eebcadbe1ab8975aaa266a0824c3d3cc720",
|
||||
"bytes": 5085
|
||||
},
|
||||
{
|
||||
"filename": "ruvector_ruvllm-0.2.0.tar.gz",
|
||||
"sha256": "860a882d5403789c9da73f51e9177a79139eb66617627cb454982da39e4fed4a",
|
||||
"bytes": 4270
|
||||
},
|
||||
{
|
||||
"filename": "ruvector_rvf-0.2.0-py3-none-any.whl",
|
||||
"sha256": "7003a7866227bc4ad336c77f677456752c4bc0f6acf37843c6effd94749d496e",
|
||||
"bytes": 4156
|
||||
},
|
||||
{
|
||||
"filename": "ruvector_rvf-0.2.0.tar.gz",
|
||||
"sha256": "4d2902065f1212ffbc4861bc316dbc9f7b928397e0840682e8d8ef246527624f",
|
||||
"bytes": 3520
|
||||
},
|
||||
{
|
||||
"filename": "ruvector_rvf_wasm-0.2.0-py3-none-any.whl",
|
||||
"sha256": "17bb6bbdb86595c9644a2cac6cb2e631fa2341eb9f87235f2105324aef989e57",
|
||||
"bytes": 24238
|
||||
},
|
||||
{
|
||||
"filename": "ruvector_rvf_wasm-0.2.0.tar.gz",
|
||||
"sha256": "566d5d46365188e1493cdace52020f5d9da38232b79580a6b946e549d8cd8028",
|
||||
"bytes": 23623
|
||||
},
|
||||
{
|
||||
"filename": "ruvector_sona-0.2.0-py3-none-any.whl",
|
||||
"sha256": "bbf005d0438458a6d1dfdd78e9553eee9cda7933af059450ca7202188eafa4ce",
|
||||
"bytes": 3675
|
||||
},
|
||||
{
|
||||
"filename": "ruvector_sona-0.2.0.tar.gz",
|
||||
"sha256": "23db0420e9b1eff2d8c01a61eb84dd1f97e46787a9b0a0ac73cf3908458cd273",
|
||||
"bytes": 2980
|
||||
}
|
||||
]
|
||||
}
|
||||
445
python/portfolio/resume_release.py
Normal file
445
python/portfolio/resume_release.py
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Select missing ORIGINAL 0.2.0 artifacts only after a read-only PyPI precheck.
|
||||
|
||||
No rebuilding, uploading, credentials, or skip-existing behavior. All artifacts
|
||||
must match the frozen successful CI inventory, including already published files.
|
||||
Runs in an owned CI workspace. POSIX write-mode checks do not model Windows ACLs;
|
||||
Windows relies on the runner workspace ACL boundary. This is not a sandbox against
|
||||
concurrent malicious processes or untrusted Windows junctions on older Python.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import email
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import tarfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
VERSION = "0.2.0"
|
||||
RUNTIME = "0.1.3"
|
||||
SOURCE_SHA = "fccf36e9a76d35bce6750dfd3b25802cae3d683b"
|
||||
SOURCE_RUN = 38169271248
|
||||
SOURCE_ARTIFACT = 11713787141
|
||||
ARCHIVE_SHA256 = "b4c92cae86c753305abef7aad395fdd9d91c7e84c3c887342defa7a3e4607241"
|
||||
PACKAGES = tuple(
|
||||
"ruvector-" + name
|
||||
for name in (
|
||||
"attention",
|
||||
"cluster",
|
||||
"core",
|
||||
"gnn",
|
||||
"graph",
|
||||
"rerank",
|
||||
"router",
|
||||
"ruvllm",
|
||||
"rvf",
|
||||
"rvf-wasm",
|
||||
"sona",
|
||||
)
|
||||
)
|
||||
MAX_FILE_BYTES = 8 * 1024 * 1024
|
||||
MAX_TOTAL_BYTES = 64 * 1024 * 1024
|
||||
MAX_MEMBERS = 256
|
||||
MAX_EXPANDED_BYTES = 16 * 1024 * 1024
|
||||
MAX_METADATA_BYTES = 256 * 1024
|
||||
MAX_RESPONSE_BYTES = 2 * 1024 * 1024
|
||||
# Filled from the immutable CI artifact, never from the live working tree.
|
||||
INVENTORY_SHA256 = "ca4c4b47df73a26619c048ed1eb02013bfd96484aeb88e20fe16464ef1e0a32d"
|
||||
DEFAULT_INVENTORY = Path(__file__).with_name("frozen-release-0.2.0.json")
|
||||
|
||||
|
||||
class GateError(ValueError):
|
||||
"""Sanitized permanent validation failure."""
|
||||
|
||||
|
||||
class RegistryError(RuntimeError):
|
||||
"""Unavailable registry; retry only after fresh read-only inspection."""
|
||||
|
||||
|
||||
def expected_files() -> dict[str, str]:
|
||||
return {
|
||||
filename: package
|
||||
for package in PACKAGES
|
||||
for filename in (
|
||||
f"{package.replace('-', '_')}-{VERSION}-py3-none-any.whl",
|
||||
f"{package.replace('-', '_')}-{VERSION}.tar.gz",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def canonical(name: str) -> str:
|
||||
return re.sub(r"[-_.]+", "-", name).lower()
|
||||
|
||||
|
||||
def safe_path(path: Path) -> None:
|
||||
"""Refuse symlink components; callers must own their working directories."""
|
||||
absolute = path.absolute()
|
||||
for component in (absolute, *absolute.parents):
|
||||
if component.is_symlink():
|
||||
raise GateError("symlink path component")
|
||||
|
||||
|
||||
def read_regular(path: Path) -> bytes:
|
||||
safe_path(path)
|
||||
try:
|
||||
preliminary = path.lstat()
|
||||
except OSError as exc:
|
||||
raise GateError("artifact unavailable") from exc
|
||||
if not stat.S_ISREG(preliminary.st_mode):
|
||||
raise GateError("artifact must be a regular file")
|
||||
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
|
||||
try:
|
||||
fd = os.open(path, flags)
|
||||
except OSError as exc:
|
||||
raise GateError("artifact cannot be opened safely") from exc
|
||||
with os.fdopen(fd, "rb") as stream:
|
||||
info = os.fstat(stream.fileno())
|
||||
if (
|
||||
not stat.S_ISREG(info.st_mode)
|
||||
or info.st_nlink != 1
|
||||
or info.st_size <= 0
|
||||
or info.st_size > MAX_FILE_BYTES
|
||||
):
|
||||
raise GateError("artifact type, links, or size exceeds bounds")
|
||||
data = stream.read(MAX_FILE_BYTES + 1)
|
||||
if len(data) != info.st_size or len(data) > MAX_FILE_BYTES:
|
||||
raise GateError("artifact changed or exceeds bounds")
|
||||
return data
|
||||
|
||||
|
||||
def member_path(name: str, prefix: str, *, gitignore: bool = False) -> None:
|
||||
if not isinstance(name, str) or "\\" in name or "\x00" in name:
|
||||
raise GateError("unsafe archive path")
|
||||
parts = PurePosixPath(name).parts
|
||||
if not name.startswith(prefix) or name.startswith("/") or ".." in parts:
|
||||
raise GateError("unexpected archive path")
|
||||
if any(
|
||||
part in {"__pycache__", "node_modules", "target"}
|
||||
or (part.startswith(".") and not (gitignore and name == prefix + ".gitignore"))
|
||||
for part in parts
|
||||
):
|
||||
raise GateError("forbidden archive content")
|
||||
if PurePosixPath(name).suffix in {".pem", ".key", ".so", ".pyd"}:
|
||||
raise GateError("forbidden archive content")
|
||||
|
||||
|
||||
def metadata(data: bytes, package: str) -> None:
|
||||
if len(data) > MAX_METADATA_BYTES:
|
||||
raise GateError("metadata exceeds bounds")
|
||||
parsed = email.message_from_bytes(data)
|
||||
if parsed.get_all("Name") != [package] or parsed.get_all("Version") != [VERSION]:
|
||||
raise GateError("distribution metadata identity mismatch")
|
||||
runtime_requirements = [
|
||||
value
|
||||
for value in parsed.get_all("Requires-Dist", [])
|
||||
if re.match(r"^ruvector(?:\s|\(|[<>=!~;\[]|$)", value, re.I)
|
||||
]
|
||||
if len(runtime_requirements) != 1 or not re.fullmatch(
|
||||
r"ruvector\s*(?:\(\s*==\s*0\.1\.3\s*\)|==\s*0\.1\.3)\s*",
|
||||
runtime_requirements[0],
|
||||
re.I,
|
||||
):
|
||||
raise GateError("distribution runtime must be exactly ruvector==0.1.3")
|
||||
|
||||
|
||||
def validate_archive(path: Path, package: str) -> None:
|
||||
normalized = package.replace("-", "_")
|
||||
if path.name.endswith(".whl"):
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
members = archive.infolist()
|
||||
if len(members) > MAX_MEMBERS or len({m.filename for m in members}) != len(
|
||||
members
|
||||
):
|
||||
raise GateError("archive has too many or duplicate members")
|
||||
if sum(m.file_size for m in members) > MAX_EXPANDED_BYTES:
|
||||
raise GateError("archive expanded size exceeds bounds")
|
||||
found = []
|
||||
for member in members:
|
||||
mode = member.external_attr >> 16
|
||||
if stat.S_ISLNK(mode) or member.flag_bits & 1:
|
||||
raise GateError("wheel symlink or encrypted content")
|
||||
prefix = normalized + "/"
|
||||
if member.filename.startswith(f"{normalized}-{VERSION}.dist-info/"):
|
||||
prefix = f"{normalized}-{VERSION}.dist-info/"
|
||||
member_path(member.filename, prefix)
|
||||
if member.filename.endswith(".dist-info/METADATA"):
|
||||
if member.file_size > MAX_METADATA_BYTES:
|
||||
raise GateError("metadata exceeds bounds")
|
||||
found.append(archive.read(member))
|
||||
if len(found) != 1:
|
||||
raise GateError("missing or ambiguous wheel metadata")
|
||||
metadata(found[0], package)
|
||||
else:
|
||||
with tarfile.open(path, mode="r:gz") as archive:
|
||||
count, expanded, found = 0, 0, []
|
||||
prefix = f"{normalized}-{VERSION}/"
|
||||
allowed = {
|
||||
"src",
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
"pyproject.toml",
|
||||
"PKG-INFO",
|
||||
".gitignore",
|
||||
}
|
||||
seen = set()
|
||||
for member in archive:
|
||||
count += 1
|
||||
expanded += member.size
|
||||
if count > MAX_MEMBERS or expanded > MAX_EXPANDED_BYTES:
|
||||
raise GateError("sdist allocation exceeds bounds")
|
||||
if member.name in seen or not (member.isfile() or member.isdir()):
|
||||
raise GateError("duplicate or unsafe sdist member")
|
||||
seen.add(member.name)
|
||||
member_path(member.name, prefix, gitignore=True)
|
||||
if member.name[len(prefix) :].split("/")[0] not in allowed:
|
||||
raise GateError("unexpected sdist content")
|
||||
if member.name == prefix + "PKG-INFO":
|
||||
if member.size > MAX_METADATA_BYTES:
|
||||
raise GateError("metadata exceeds bounds")
|
||||
stream = archive.extractfile(member)
|
||||
if stream is None:
|
||||
raise GateError("missing sdist metadata")
|
||||
found.append(stream.read(MAX_METADATA_BYTES + 1))
|
||||
if len(found) != 1:
|
||||
raise GateError("missing or ambiguous sdist metadata")
|
||||
metadata(found[0], package)
|
||||
|
||||
|
||||
def fetch_json(package: str, version: str) -> dict | None:
|
||||
request = urllib.request.Request(
|
||||
f"https://pypi.org/pypi/{package}/{version}/json",
|
||||
headers={"User-Agent": "RuVector-frozen-release-precheck/1"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
if response.status != 200:
|
||||
raise RegistryError("unexpected registry response")
|
||||
data = response.read(MAX_RESPONSE_BYTES + 1)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
return None
|
||||
raise RegistryError(f"registry HTTP {exc.code}; no files selected") from exc
|
||||
except OSError as exc:
|
||||
raise RegistryError("registry unavailable; no files selected") from exc
|
||||
if len(data) > MAX_RESPONSE_BYTES:
|
||||
raise RegistryError("registry response exceeds bounds")
|
||||
try:
|
||||
result = json.loads(data)
|
||||
except (ValueError, UnicodeError) as exc:
|
||||
raise RegistryError("malformed registry response") from exc
|
||||
if not isinstance(result, dict):
|
||||
raise RegistryError("invalid registry response")
|
||||
return result
|
||||
|
||||
|
||||
def registry_files(data: dict | None, package: str, version: str) -> list[dict]:
|
||||
if data is None:
|
||||
return []
|
||||
if not isinstance(data, dict):
|
||||
raise GateError("invalid registry metadata")
|
||||
info, urls = data.get("info"), data.get("urls")
|
||||
if (
|
||||
not isinstance(info, dict)
|
||||
or not isinstance(info.get("name"), str)
|
||||
or canonical(info["name"]) != package
|
||||
or info.get("version") != version
|
||||
or not isinstance(urls, list)
|
||||
or not urls
|
||||
or len(urls) > 128
|
||||
):
|
||||
raise GateError("registry identity, version, or file inventory mismatch")
|
||||
seen = set()
|
||||
for item in urls:
|
||||
if (
|
||||
not isinstance(item, dict)
|
||||
or not isinstance(item.get("filename"), str)
|
||||
or item["filename"] in seen
|
||||
or item.get("yanked") is not False
|
||||
or not isinstance(item.get("digests"), dict)
|
||||
or not isinstance(item["digests"].get("sha256"), str)
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", item["digests"]["sha256"])
|
||||
):
|
||||
raise GateError("registry duplicate, yanked, or invalid file")
|
||||
seen.add(item["filename"])
|
||||
return urls
|
||||
|
||||
|
||||
def load_inventory(path: Path) -> dict[str, str]:
|
||||
data = read_regular(path)
|
||||
if hashlib.sha256(data).hexdigest() != INVENTORY_SHA256:
|
||||
raise GateError("frozen inventory hash mismatch")
|
||||
try:
|
||||
inventory = json.loads(data)
|
||||
except ValueError as exc:
|
||||
raise GateError("invalid frozen inventory") from exc
|
||||
required = {
|
||||
"version": VERSION,
|
||||
"runtime": RUNTIME,
|
||||
"source_sha": SOURCE_SHA,
|
||||
"run_id": SOURCE_RUN,
|
||||
"artifact_id": SOURCE_ARTIFACT,
|
||||
"archive_sha256": ARCHIVE_SHA256,
|
||||
}
|
||||
if not isinstance(inventory, dict) or any(
|
||||
inventory.get(k) != v for k, v in required.items()
|
||||
):
|
||||
raise GateError("frozen inventory source identity mismatch")
|
||||
files = inventory.get("files")
|
||||
if not isinstance(files, list) or len(files) != 22:
|
||||
raise GateError("frozen inventory file count mismatch")
|
||||
result = {}
|
||||
for item in files:
|
||||
if (
|
||||
not isinstance(item, dict)
|
||||
or item.get("filename") not in expected_files()
|
||||
or item["filename"] in result
|
||||
or not isinstance(item.get("sha256"), str)
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", item["sha256"])
|
||||
or type(item.get("bytes")) is not int
|
||||
or not 0 < item["bytes"] <= MAX_FILE_BYTES
|
||||
):
|
||||
raise GateError("invalid frozen inventory entry")
|
||||
result[item["filename"]] = item["sha256"]
|
||||
if (
|
||||
set(result) != set(expected_files())
|
||||
or sum(item["bytes"] for item in files) > MAX_TOTAL_BYTES
|
||||
):
|
||||
raise GateError("frozen inventory file set or size mismatch")
|
||||
return result
|
||||
|
||||
|
||||
def directory_mode_safe(mode: int, platform: str = os.name) -> bool:
|
||||
"""POSIX mode checks; Windows trusts the CI workspace ACL boundary."""
|
||||
return platform == "nt" or not (mode & 0o022)
|
||||
|
||||
|
||||
def precheck(
|
||||
directory: Path, *, inventory=DEFAULT_INVENTORY, fetcher=fetch_json
|
||||
) -> tuple[dict, dict[str, bytes]]:
|
||||
safe_path(directory)
|
||||
if not directory.is_dir() or not directory_mode_safe(directory.stat().st_mode):
|
||||
raise GateError("artifact directory must be private to its owner")
|
||||
expected = expected_files()
|
||||
frozen = load_inventory(inventory)
|
||||
if {p.name for p in directory.iterdir()} != set(expected):
|
||||
raise GateError("missing or unexpected release artifacts")
|
||||
contents = {}
|
||||
total = 0
|
||||
for filename, package in sorted(expected.items()):
|
||||
data = read_regular(directory / filename)
|
||||
total += len(data)
|
||||
if total > MAX_TOTAL_BYTES:
|
||||
raise GateError("total artifact size exceeds bounds")
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
if digest != frozen[filename]:
|
||||
raise GateError(f"frozen artifact hash mismatch: {filename}")
|
||||
try:
|
||||
validate_archive(directory / filename, package)
|
||||
except (OSError, zipfile.BadZipFile, tarfile.TarError, RuntimeError) as exc:
|
||||
raise GateError("invalid distribution archive") from exc
|
||||
contents[filename] = data
|
||||
native = registry_files(fetcher("ruvector", RUNTIME), "ruvector", RUNTIME)
|
||||
if not native:
|
||||
raise GateError("required native runtime is not published")
|
||||
published = []
|
||||
for package in PACKAGES:
|
||||
files = registry_files(fetcher(package, VERSION), package, VERSION)
|
||||
package_expected = {
|
||||
name for name, owner in expected.items() if owner == package
|
||||
}
|
||||
for item in files:
|
||||
filename = item["filename"]
|
||||
if filename not in package_expected:
|
||||
raise GateError("unknown registry artifact")
|
||||
if item["digests"]["sha256"] != frozen[filename]:
|
||||
raise GateError(f"published artifact hash mismatch: {filename}")
|
||||
published.append(filename)
|
||||
missing = sorted(set(expected) - set(published))
|
||||
plan = {
|
||||
"schema": 1,
|
||||
"operation": "precheck-only",
|
||||
"version": VERSION,
|
||||
"runtime": RUNTIME,
|
||||
"source_sha": SOURCE_SHA,
|
||||
"source_run": SOURCE_RUN,
|
||||
"source_artifact": SOURCE_ARTIFACT,
|
||||
"archive_sha256": ARCHIVE_SHA256,
|
||||
"published": sorted(published),
|
||||
"missing": missing,
|
||||
"files": {name: frozen[name] for name in sorted(expected)},
|
||||
"promotion": "independent-review-and-protected-environment-required",
|
||||
}
|
||||
return plan, {name: contents[name] for name in missing}
|
||||
|
||||
|
||||
def stage(
|
||||
directory: Path, output: Path, *, inventory=DEFAULT_INVENTORY, fetcher=fetch_json
|
||||
) -> dict:
|
||||
"""Write a new directory only after every local and registry check passes."""
|
||||
safe_path(output)
|
||||
if output.exists():
|
||||
raise GateError("staging destination already exists")
|
||||
plan, files = precheck(directory, inventory=inventory, fetcher=fetcher)
|
||||
output.mkdir(mode=0o700)
|
||||
try:
|
||||
for name, data in files.items():
|
||||
with (output / name).open("xb") as stream:
|
||||
stream.write(data)
|
||||
except BaseException:
|
||||
shutil.rmtree(output)
|
||||
raise
|
||||
return plan
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--dist", required=True, type=Path)
|
||||
parser.add_argument("--inventory", type=Path, default=DEFAULT_INVENTORY)
|
||||
parser.add_argument(
|
||||
"--missing-dir", type=Path, help="new directory for missing distributions only"
|
||||
)
|
||||
parser.add_argument("--receipt", type=Path, help="new JSON receipt file")
|
||||
parser.add_argument(
|
||||
"--verify-only",
|
||||
action="store_true",
|
||||
help="require every original file published",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.verify_only and args.missing_dir:
|
||||
raise GateError("verify-only cannot select files")
|
||||
if args.receipt:
|
||||
safe_path(args.receipt)
|
||||
if args.receipt.exists():
|
||||
raise GateError("receipt destination already exists")
|
||||
result = (
|
||||
stage(args.dist, args.missing_dir, inventory=args.inventory)
|
||||
if args.missing_dir
|
||||
else precheck(args.dist, inventory=args.inventory)[0]
|
||||
)
|
||||
if args.verify_only and result["missing"]:
|
||||
raise GateError("original distributions remain unpublished")
|
||||
if args.receipt:
|
||||
with args.receipt.open("x", encoding="utf-8") as stream:
|
||||
json.dump(
|
||||
{"status": "validated", **result}, stream, indent=2, sort_keys=True
|
||||
)
|
||||
stream.write("\n")
|
||||
except (GateError, RegistryError) as exc:
|
||||
print(json.dumps({"status": "blocked", "reason": str(exc)}))
|
||||
return 1
|
||||
print(json.dumps({"status": "validated", **result}, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
486
python/portfolio/tests/test_resume_release.py
Normal file
486
python/portfolio/tests/test_resume_release.py
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
import copy
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tarfile
|
||||
import urllib.error
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"resume", Path(__file__).parents[1] / "resume_release.py"
|
||||
)
|
||||
resume = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(resume)
|
||||
|
||||
|
||||
def digest(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def distribution_metadata(package, runtime="0.1.3"):
|
||||
return f"Metadata-Version: 2.4\nName: {package}\nVersion: 0.2.0\nRequires-Dist: ruvector=={runtime}\n\n".encode()
|
||||
|
||||
|
||||
def write_wheel(path, package, extra=None, runtime="0.1.3"):
|
||||
normalized = package.replace("-", "_")
|
||||
with zipfile.ZipFile(path, "w") as archive:
|
||||
archive.writestr(
|
||||
f"{normalized}-0.2.0.dist-info/METADATA",
|
||||
distribution_metadata(package, runtime),
|
||||
)
|
||||
archive.writestr(f"{normalized}/__init__.py", "# synthetic fixture\n")
|
||||
if extra:
|
||||
archive.writestr(extra, "bad")
|
||||
|
||||
|
||||
def write_sdist(path, package, extra=None, runtime="0.1.3"):
|
||||
normalized = package.replace("-", "_")
|
||||
with tarfile.open(path, "w:gz") as archive:
|
||||
member = tarfile.TarInfo(f"{normalized}-0.2.0/PKG-INFO")
|
||||
data = distribution_metadata(package, runtime)
|
||||
member.size = len(data)
|
||||
archive.addfile(member, io.BytesIO(data))
|
||||
if extra:
|
||||
archive.addfile(extra)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def release(tmp_path, monkeypatch):
|
||||
directory = tmp_path / "dist"
|
||||
directory.mkdir()
|
||||
inventory = tmp_path / "inventory.json"
|
||||
for filename, package in resume.expected_files().items():
|
||||
path = directory / filename
|
||||
(write_wheel if filename.endswith(".whl") else write_sdist)(path, package)
|
||||
|
||||
def freeze():
|
||||
data = {
|
||||
"version": resume.VERSION,
|
||||
"runtime": resume.RUNTIME,
|
||||
"source_sha": resume.SOURCE_SHA,
|
||||
"run_id": resume.SOURCE_RUN,
|
||||
"artifact_id": resume.SOURCE_ARTIFACT,
|
||||
"archive_sha256": resume.ARCHIVE_SHA256,
|
||||
"files": [
|
||||
{
|
||||
"filename": p.name,
|
||||
"bytes": p.stat().st_size,
|
||||
"sha256": digest(p.read_bytes()),
|
||||
}
|
||||
for p in sorted(directory.iterdir())
|
||||
if p.is_file()
|
||||
],
|
||||
}
|
||||
inventory.write_text(json.dumps(data))
|
||||
monkeypatch.setattr(resume, "INVENTORY_SHA256", digest(inventory.read_bytes()))
|
||||
return data
|
||||
|
||||
frozen = freeze()
|
||||
native = {
|
||||
"info": {"name": "ruvector", "version": "0.1.3"},
|
||||
"urls": [
|
||||
{"filename": "native.whl", "yanked": False, "digests": {"sha256": "a" * 64}}
|
||||
],
|
||||
}
|
||||
registry = {"ruvector": native}
|
||||
for package in resume.PACKAGES:
|
||||
registry[package] = {
|
||||
"info": {"name": package, "version": "0.2.0"},
|
||||
"urls": [
|
||||
{
|
||||
"filename": f["filename"],
|
||||
"yanked": False,
|
||||
"digests": {"sha256": f["sha256"]},
|
||||
}
|
||||
for f in frozen["files"]
|
||||
if resume.expected_files()[f["filename"]] == package
|
||||
],
|
||||
}
|
||||
|
||||
def fetcher(package, version):
|
||||
return registry.get(package)
|
||||
|
||||
return directory, inventory, registry, fetcher, freeze
|
||||
|
||||
|
||||
def test_complete_release_selects_nothing(release, tmp_path):
|
||||
directory, inventory, _, fetcher, _ = release
|
||||
output = tmp_path / "missing"
|
||||
plan = resume.stage(directory, output, inventory=inventory, fetcher=fetcher)
|
||||
assert not plan["missing"] and len(plan["published"]) == 22
|
||||
assert list(output.iterdir()) == []
|
||||
|
||||
|
||||
def test_partial_release_selects_only_absent_files(release, tmp_path):
|
||||
directory, inventory, registry, fetcher, _ = release
|
||||
first = resume.PACKAGES[0]
|
||||
missing = registry[first]["urls"].pop()["filename"]
|
||||
del registry[resume.PACKAGES[1]]
|
||||
output = tmp_path / "missing"
|
||||
plan = resume.stage(directory, output, inventory=inventory, fetcher=fetcher)
|
||||
assert len(plan["missing"]) == 3 and missing in plan["missing"]
|
||||
assert {p.name for p in output.iterdir()} == set(plan["missing"])
|
||||
assert all(
|
||||
digest(p.read_bytes()) == plan["files"][p.name] for p in output.iterdir()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fault",
|
||||
[
|
||||
"hash",
|
||||
"unknown",
|
||||
"yanked",
|
||||
"yanked_missing",
|
||||
"wrong_version",
|
||||
"wrong_name",
|
||||
"duplicate",
|
||||
"digest_missing",
|
||||
"upper_digest",
|
||||
"empty_urls",
|
||||
"wrong_urls_type",
|
||||
],
|
||||
)
|
||||
def test_registry_faults_write_no_selection(release, tmp_path, fault):
|
||||
directory, inventory, registry, fetcher, _ = release
|
||||
data = registry[resume.PACKAGES[-1]]
|
||||
if fault == "hash":
|
||||
data["urls"][0]["digests"]["sha256"] = "a" * 64
|
||||
elif fault == "unknown":
|
||||
data["urls"][0]["filename"] = "unknown.whl"
|
||||
elif fault == "yanked":
|
||||
data["urls"][0]["yanked"] = True
|
||||
elif fault == "yanked_missing":
|
||||
data["urls"][0].pop("yanked")
|
||||
elif fault in {"wrong_version", "wrong_name"}:
|
||||
data["info"]["version" if fault == "wrong_version" else "name"] = "unexpected"
|
||||
elif fault == "duplicate":
|
||||
data["urls"].append(copy.deepcopy(data["urls"][0]))
|
||||
elif fault == "digest_missing":
|
||||
data["urls"][0]["digests"] = {}
|
||||
elif fault == "upper_digest":
|
||||
data["urls"][0]["digests"]["sha256"] = "A" * 64
|
||||
elif fault == "empty_urls":
|
||||
data["urls"] = []
|
||||
else:
|
||||
data["urls"] = {}
|
||||
output = tmp_path / "missing"
|
||||
with pytest.raises(resume.GateError):
|
||||
resume.stage(directory, output, inventory=inventory, fetcher=fetcher)
|
||||
assert not output.exists()
|
||||
|
||||
|
||||
def test_late_registry_outage_writes_nothing(release, tmp_path):
|
||||
directory, inventory, registry, fetcher, _ = release
|
||||
del registry[resume.PACKAGES[0]]
|
||||
|
||||
def outage(package, version):
|
||||
if package == resume.PACKAGES[-1]:
|
||||
raise resume.RegistryError("registry HTTP 429")
|
||||
return fetcher(package, version)
|
||||
|
||||
output = tmp_path / "missing"
|
||||
with pytest.raises(resume.RegistryError):
|
||||
resume.stage(directory, output, inventory=inventory, fetcher=outage)
|
||||
assert not output.exists()
|
||||
|
||||
|
||||
def test_native_runtime_required(release):
|
||||
directory, inventory, registry, fetcher, _ = release
|
||||
del registry["ruvector"]
|
||||
with pytest.raises(resume.GateError, match="native"):
|
||||
resume.precheck(directory, inventory=inventory, fetcher=fetcher)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fault", ["extra", "absent", "hash", "symlink", "hardlink", "fifo", "oversize"]
|
||||
)
|
||||
def test_local_faults_fail_before_registry(release, tmp_path, fault, monkeypatch):
|
||||
directory, inventory, _, _, _ = release
|
||||
if fault == "fifo" and not hasattr(os, "mkfifo"):
|
||||
pytest.skip("POSIX FIFO files are unavailable on this platform")
|
||||
path = next(iter(directory.iterdir()))
|
||||
if fault == "extra":
|
||||
(directory / "unexpected.txt").write_text("extra")
|
||||
elif fault == "absent":
|
||||
path.unlink()
|
||||
elif fault == "hash":
|
||||
path.write_bytes(b"wrong")
|
||||
elif fault == "symlink":
|
||||
original = path.read_bytes()
|
||||
path.unlink()
|
||||
external = tmp_path / "outside"
|
||||
external.write_bytes(original)
|
||||
path.symlink_to(external)
|
||||
elif fault == "hardlink":
|
||||
os.link(path, tmp_path / "linked")
|
||||
elif fault == "fifo":
|
||||
path.unlink()
|
||||
os.mkfifo(path)
|
||||
else:
|
||||
monkeypatch.setattr(resume, "MAX_FILE_BYTES", 1)
|
||||
|
||||
def should_not_fetch(*args):
|
||||
pytest.fail("invalid local artifacts must not reach registry")
|
||||
|
||||
with pytest.raises(resume.GateError):
|
||||
resume.precheck(directory, inventory=inventory, fetcher=should_not_fetch)
|
||||
|
||||
|
||||
def test_inventory_tampering(release):
|
||||
directory, inventory, _, fetcher, _ = release
|
||||
inventory.write_text(inventory.read_text() + " ")
|
||||
with pytest.raises(resume.GateError, match="inventory hash"):
|
||||
resume.precheck(directory, inventory=inventory, fetcher=fetcher)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("runtime", "0.1.4"),
|
||||
("version", "0.3.0"),
|
||||
("source_sha", "a" * 40),
|
||||
("run_id", 1),
|
||||
("artifact_id", 1),
|
||||
("archive_sha256", "b" * 64),
|
||||
],
|
||||
)
|
||||
def test_inventory_identity_bound(release, monkeypatch, field, value):
|
||||
directory, inventory, _, fetcher, _ = release
|
||||
content = json.loads(inventory.read_bytes())
|
||||
content[field] = value
|
||||
inventory.write_text(json.dumps(content))
|
||||
monkeypatch.setattr(resume, "INVENTORY_SHA256", digest(inventory.read_bytes()))
|
||||
with pytest.raises(resume.GateError, match="source identity"):
|
||||
resume.precheck(directory, inventory=inventory, fetcher=fetcher)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("suffix", [".whl", ".tar.gz"])
|
||||
def test_wrong_runtime_rejected_even_when_fixture_hash_updated(release, suffix):
|
||||
directory, inventory, _, fetcher, freeze = release
|
||||
filename, package = next(
|
||||
(n, p) for n, p in resume.expected_files().items() if n.endswith(suffix)
|
||||
)
|
||||
path = directory / filename
|
||||
(write_wheel if suffix == ".whl" else write_sdist)(path, package, runtime="0.1.4")
|
||||
freeze()
|
||||
with pytest.raises(resume.GateError, match="runtime"):
|
||||
resume.precheck(directory, inventory=inventory, fetcher=fetcher)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
"../escape",
|
||||
"/absolute",
|
||||
"ruvector_attention/.secret",
|
||||
"ruvector_attention/key.pem",
|
||||
],
|
||||
)
|
||||
def test_wheel_unsafe_paths(release, path):
|
||||
directory, inventory, _, fetcher, freeze = release
|
||||
wheel = directory / "ruvector_attention-0.2.0-py3-none-any.whl"
|
||||
write_wheel(wheel, "ruvector-attention", extra=path)
|
||||
freeze()
|
||||
with pytest.raises(resume.GateError):
|
||||
resume.precheck(directory, inventory=inventory, fetcher=fetcher)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["symlink", "hardlink", "device", "fifo", "traversal"])
|
||||
def test_sdist_unsafe_members(release, kind):
|
||||
directory, inventory, _, fetcher, freeze = release
|
||||
member = tarfile.TarInfo("ruvector_attention-0.2.0/src/bad")
|
||||
member.type = {
|
||||
"symlink": tarfile.SYMTYPE,
|
||||
"hardlink": tarfile.LNKTYPE,
|
||||
"device": tarfile.CHRTYPE,
|
||||
"fifo": tarfile.FIFOTYPE,
|
||||
"traversal": tarfile.REGTYPE,
|
||||
}[kind]
|
||||
if kind == "traversal":
|
||||
member.name = "ruvector_attention-0.2.0/../escape"
|
||||
write_sdist(
|
||||
directory / "ruvector_attention-0.2.0.tar.gz",
|
||||
"ruvector-attention",
|
||||
extra=member,
|
||||
)
|
||||
freeze()
|
||||
with pytest.raises(resume.GateError):
|
||||
resume.precheck(directory, inventory=inventory, fetcher=fetcher)
|
||||
|
||||
|
||||
def test_archive_member_and_expansion_bounds(release, monkeypatch):
|
||||
directory, inventory, _, fetcher, _ = release
|
||||
monkeypatch.setattr(resume, "MAX_MEMBERS", 1)
|
||||
with pytest.raises(resume.GateError, match="members"):
|
||||
resume.precheck(directory, inventory=inventory, fetcher=fetcher)
|
||||
monkeypatch.setattr(resume, "MAX_MEMBERS", 256)
|
||||
monkeypatch.setattr(resume, "MAX_EXPANDED_BYTES", 1)
|
||||
with pytest.raises(resume.GateError, match="expanded"):
|
||||
resume.precheck(directory, inventory=inventory, fetcher=fetcher)
|
||||
|
||||
|
||||
def test_existing_staging_never_overwritten(release, tmp_path):
|
||||
directory, inventory, _, fetcher, _ = release
|
||||
output = tmp_path / "missing"
|
||||
output.mkdir()
|
||||
sentinel = output / "keep"
|
||||
sentinel.write_text("retained")
|
||||
with pytest.raises(resume.GateError, match="already exists"):
|
||||
resume.stage(directory, output, inventory=inventory, fetcher=fetcher)
|
||||
assert sentinel.read_text() == "retained"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [403, 429, 500])
|
||||
def test_http_errors_are_unavailable_not_absent(monkeypatch, status):
|
||||
def fail(*args, **kwargs):
|
||||
raise urllib.error.HTTPError("https://pypi.org", status, "", {}, None)
|
||||
|
||||
monkeypatch.setattr(resume.urllib.request, "urlopen", fail)
|
||||
with pytest.raises(resume.RegistryError, match=str(status)):
|
||||
resume.fetch_json("ruvector-core", "0.2.0")
|
||||
|
||||
|
||||
def test_http_404_means_absent(monkeypatch):
|
||||
def absent(*args, **kwargs):
|
||||
raise urllib.error.HTTPError("https://pypi.org", 404, "", {}, None)
|
||||
|
||||
monkeypatch.setattr(resume.urllib.request, "urlopen", absent)
|
||||
assert resume.fetch_json("ruvector-core", "0.2.0") is None
|
||||
|
||||
|
||||
def test_duplicate_metadata_identity_rejected():
|
||||
data = distribution_metadata("ruvector-attention").replace(
|
||||
b"Version:", b"Name: ruvector-attention\nVersion:"
|
||||
)
|
||||
with pytest.raises(resume.GateError, match="identity"):
|
||||
resume.metadata(data, "ruvector-attention")
|
||||
|
||||
|
||||
def test_runtime_environment_marker_and_extra_rejected():
|
||||
for requirement in ("ruvector==0.1.3; python_version<'3.13'", "ruvector[x]==0.1.3"):
|
||||
data = distribution_metadata("ruvector-attention").replace(
|
||||
b"ruvector==0.1.3", requirement.encode()
|
||||
)
|
||||
with pytest.raises(resume.GateError, match="runtime"):
|
||||
resume.metadata(data, "ruvector-attention")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"verify,missing,expected", [(True, False, 0), (True, True, 1), (False, True, 0)]
|
||||
)
|
||||
def test_cli_verify_receipt(
|
||||
release, tmp_path, monkeypatch, capsys, verify, missing, expected
|
||||
):
|
||||
directory, inventory, registry, fetcher, _ = release
|
||||
if missing:
|
||||
del registry[resume.PACKAGES[-1]]
|
||||
original = resume.precheck
|
||||
monkeypatch.setattr(
|
||||
resume,
|
||||
"precheck",
|
||||
lambda path, inventory: original(path, inventory=inventory, fetcher=fetcher),
|
||||
)
|
||||
receipt = tmp_path / "receipt.json"
|
||||
argv = [
|
||||
"resume",
|
||||
"--dist",
|
||||
str(directory),
|
||||
"--inventory",
|
||||
str(inventory),
|
||||
"--receipt",
|
||||
str(receipt),
|
||||
]
|
||||
if verify:
|
||||
argv.append("--verify-only")
|
||||
monkeypatch.setattr(resume, "__name__", "resume")
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
assert resume.main() == expected
|
||||
output = json.loads(capsys.readouterr().out)
|
||||
assert output["status"] == ("validated" if expected == 0 else "blocked")
|
||||
assert receipt.exists() == (expected == 0)
|
||||
if expected == 0:
|
||||
assert json.loads(receipt.read_text()) == output
|
||||
|
||||
|
||||
def test_cli_verify_cannot_select(tmp_path, monkeypatch, capsys):
|
||||
output = tmp_path / "missing"
|
||||
monkeypatch.setattr(
|
||||
"sys.argv",
|
||||
[
|
||||
"resume",
|
||||
"--dist",
|
||||
str(tmp_path),
|
||||
"--missing-dir",
|
||||
str(output),
|
||||
"--verify-only",
|
||||
],
|
||||
)
|
||||
assert resume.main() == 1
|
||||
assert "cannot select" in capsys.readouterr().out
|
||||
assert not output.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body", [b"[]", b"null", b"not-json", b"x" * (resume.MAX_RESPONSE_BYTES + 1)]
|
||||
)
|
||||
def test_registry_response_bounds_and_schema(monkeypatch, body):
|
||||
class Response:
|
||||
status = 200
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return None
|
||||
|
||||
def read(self, limit):
|
||||
return body[:limit]
|
||||
|
||||
monkeypatch.setattr(
|
||||
resume.urllib.request, "urlopen", lambda *args, **kwargs: Response()
|
||||
)
|
||||
with pytest.raises(resume.RegistryError):
|
||||
resume.fetch_json("ruvector-core", "0.2.0")
|
||||
|
||||
|
||||
def test_metadata_allocation_bound(monkeypatch):
|
||||
monkeypatch.setattr(resume, "MAX_METADATA_BYTES", 5)
|
||||
with pytest.raises(resume.GateError, match="exceeds bounds"):
|
||||
resume.metadata(
|
||||
distribution_metadata("ruvector-attention"), "ruvector-attention"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name == "nt", reason="POSIX chmod does not model Windows workspace ACLs"
|
||||
)
|
||||
def test_owned_directory_cannot_be_group_writable(release):
|
||||
directory, inventory, _, fetcher, _ = release
|
||||
directory.chmod(0o775)
|
||||
with pytest.raises(resume.GateError, match="private"):
|
||||
resume.precheck(directory, inventory=inventory, fetcher=fetcher)
|
||||
|
||||
|
||||
def test_windows_synthetic_mode_0777_uses_trusted_workspace_acl(release, monkeypatch):
|
||||
directory, inventory, _, fetcher, _ = release
|
||||
directory.chmod(0o777)
|
||||
original = resume.directory_mode_safe
|
||||
assert original(0o777, "nt")
|
||||
assert not original(0o777, "posix")
|
||||
monkeypatch.setattr(
|
||||
resume, "directory_mode_safe", lambda mode: original(mode, "nt")
|
||||
)
|
||||
plan, files = resume.precheck(directory, inventory=inventory, fetcher=fetcher)
|
||||
assert len(plan["published"]) == 22 and not files
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [0o775, 0o757, 0o777])
|
||||
def test_posix_directory_write_mode_guard(mode):
|
||||
assert not resume.directory_mode_safe(mode, "posix")
|
||||
assert resume.directory_mode_safe(0o755, "posix")
|
||||
42
python/portfolio/tutorials/README.md
Normal file
42
python/portfolio/tutorials/README.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Native attention quickstart
|
||||
|
||||
Use published `ruvector-attention==0.2.0` to blend synthetic embeddings with scaled dot product attention. This example calls the RuVector Rust runtime and checks its output against an independent NumPy reference.
|
||||
|
||||
## Install and run
|
||||
|
||||
Python 3.10 or later; the native `ruvector==0.1.3` wheel must support your platform. Save this README and `attention_quickstart.py` together.
|
||||
|
||||
```sh
|
||||
uv venv .venv
|
||||
uv pip install --python .venv/bin/python ruvector-attention==0.2.0 ruvector==0.1.3 numpy==2.2.6
|
||||
.venv/bin/python attention_quickstart.py
|
||||
```
|
||||
|
||||
On Windows use `.venv/Scripts/python.exe` in the two commands above. The exact script is [attention_quickstart.py](attention_quickstart.py).
|
||||
|
||||
Expected output:
|
||||
|
||||
```text
|
||||
ruvector-attention=0.2.0 ruvector=0.1.3
|
||||
weights=0.575975,0.283995,0.140029
|
||||
blended=0.435946,0.283995
|
||||
PASS: native attention matches reference; invalid inputs rejected
|
||||
```
|
||||
|
||||
## What the example proves
|
||||
|
||||
The three candidates act as both keys and values. The runtime computes `softmax(query @ candidates.T / sqrt(dimension))` and their weighted sum. The first candidate receives the greatest weight. The example also verifies rejection of a dimension mismatch and a nonfinite query.
|
||||
|
||||
Fresh PyPI installation and the exact script passed on October 12, 2026, using CPython 3.10.22, Linux x86_64, glibc 2.39 and NumPy 2.2.6. This is a numerical correctness smoke test, with no speed or quality benchmark. This run does not establish fresh macOS or Windows execution.
|
||||
|
||||
## Scope and limitations
|
||||
|
||||
There is no model download, text generation, trained cross encoder or model training. Attention ordering is dot product ordering. The package exposes this attention operation, not full npm feature parity. The wrapper converts inputs to contiguous float32; conversion can allocate before shape validation. Use external resource limits for untrusted workloads.
|
||||
|
||||
The published 0.2.0 wheel requires `ruvector==0.1.3`. Current repository main changes that dependency to 0.1.4. This tutorial deliberately reproduces the immutable published artifact and does not install unreleased source.
|
||||
|
||||
## Sources and release recovery
|
||||
|
||||
[PyPI package](https://pypi.org/project/ruvector-attention/0.2.0/) · [Native runtime](https://pypi.org/project/ruvector/0.1.3/) · [Immutable source](https://github.com/ruvnet/RuVector/tree/fccf36e9a76d35bce6750dfd3b25802cae3d683b/python/portfolio/ruvector-attention) · [Portfolio PR #1188](https://github.com/ruvnet/RuVector/pull/1188) · [Recovery ADR](../../../docs/python-portfolio/resume-release-adr.md)
|
||||
|
||||
The portfolio release is partially delivered. Recovery checks every existing filename and checksum against the original artifact ledger, then permits only missing original files through protected publishing. That does not change the successfully published attention wheel used here.
|
||||
48
python/portfolio/tutorials/attention_quickstart.py
Normal file
48
python/portfolio/tutorials/attention_quickstart.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""Synthetic scaled dot product attention using published RuVector packages.
|
||||
|
||||
Requires ruvector-attention==0.2.0, ruvector==0.1.3 and numpy==2.2.6.
|
||||
This validates attention math, not model training or text generation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.metadata import version
|
||||
|
||||
import numpy as np
|
||||
from ruvector_attention import attend
|
||||
|
||||
|
||||
def main() -> None:
|
||||
assert version("ruvector-attention") == "0.2.0"
|
||||
assert version("ruvector") == "0.1.3"
|
||||
query = np.array([1.0, 0.0], dtype=np.float32)
|
||||
candidates = np.array([[1.0, 0.0], [0.0, 1.0], [-1.0, 0.0]], dtype=np.float32)
|
||||
result = attend(query, candidates)
|
||||
|
||||
# Independent float64 reference; subtracting max prevents exp overflow.
|
||||
logits = candidates.astype(np.float64) @ query.astype(np.float64) / np.sqrt(2.0)
|
||||
reference = np.exp(logits - logits.max())
|
||||
reference /= reference.sum()
|
||||
np.testing.assert_allclose(result.weights, reference, rtol=1e-6, atol=1e-7)
|
||||
np.testing.assert_allclose(result.blended, reference @ candidates, rtol=1e-6, atol=1e-7)
|
||||
assert result.blended.shape == (2,)
|
||||
assert np.isclose(result.weights.sum(), 1.0)
|
||||
assert int(np.argmax(result.weights)) == 0
|
||||
|
||||
rejected = 0
|
||||
for bad_query in ([1.0], [float("nan"), 0.0]):
|
||||
try:
|
||||
attend(bad_query, candidates)
|
||||
except ValueError:
|
||||
rejected += 1
|
||||
else:
|
||||
raise AssertionError("invalid input was accepted")
|
||||
assert rejected == 2
|
||||
|
||||
print("ruvector-attention=0.2.0 ruvector=0.1.3")
|
||||
print("weights=" + ",".join(f"{weight:.6f}" for weight in result.weights))
|
||||
print("blended=" + ",".join(f"{coordinate:.6f}" for coordinate in result.blended))
|
||||
print("PASS: native attention matches reference; invalid inputs rejected")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue