Skip to content

Commit 7d485dc

Browse files
authored
Merge pull request #1800 from AdaWorldAPI/claude/d-tflp-softmax-evidence
probes(D-TFLP 6.5): softmax fixture replay + certified preparation gate
2 parents 88e1739 + 26666aa commit 7d485dc

28 files changed

Lines changed: 43250 additions & 0 deletions
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Runs TFLite Micro's real int8 SOFTMAX (Register_SOFTMAX via KernelRunner).
2+
// stdin, one case per line: n scale_bits zero_point beta_bits c_0 .. c_{n-1}
3+
// stdout, one line per case: prepare_ok invoke_ok o_0 .. o_{n-1}
4+
// Output contract: int8, scale 1/256, zero point -128.
5+
#include <cstdint>
6+
#include <cstdio>
7+
#include <cstring>
8+
#include <vector>
9+
#include "tensorflow/lite/micro/builtin_op_data.h"
10+
#include "tensorflow/lite/micro/kernels/kernel_runner.h"
11+
#include "tensorflow/lite/micro/test_helpers.h"
12+
13+
int main() {
14+
int n; unsigned sb, bb; int zp;
15+
while (std::scanf("%d %x %d %x", &n, &sb, &zp, &bb) == 4) {
16+
float scale, beta;
17+
std::memcpy(&scale, &sb, 4); std::memcpy(&beta, &bb, 4);
18+
std::vector<int8_t> in(n), out(n);
19+
for (int i = 0; i < n; ++i) { int v; std::scanf("%d", &v); in[i] = (int8_t)v; }
20+
int dims_data[] = {2, 1, n};
21+
TfLiteIntArray* dims = tflite::testing::IntArrayFromInts(dims_data);
22+
TfLiteTensor tensors[2] = {
23+
tflite::testing::CreateQuantizedTensor(in.data(), dims, scale, zp),
24+
tflite::testing::CreateQuantizedTensor(out.data(), dims, 1.0f / 256.0f, -128)};
25+
int ia[] = {1, 0}, oa[] = {1, 1};
26+
TfLiteSoftmaxParams params = {beta};
27+
const TFLMRegistration reg = tflite::Register_SOFTMAX();
28+
tflite::micro::KernelRunner runner(reg, tensors, 2, tflite::testing::IntArrayFromInts(ia),
29+
tflite::testing::IntArrayFromInts(oa), &params);
30+
TfLiteStatus p = runner.InitAndPrepare();
31+
TfLiteStatus v = p == kTfLiteOk ? runner.Invoke() : kTfLiteError;
32+
std::printf("%d %d", p == kTfLiteOk, v == kTfLiteOk);
33+
for (int i = 0; i < n; ++i) std::printf(" %d", out[i]);
34+
std::printf("\n");
35+
}
36+
return 0;
37+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[package]
2+
name = "replay"
3+
version = "0.0.0"
4+
edition = "2021"
5+
publish = false
6+
7+
# D-TFLP Amendment 6.5 P2: replay of the committed softmax reciprocal fixtures without TFLM.
8+
# Lab only; not a workspace member.
9+
[dependencies]
10+
serde_json = "1"
11+
sha2 = "0.10"
12+
13+
[profile.release]
14+
debug = 0
15+
16+
[workspace]
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Softmax reciprocal fixture replay (D-TFLP 6.5, lab)
2+
3+
This crate replays committed int8 SOFTMAX fixtures without TensorFlow Lite Micro.
4+
It shows that the fixtures, and the integer recipe they were recorded against, still agree.
5+
It runs under plain `cargo test`. It is not a workspace member and has no production consumer.
6+
7+
## What is tested
8+
9+
The fixtures live in `../../source-behavior-v1/fixtures65/`. A SHA-256 manifest
10+
(`manifest65.json`) covers every file.
11+
12+
| file | rows | content |
13+
|---|---|---|
14+
| `softmax_tables65.json` | 14 tables | prepared source weights per calibration: admitted flag, `A = exp_on_negative_values`, `B = Rescale<12>`, each with its SHA-256 |
15+
| `witness65_full.jsonl` | 529 | every kernel-confirmed witness row. In each, the TFLM reciprocal and the correctly rounded one give different output bytes |
16+
| `witness65_reduced.jsonl` | 27 | the regression subset (selection rule in each row's `why`) |
17+
| `equivalence65.jsonl` | 7 | calibration 9 rows where both reciprocals give the kernel's bytes |
18+
| `unreachable65.jsonl` | 10 | arithmetic candidates proved not to occur in any admitted row |
19+
20+
The runner works through three stages, in this order:
21+
1. manifest integrity: any mismatch exits with code 2;
22+
2. each table's digest;
23+
3. every fixture row.
24+
25+
For each witness row it checks the following:
26+
- the claimed calibration identity is the one the table was prepared for;
27+
- the row maximum is code 127;
28+
- every code is admitted and every count is positive;
29+
- the recomputed sum equals the stored `S`;
30+
- `S` lies in the supported domain;
31+
- the source-reciprocal byte equals the stored kernel byte;
32+
- the exact-reciprocal byte equals the stored alternative byte, which differs from the source byte.
33+
34+
All arithmetic is checked: an overflow the source would leave undefined becomes a named `Refusal`.
35+
36+
## Where the oracle came from
37+
38+
TFLM revision `5f64e4ad` (its `tensorflow/` tree is identical to `8f6596d`), reference int8 SOFTMAX
39+
(`kernels/internal/reference/softmax.h`), built with g++ 13.3.0 on x86-64.
40+
The source digest is sha256(`common.h` + `fixedpoint.h` + `reference/softmax.h`), equal to
41+
`0566576b4fb0be4df914ce36e369de295d22e227d661c13a0cec17f5d7f2eb3c`.
42+
43+
The rows were produced by the certification gate (`../../source-behavior-v1/softcert65.cc`) and
44+
confirmed by the real TFLM kernel (`softmax_oracle.cc`) before `fixtures65.py` wrote them.
45+
Regenerating them with the current gate gives byte-identical files.
46+
47+
## Admitted domain
48+
49+
- int8 input, output scale 1/256, zero point −128;
50+
- `x_integer_digits = 12`;
51+
- row sums `S ∈ [2^19, 2^28)`.
52+
53+
The 14 calibrations are the known set of `softmax63.py`.
54+
55+
## How to run
56+
57+
```sh
58+
cargo test --release # 11 tests, < 1 s
59+
cargo run --release -- ../../source-behavior-v1/fixtures65 --full # all 529 + 27 + 7 + 10
60+
cargo run --release -- ../../source-behavior-v1/fixtures65 --reciprocal exact --full
61+
```
62+
63+
The last command is the negative control. It must fail all 556 witness rows (529 full and 27 reduced) while
64+
the 7 equivalence rows still pass. Measured: about 90 ms for the full tier.
65+
66+
## What PASS proves
67+
68+
- The committed fixture bytes are the ones the manifest names.
69+
- On these 14 calibrations and these rows, the integer recipe in `src/lib.rs` reproduces the bytes the TFLM kernel produced.
70+
- The correctly rounded reciprocal is *not* a drop-in replacement on the 13 calibrations that have witnesses.
71+
- The tamper cases below are rejected, each for its own reason.
72+
73+
## What PASS does not prove
74+
75+
- It says nothing about calibrations outside the 14, rows outside the fixtures, other TFLM revisions, other compilers or other backends.
76+
- It does not re-run TFLM. Kernel agreement was established when the fixtures were made; the replay checks the recorded result.
77+
- It is not a softmax implementation, a Codec8 consumer or a production path.
78+
79+
## Tamper detection (`tests/tamper.rs`)
80+
81+
| tamper | expected result |
82+
|---|---|
83+
| changed byte, manifest not re-stamped | exit 2, `REFUSE integrity` |
84+
| changed source byte, re-stamped | `source byte … != stored` |
85+
| changed table weight, re-stamped | `table digest` |
86+
| witness claims another calibration (scale) | `calibration identity` |
87+
| witness points at another calibration's table | `table_sha256` |
88+
| row without its maximum (code 127) | `no mandatory maximum` |
89+
| stored `S` off by one | `!= stored S` |
90+
| a count of −1 | `NonPositiveMultiplicity` |
91+
| 600 maxima (`S > 2^28`) | `UnsupportedDomain` |
92+
| exact reciprocal | 556 failures, 7 equivalence rows still pass |
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
//! Replay the D-TFLP 6.5 softmax fixtures. Usage: replay FIXTURE_DIR [--reciprocal source|exact] [--full]
2+
//!
3+
//! Order: manifest digests (integrity) → each table's digest → each fixture. The reduced suite runs by
4+
//! default; `--full` adds the complete 529-witness tier. Exit status 0 only if every check passes.
5+
//! `--reciprocal exact` is the negative control: the witnesses must then fail and the
6+
//! calibration-9 equivalence rows must still pass.
7+
use replay::*;
8+
use serde_json::Value;
9+
use sha2::{Digest, Sha256};
10+
use std::{collections::BTreeMap, fs, path::Path, process::ExitCode};
11+
12+
fn hex(b: &[u8]) -> String {
13+
b.iter().map(|x| format!("{x:02x}")).collect()
14+
}
15+
16+
fn table(v: &Value) -> Table {
17+
let arr = |k: &str| v[k].as_array().unwrap().iter().map(|x| x.as_i64().unwrap()).collect::<Vec<_>>();
18+
let (adm, a, b) = (arr("admitted"), arr("A"), arr("B"));
19+
let mut t = Table { admitted: [false; 256], a: [0; 256], b: [0; 256] };
20+
for i in 0..256 {
21+
t.admitted[i] = adm[i] == 1;
22+
t.a[i] = a[i] as i32;
23+
t.b[i] = b[i] as i32;
24+
}
25+
t
26+
}
27+
28+
fn row_pairs(v: &Value) -> Vec<(i32, i32)> {
29+
match v {
30+
Value::Object(m) => m.iter().map(|(k, n)| (k.parse().unwrap(), n.as_i64().unwrap() as i32)).collect(),
31+
Value::Array(a) => {
32+
let mut c: BTreeMap<i32, i32> = BTreeMap::new();
33+
for x in a {
34+
*c.entry(x.as_i64().unwrap() as i32).or_default() += 1;
35+
}
36+
c.into_iter().collect()
37+
}
38+
_ => panic!("row"),
39+
}
40+
}
41+
42+
fn main() -> ExitCode {
43+
let args: Vec<String> = std::env::args().collect();
44+
let dir = Path::new(&args[1]);
45+
let recip = if args.iter().any(|a| a == "exact") { Recip::Exact } else { Recip::Source };
46+
let full = args.iter().any(|a| a == "--full");
47+
let mut fails: Vec<String> = Vec::new();
48+
let t0 = std::time::Instant::now();
49+
// 1. integrity
50+
let man: Value = serde_json::from_str(&fs::read_to_string(dir.join("manifest65.json")).unwrap()).unwrap();
51+
for (name, want) in man["sha256"].as_object().unwrap() {
52+
let got = hex(&Sha256::digest(fs::read(dir.join(name)).unwrap()));
53+
if &got != want.as_str().unwrap() {
54+
fails.push(format!("integrity {name}"));
55+
}
56+
}
57+
if !fails.is_empty() {
58+
println!("REFUSE integrity: {fails:?}");
59+
return ExitCode::from(2);
60+
}
61+
let t_integrity = t0.elapsed();
62+
// 2. tables
63+
let tj: Value = serde_json::from_str(&fs::read_to_string(dir.join("softmax_tables65.json")).unwrap()).unwrap();
64+
let mut tables: BTreeMap<String, Table> = BTreeMap::new();
65+
for (key, t) in tj["tables"].as_object().unwrap() {
66+
let d = hex(&Sha256::digest(serde_json::to_string(t).unwrap()));
67+
if d != tj["sha256"][key].as_str().unwrap() {
68+
fails.push(format!("table digest {key}"));
69+
}
70+
tables.insert(key.clone(), table(t));
71+
}
72+
let t_tables = t0.elapsed() - t_integrity;
73+
// 3. fixtures
74+
let lines = |f: &str| fs::read_to_string(dir.join(f)).unwrap().lines().map(|l| serde_json::from_str::<Value>(l).unwrap()).collect::<Vec<_>>();
75+
let mut ok = BTreeMap::<&str, u32>::new();
76+
let mut check_w = |name: &'static str, ws: &[Value], fails: &mut Vec<String>| {
77+
for w in ws {
78+
let id = w["id"].as_str().unwrap();
79+
let key = w["table"].as_str().unwrap();
80+
if tj["sha256"][key] != w["table_sha256"] {
81+
fails.push(format!("{name} {id}: table_sha256"));
82+
continue;
83+
}
84+
// the calibration identity the witness claims must be the one its table was prepared for
85+
let claimed = format!("{}:{}", w["scale_bits"].as_str().unwrap_or(""), w["beta_bits"].as_str().unwrap_or(""));
86+
if claimed != key || tj["tables"][key]["scale_bits"].as_str() != w["scale_bits"].as_str() {
87+
fails.push(format!("{name} {id}: calibration identity {claimed} != table {key}"));
88+
continue;
89+
}
90+
let t = &tables[key];
91+
let row = row_pairs(&w["row"]);
92+
let d = w["d"].as_i64().unwrap() as i32;
93+
let r = (|| -> Result<(), String> {
94+
// differences are relative to code 127: the row maximum is mandatory
95+
if row.iter().map(|&(c, _)| c).max() != Some(127) {
96+
return Err("no mandatory maximum (code 127)".into());
97+
}
98+
let s = row_sum(t, &row).map_err(|e| e.to_string())?;
99+
if s != w["S"].as_i64().unwrap() {
100+
return Err(format!("sum {s} != stored S"));
101+
}
102+
if !row.iter().any(|&(c, _)| c == 127 + d) {
103+
return Err("no element at d".into());
104+
}
105+
let bs = byte_of(t, &row, 127 + d, recip).map_err(|e| e.to_string())?;
106+
let bx = byte_of(t, &row, 127 + d, Recip::Exact).map_err(|e| e.to_string())?;
107+
if i64::from(bs) != w["byte_source"].as_i64().unwrap() || i64::from(bs) != w["kernel_byte"].as_i64().unwrap() {
108+
return Err(format!("source byte {bs} != stored"));
109+
}
110+
if i64::from(bx) != w["byte_exact"].as_i64().unwrap() || bs == bx {
111+
return Err(format!("exact byte {bx}"));
112+
}
113+
Ok(())
114+
})();
115+
match r {
116+
Ok(()) => *ok.entry(name).or_default() += 1,
117+
Err(e) => fails.push(format!("{name} {id}: {e}")),
118+
}
119+
}
120+
};
121+
check_w("reduced", &lines("witness65_reduced.jsonl"), &mut fails);
122+
if full {
123+
check_w("full", &lines("witness65_full.jsonl"), &mut fails);
124+
}
125+
for e in lines("equivalence65.jsonl") {
126+
let t = &tables[e["table"].as_str().unwrap()];
127+
let codes: Vec<i32> = e["row"].as_array().unwrap().iter().map(|x| x.as_i64().unwrap() as i32).collect();
128+
let row = row_pairs(&e["row"]);
129+
let want: Vec<i64> = e["kernel_bytes"].as_array().unwrap().iter().map(|x| x.as_i64().unwrap()).collect();
130+
let got: Result<Vec<i64>, Refusal> = codes.iter().map(|&c| byte_of(t, &row, c, recip).map(i64::from)).collect();
131+
let got_x: Result<Vec<i64>, Refusal> = codes.iter().map(|&c| byte_of(t, &row, c, Recip::Exact).map(i64::from)).collect();
132+
if got.as_ref() == Ok(&want) && got_x.as_ref() == Ok(&want) {
133+
*ok.entry("equivalence").or_default() += 1;
134+
} else {
135+
fails.push(format!("equivalence {}", e["id"]));
136+
}
137+
}
138+
for u in lines("unreachable65.jsonl") {
139+
let t = &tables[u["table"].as_str().unwrap()];
140+
let (s, d) = (u["S"].as_i64().unwrap(), u["d"].as_i64().unwrap() as i32);
141+
let r = s - i64::from(t.b[255]) - if d != 0 { i64::from(t.b[(d + 255) as usize]) } else { 0 };
142+
let proved = match u["status"].as_str().unwrap() {
143+
"unreachable_negative" => r < 0,
144+
_ => r >= 0 && r <= 4_000_000 && !representable(t, r as usize),
145+
};
146+
if proved {
147+
*ok.entry("unreachable").or_default() += 1;
148+
} else {
149+
fails.push(format!("unreachable S={s} d={d}"));
150+
}
151+
}
152+
let t_total = t0.elapsed();
153+
eprintln!("timing_us integrity={} tables={} fixtures={} total={}", t_integrity.as_micros(), t_tables.as_micros(), (t_total - t_integrity - t_tables).as_micros(), t_total.as_micros());
154+
println!("recip={recip:?} passed={ok:?} failures={}", fails.len());
155+
for f in fails.iter().take(10) {
156+
println!(" {f}");
157+
}
158+
if fails.is_empty() { ExitCode::SUCCESS } else { ExitCode::from(1) }
159+
}
160+
161+
/// Plain unbounded-coin DP over the admitted weights: is r a non-negative combination?
162+
fn representable(t: &Table, r: usize) -> bool {
163+
let coins: Vec<usize> = (0..256).filter(|&i| t.admitted[i] && t.b[i] > 0).map(|i| t.b[i] as usize).collect();
164+
let mut rep = vec![false; r + 1];
165+
rep[0] = true;
166+
for x in 1..=r {
167+
rep[x] = coins.iter().any(|&c| c <= x && rep[x - c]);
168+
}
169+
rep[r]
170+
}

0 commit comments

Comments
 (0)