From 6e15a27e05e04e4629d7ccd9927eb454f1da9c02 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 19:35:09 +0000 Subject: [PATCH 1/7] planner: learned ordering of scent leads over Codec9/lut9 (D-EPI-COT C3c) ScentLearner reorders C3b leads within a status group from investigation outcomes. Weights and steps are ndarray Codec9 lanes (NEVER_TOUCHED = feature never seen); the learning rate is applied through ndarray::simd::lut9 with a table built by TFLM's LUTPopulateInt8 recipe and pinned against a golden generated from tflite-micro. Only odd rules are prepared; a table for another spec refuses; one source trains once. Output is an order only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMPWTwovjYJEUMuGPb4 --- .../src/nars/data/tflm_lut_step_golden.txt | 1024 ++++++++++++++++ crates/lance-graph-planner/src/nars/mod.rs | 1 + .../src/nars/scent_learn.rs | 1062 +++++++++++++++++ .../oracles/tflm_lut_step_oracle.cc | 23 + 4 files changed, 2110 insertions(+) create mode 100644 crates/lance-graph-planner/src/nars/data/tflm_lut_step_golden.txt create mode 100644 crates/lance-graph-planner/src/nars/scent_learn.rs create mode 100644 docs/research/epistemic-cot/oracles/tflm_lut_step_oracle.cc diff --git a/crates/lance-graph-planner/src/nars/data/tflm_lut_step_golden.txt b/crates/lance-graph-planner/src/nars/data/tflm_lut_step_golden.txt new file mode 100644 index 000000000..9f2c0072a --- /dev/null +++ b/crates/lance-graph-planner/src/nars/data/tflm_lut_step_golden.txt @@ -0,0 +1,1024 @@ +L 1 4 -128 -32 +L 1 4 -127 -32 +L 1 4 -126 -32 +L 1 4 -125 -31 +L 1 4 -124 -31 +L 1 4 -123 -31 +L 1 4 -122 -31 +L 1 4 -121 -30 +L 1 4 -120 -30 +L 1 4 -119 -30 +L 1 4 -118 -30 +L 1 4 -117 -29 +L 1 4 -116 -29 +L 1 4 -115 -29 +L 1 4 -114 -29 +L 1 4 -113 -28 +L 1 4 -112 -28 +L 1 4 -111 -28 +L 1 4 -110 -28 +L 1 4 -109 -27 +L 1 4 -108 -27 +L 1 4 -107 -27 +L 1 4 -106 -27 +L 1 4 -105 -26 +L 1 4 -104 -26 +L 1 4 -103 -26 +L 1 4 -102 -26 +L 1 4 -101 -25 +L 1 4 -100 -25 +L 1 4 -99 -25 +L 1 4 -98 -25 +L 1 4 -97 -24 +L 1 4 -96 -24 +L 1 4 -95 -24 +L 1 4 -94 -24 +L 1 4 -93 -23 +L 1 4 -92 -23 +L 1 4 -91 -23 +L 1 4 -90 -23 +L 1 4 -89 -22 +L 1 4 -88 -22 +L 1 4 -87 -22 +L 1 4 -86 -22 +L 1 4 -85 -21 +L 1 4 -84 -21 +L 1 4 -83 -21 +L 1 4 -82 -21 +L 1 4 -81 -20 +L 1 4 -80 -20 +L 1 4 -79 -20 +L 1 4 -78 -20 +L 1 4 -77 -19 +L 1 4 -76 -19 +L 1 4 -75 -19 +L 1 4 -74 -19 +L 1 4 -73 -18 +L 1 4 -72 -18 +L 1 4 -71 -18 +L 1 4 -70 -18 +L 1 4 -69 -17 +L 1 4 -68 -17 +L 1 4 -67 -17 +L 1 4 -66 -17 +L 1 4 -65 -16 +L 1 4 -64 -16 +L 1 4 -63 -16 +L 1 4 -62 -16 +L 1 4 -61 -15 +L 1 4 -60 -15 +L 1 4 -59 -15 +L 1 4 -58 -15 +L 1 4 -57 -14 +L 1 4 -56 -14 +L 1 4 -55 -14 +L 1 4 -54 -14 +L 1 4 -53 -13 +L 1 4 -52 -13 +L 1 4 -51 -13 +L 1 4 -50 -13 +L 1 4 -49 -12 +L 1 4 -48 -12 +L 1 4 -47 -12 +L 1 4 -46 -12 +L 1 4 -45 -11 +L 1 4 -44 -11 +L 1 4 -43 -11 +L 1 4 -42 -11 +L 1 4 -41 -10 +L 1 4 -40 -10 +L 1 4 -39 -10 +L 1 4 -38 -10 +L 1 4 -37 -9 +L 1 4 -36 -9 +L 1 4 -35 -9 +L 1 4 -34 -9 +L 1 4 -33 -8 +L 1 4 -32 -8 +L 1 4 -31 -8 +L 1 4 -30 -8 +L 1 4 -29 -7 +L 1 4 -28 -7 +L 1 4 -27 -7 +L 1 4 -26 -7 +L 1 4 -25 -6 +L 1 4 -24 -6 +L 1 4 -23 -6 +L 1 4 -22 -6 +L 1 4 -21 -5 +L 1 4 -20 -5 +L 1 4 -19 -5 +L 1 4 -18 -5 +L 1 4 -17 -4 +L 1 4 -16 -4 +L 1 4 -15 -4 +L 1 4 -14 -4 +L 1 4 -13 -3 +L 1 4 -12 -3 +L 1 4 -11 -3 +L 1 4 -10 -3 +L 1 4 -9 -2 +L 1 4 -8 -2 +L 1 4 -7 -2 +L 1 4 -6 -2 +L 1 4 -5 -1 +L 1 4 -4 -1 +L 1 4 -3 -1 +L 1 4 -2 -1 +L 1 4 -1 0 +L 1 4 0 0 +L 1 4 1 0 +L 1 4 2 1 +L 1 4 3 1 +L 1 4 4 1 +L 1 4 5 1 +L 1 4 6 2 +L 1 4 7 2 +L 1 4 8 2 +L 1 4 9 2 +L 1 4 10 3 +L 1 4 11 3 +L 1 4 12 3 +L 1 4 13 3 +L 1 4 14 4 +L 1 4 15 4 +L 1 4 16 4 +L 1 4 17 4 +L 1 4 18 5 +L 1 4 19 5 +L 1 4 20 5 +L 1 4 21 5 +L 1 4 22 6 +L 1 4 23 6 +L 1 4 24 6 +L 1 4 25 6 +L 1 4 26 7 +L 1 4 27 7 +L 1 4 28 7 +L 1 4 29 7 +L 1 4 30 8 +L 1 4 31 8 +L 1 4 32 8 +L 1 4 33 8 +L 1 4 34 9 +L 1 4 35 9 +L 1 4 36 9 +L 1 4 37 9 +L 1 4 38 10 +L 1 4 39 10 +L 1 4 40 10 +L 1 4 41 10 +L 1 4 42 11 +L 1 4 43 11 +L 1 4 44 11 +L 1 4 45 11 +L 1 4 46 12 +L 1 4 47 12 +L 1 4 48 12 +L 1 4 49 12 +L 1 4 50 13 +L 1 4 51 13 +L 1 4 52 13 +L 1 4 53 13 +L 1 4 54 14 +L 1 4 55 14 +L 1 4 56 14 +L 1 4 57 14 +L 1 4 58 15 +L 1 4 59 15 +L 1 4 60 15 +L 1 4 61 15 +L 1 4 62 16 +L 1 4 63 16 +L 1 4 64 16 +L 1 4 65 16 +L 1 4 66 17 +L 1 4 67 17 +L 1 4 68 17 +L 1 4 69 17 +L 1 4 70 18 +L 1 4 71 18 +L 1 4 72 18 +L 1 4 73 18 +L 1 4 74 19 +L 1 4 75 19 +L 1 4 76 19 +L 1 4 77 19 +L 1 4 78 20 +L 1 4 79 20 +L 1 4 80 20 +L 1 4 81 20 +L 1 4 82 21 +L 1 4 83 21 +L 1 4 84 21 +L 1 4 85 21 +L 1 4 86 22 +L 1 4 87 22 +L 1 4 88 22 +L 1 4 89 22 +L 1 4 90 23 +L 1 4 91 23 +L 1 4 92 23 +L 1 4 93 23 +L 1 4 94 24 +L 1 4 95 24 +L 1 4 96 24 +L 1 4 97 24 +L 1 4 98 25 +L 1 4 99 25 +L 1 4 100 25 +L 1 4 101 25 +L 1 4 102 26 +L 1 4 103 26 +L 1 4 104 26 +L 1 4 105 26 +L 1 4 106 27 +L 1 4 107 27 +L 1 4 108 27 +L 1 4 109 27 +L 1 4 110 28 +L 1 4 111 28 +L 1 4 112 28 +L 1 4 113 28 +L 1 4 114 29 +L 1 4 115 29 +L 1 4 116 29 +L 1 4 117 29 +L 1 4 118 30 +L 1 4 119 30 +L 1 4 120 30 +L 1 4 121 30 +L 1 4 122 31 +L 1 4 123 31 +L 1 4 124 31 +L 1 4 125 31 +L 1 4 126 32 +L 1 4 127 32 +L 1 2 -128 -64 +L 1 2 -127 -64 +L 1 2 -126 -63 +L 1 2 -125 -63 +L 1 2 -124 -62 +L 1 2 -123 -62 +L 1 2 -122 -61 +L 1 2 -121 -61 +L 1 2 -120 -60 +L 1 2 -119 -60 +L 1 2 -118 -59 +L 1 2 -117 -59 +L 1 2 -116 -58 +L 1 2 -115 -58 +L 1 2 -114 -57 +L 1 2 -113 -57 +L 1 2 -112 -56 +L 1 2 -111 -56 +L 1 2 -110 -55 +L 1 2 -109 -55 +L 1 2 -108 -54 +L 1 2 -107 -54 +L 1 2 -106 -53 +L 1 2 -105 -53 +L 1 2 -104 -52 +L 1 2 -103 -52 +L 1 2 -102 -51 +L 1 2 -101 -51 +L 1 2 -100 -50 +L 1 2 -99 -50 +L 1 2 -98 -49 +L 1 2 -97 -49 +L 1 2 -96 -48 +L 1 2 -95 -48 +L 1 2 -94 -47 +L 1 2 -93 -47 +L 1 2 -92 -46 +L 1 2 -91 -46 +L 1 2 -90 -45 +L 1 2 -89 -45 +L 1 2 -88 -44 +L 1 2 -87 -44 +L 1 2 -86 -43 +L 1 2 -85 -43 +L 1 2 -84 -42 +L 1 2 -83 -42 +L 1 2 -82 -41 +L 1 2 -81 -41 +L 1 2 -80 -40 +L 1 2 -79 -40 +L 1 2 -78 -39 +L 1 2 -77 -39 +L 1 2 -76 -38 +L 1 2 -75 -38 +L 1 2 -74 -37 +L 1 2 -73 -37 +L 1 2 -72 -36 +L 1 2 -71 -36 +L 1 2 -70 -35 +L 1 2 -69 -35 +L 1 2 -68 -34 +L 1 2 -67 -34 +L 1 2 -66 -33 +L 1 2 -65 -33 +L 1 2 -64 -32 +L 1 2 -63 -32 +L 1 2 -62 -31 +L 1 2 -61 -31 +L 1 2 -60 -30 +L 1 2 -59 -30 +L 1 2 -58 -29 +L 1 2 -57 -29 +L 1 2 -56 -28 +L 1 2 -55 -28 +L 1 2 -54 -27 +L 1 2 -53 -27 +L 1 2 -52 -26 +L 1 2 -51 -26 +L 1 2 -50 -25 +L 1 2 -49 -25 +L 1 2 -48 -24 +L 1 2 -47 -24 +L 1 2 -46 -23 +L 1 2 -45 -23 +L 1 2 -44 -22 +L 1 2 -43 -22 +L 1 2 -42 -21 +L 1 2 -41 -21 +L 1 2 -40 -20 +L 1 2 -39 -20 +L 1 2 -38 -19 +L 1 2 -37 -19 +L 1 2 -36 -18 +L 1 2 -35 -18 +L 1 2 -34 -17 +L 1 2 -33 -17 +L 1 2 -32 -16 +L 1 2 -31 -16 +L 1 2 -30 -15 +L 1 2 -29 -15 +L 1 2 -28 -14 +L 1 2 -27 -14 +L 1 2 -26 -13 +L 1 2 -25 -13 +L 1 2 -24 -12 +L 1 2 -23 -12 +L 1 2 -22 -11 +L 1 2 -21 -11 +L 1 2 -20 -10 +L 1 2 -19 -10 +L 1 2 -18 -9 +L 1 2 -17 -9 +L 1 2 -16 -8 +L 1 2 -15 -8 +L 1 2 -14 -7 +L 1 2 -13 -7 +L 1 2 -12 -6 +L 1 2 -11 -6 +L 1 2 -10 -5 +L 1 2 -9 -5 +L 1 2 -8 -4 +L 1 2 -7 -4 +L 1 2 -6 -3 +L 1 2 -5 -3 +L 1 2 -4 -2 +L 1 2 -3 -2 +L 1 2 -2 -1 +L 1 2 -1 -1 +L 1 2 0 0 +L 1 2 1 1 +L 1 2 2 1 +L 1 2 3 2 +L 1 2 4 2 +L 1 2 5 3 +L 1 2 6 3 +L 1 2 7 4 +L 1 2 8 4 +L 1 2 9 5 +L 1 2 10 5 +L 1 2 11 6 +L 1 2 12 6 +L 1 2 13 7 +L 1 2 14 7 +L 1 2 15 8 +L 1 2 16 8 +L 1 2 17 9 +L 1 2 18 9 +L 1 2 19 10 +L 1 2 20 10 +L 1 2 21 11 +L 1 2 22 11 +L 1 2 23 12 +L 1 2 24 12 +L 1 2 25 13 +L 1 2 26 13 +L 1 2 27 14 +L 1 2 28 14 +L 1 2 29 15 +L 1 2 30 15 +L 1 2 31 16 +L 1 2 32 16 +L 1 2 33 17 +L 1 2 34 17 +L 1 2 35 18 +L 1 2 36 18 +L 1 2 37 19 +L 1 2 38 19 +L 1 2 39 20 +L 1 2 40 20 +L 1 2 41 21 +L 1 2 42 21 +L 1 2 43 22 +L 1 2 44 22 +L 1 2 45 23 +L 1 2 46 23 +L 1 2 47 24 +L 1 2 48 24 +L 1 2 49 25 +L 1 2 50 25 +L 1 2 51 26 +L 1 2 52 26 +L 1 2 53 27 +L 1 2 54 27 +L 1 2 55 28 +L 1 2 56 28 +L 1 2 57 29 +L 1 2 58 29 +L 1 2 59 30 +L 1 2 60 30 +L 1 2 61 31 +L 1 2 62 31 +L 1 2 63 32 +L 1 2 64 32 +L 1 2 65 33 +L 1 2 66 33 +L 1 2 67 34 +L 1 2 68 34 +L 1 2 69 35 +L 1 2 70 35 +L 1 2 71 36 +L 1 2 72 36 +L 1 2 73 37 +L 1 2 74 37 +L 1 2 75 38 +L 1 2 76 38 +L 1 2 77 39 +L 1 2 78 39 +L 1 2 79 40 +L 1 2 80 40 +L 1 2 81 41 +L 1 2 82 41 +L 1 2 83 42 +L 1 2 84 42 +L 1 2 85 43 +L 1 2 86 43 +L 1 2 87 44 +L 1 2 88 44 +L 1 2 89 45 +L 1 2 90 45 +L 1 2 91 46 +L 1 2 92 46 +L 1 2 93 47 +L 1 2 94 47 +L 1 2 95 48 +L 1 2 96 48 +L 1 2 97 49 +L 1 2 98 49 +L 1 2 99 50 +L 1 2 100 50 +L 1 2 101 51 +L 1 2 102 51 +L 1 2 103 52 +L 1 2 104 52 +L 1 2 105 53 +L 1 2 106 53 +L 1 2 107 54 +L 1 2 108 54 +L 1 2 109 55 +L 1 2 110 55 +L 1 2 111 56 +L 1 2 112 56 +L 1 2 113 57 +L 1 2 114 57 +L 1 2 115 58 +L 1 2 116 58 +L 1 2 117 59 +L 1 2 118 59 +L 1 2 119 60 +L 1 2 120 60 +L 1 2 121 61 +L 1 2 122 61 +L 1 2 123 62 +L 1 2 124 62 +L 1 2 125 63 +L 1 2 126 63 +L 1 2 127 64 +L 3 4 -128 -96 +L 3 4 -127 -95 +L 3 4 -126 -95 +L 3 4 -125 -94 +L 3 4 -124 -93 +L 3 4 -123 -92 +L 3 4 -122 -92 +L 3 4 -121 -91 +L 3 4 -120 -90 +L 3 4 -119 -89 +L 3 4 -118 -89 +L 3 4 -117 -88 +L 3 4 -116 -87 +L 3 4 -115 -86 +L 3 4 -114 -86 +L 3 4 -113 -85 +L 3 4 -112 -84 +L 3 4 -111 -83 +L 3 4 -110 -83 +L 3 4 -109 -82 +L 3 4 -108 -81 +L 3 4 -107 -80 +L 3 4 -106 -80 +L 3 4 -105 -79 +L 3 4 -104 -78 +L 3 4 -103 -77 +L 3 4 -102 -77 +L 3 4 -101 -76 +L 3 4 -100 -75 +L 3 4 -99 -74 +L 3 4 -98 -74 +L 3 4 -97 -73 +L 3 4 -96 -72 +L 3 4 -95 -71 +L 3 4 -94 -71 +L 3 4 -93 -70 +L 3 4 -92 -69 +L 3 4 -91 -68 +L 3 4 -90 -68 +L 3 4 -89 -67 +L 3 4 -88 -66 +L 3 4 -87 -65 +L 3 4 -86 -65 +L 3 4 -85 -64 +L 3 4 -84 -63 +L 3 4 -83 -62 +L 3 4 -82 -62 +L 3 4 -81 -61 +L 3 4 -80 -60 +L 3 4 -79 -59 +L 3 4 -78 -59 +L 3 4 -77 -58 +L 3 4 -76 -57 +L 3 4 -75 -56 +L 3 4 -74 -56 +L 3 4 -73 -55 +L 3 4 -72 -54 +L 3 4 -71 -53 +L 3 4 -70 -53 +L 3 4 -69 -52 +L 3 4 -68 -51 +L 3 4 -67 -50 +L 3 4 -66 -50 +L 3 4 -65 -49 +L 3 4 -64 -48 +L 3 4 -63 -47 +L 3 4 -62 -47 +L 3 4 -61 -46 +L 3 4 -60 -45 +L 3 4 -59 -44 +L 3 4 -58 -44 +L 3 4 -57 -43 +L 3 4 -56 -42 +L 3 4 -55 -41 +L 3 4 -54 -41 +L 3 4 -53 -40 +L 3 4 -52 -39 +L 3 4 -51 -38 +L 3 4 -50 -38 +L 3 4 -49 -37 +L 3 4 -48 -36 +L 3 4 -47 -35 +L 3 4 -46 -35 +L 3 4 -45 -34 +L 3 4 -44 -33 +L 3 4 -43 -32 +L 3 4 -42 -32 +L 3 4 -41 -31 +L 3 4 -40 -30 +L 3 4 -39 -29 +L 3 4 -38 -29 +L 3 4 -37 -28 +L 3 4 -36 -27 +L 3 4 -35 -26 +L 3 4 -34 -26 +L 3 4 -33 -25 +L 3 4 -32 -24 +L 3 4 -31 -23 +L 3 4 -30 -23 +L 3 4 -29 -22 +L 3 4 -28 -21 +L 3 4 -27 -20 +L 3 4 -26 -20 +L 3 4 -25 -19 +L 3 4 -24 -18 +L 3 4 -23 -17 +L 3 4 -22 -17 +L 3 4 -21 -16 +L 3 4 -20 -15 +L 3 4 -19 -14 +L 3 4 -18 -14 +L 3 4 -17 -13 +L 3 4 -16 -12 +L 3 4 -15 -11 +L 3 4 -14 -11 +L 3 4 -13 -10 +L 3 4 -12 -9 +L 3 4 -11 -8 +L 3 4 -10 -8 +L 3 4 -9 -7 +L 3 4 -8 -6 +L 3 4 -7 -5 +L 3 4 -6 -5 +L 3 4 -5 -4 +L 3 4 -4 -3 +L 3 4 -3 -2 +L 3 4 -2 -2 +L 3 4 -1 -1 +L 3 4 0 0 +L 3 4 1 1 +L 3 4 2 2 +L 3 4 3 2 +L 3 4 4 3 +L 3 4 5 4 +L 3 4 6 5 +L 3 4 7 5 +L 3 4 8 6 +L 3 4 9 7 +L 3 4 10 8 +L 3 4 11 8 +L 3 4 12 9 +L 3 4 13 10 +L 3 4 14 11 +L 3 4 15 11 +L 3 4 16 12 +L 3 4 17 13 +L 3 4 18 14 +L 3 4 19 14 +L 3 4 20 15 +L 3 4 21 16 +L 3 4 22 17 +L 3 4 23 17 +L 3 4 24 18 +L 3 4 25 19 +L 3 4 26 20 +L 3 4 27 20 +L 3 4 28 21 +L 3 4 29 22 +L 3 4 30 23 +L 3 4 31 23 +L 3 4 32 24 +L 3 4 33 25 +L 3 4 34 26 +L 3 4 35 26 +L 3 4 36 27 +L 3 4 37 28 +L 3 4 38 29 +L 3 4 39 29 +L 3 4 40 30 +L 3 4 41 31 +L 3 4 42 32 +L 3 4 43 32 +L 3 4 44 33 +L 3 4 45 34 +L 3 4 46 35 +L 3 4 47 35 +L 3 4 48 36 +L 3 4 49 37 +L 3 4 50 38 +L 3 4 51 38 +L 3 4 52 39 +L 3 4 53 40 +L 3 4 54 41 +L 3 4 55 41 +L 3 4 56 42 +L 3 4 57 43 +L 3 4 58 44 +L 3 4 59 44 +L 3 4 60 45 +L 3 4 61 46 +L 3 4 62 47 +L 3 4 63 47 +L 3 4 64 48 +L 3 4 65 49 +L 3 4 66 50 +L 3 4 67 50 +L 3 4 68 51 +L 3 4 69 52 +L 3 4 70 53 +L 3 4 71 53 +L 3 4 72 54 +L 3 4 73 55 +L 3 4 74 56 +L 3 4 75 56 +L 3 4 76 57 +L 3 4 77 58 +L 3 4 78 59 +L 3 4 79 59 +L 3 4 80 60 +L 3 4 81 61 +L 3 4 82 62 +L 3 4 83 62 +L 3 4 84 63 +L 3 4 85 64 +L 3 4 86 65 +L 3 4 87 65 +L 3 4 88 66 +L 3 4 89 67 +L 3 4 90 68 +L 3 4 91 68 +L 3 4 92 69 +L 3 4 93 70 +L 3 4 94 71 +L 3 4 95 71 +L 3 4 96 72 +L 3 4 97 73 +L 3 4 98 74 +L 3 4 99 74 +L 3 4 100 75 +L 3 4 101 76 +L 3 4 102 77 +L 3 4 103 77 +L 3 4 104 78 +L 3 4 105 79 +L 3 4 106 80 +L 3 4 107 80 +L 3 4 108 81 +L 3 4 109 82 +L 3 4 110 83 +L 3 4 111 83 +L 3 4 112 84 +L 3 4 113 85 +L 3 4 114 86 +L 3 4 115 86 +L 3 4 116 87 +L 3 4 117 88 +L 3 4 118 89 +L 3 4 119 89 +L 3 4 120 90 +L 3 4 121 91 +L 3 4 122 92 +L 3 4 123 92 +L 3 4 124 93 +L 3 4 125 94 +L 3 4 126 95 +L 3 4 127 95 +L 1 1 -128 -128 +L 1 1 -127 -127 +L 1 1 -126 -126 +L 1 1 -125 -125 +L 1 1 -124 -124 +L 1 1 -123 -123 +L 1 1 -122 -122 +L 1 1 -121 -121 +L 1 1 -120 -120 +L 1 1 -119 -119 +L 1 1 -118 -118 +L 1 1 -117 -117 +L 1 1 -116 -116 +L 1 1 -115 -115 +L 1 1 -114 -114 +L 1 1 -113 -113 +L 1 1 -112 -112 +L 1 1 -111 -111 +L 1 1 -110 -110 +L 1 1 -109 -109 +L 1 1 -108 -108 +L 1 1 -107 -107 +L 1 1 -106 -106 +L 1 1 -105 -105 +L 1 1 -104 -104 +L 1 1 -103 -103 +L 1 1 -102 -102 +L 1 1 -101 -101 +L 1 1 -100 -100 +L 1 1 -99 -99 +L 1 1 -98 -98 +L 1 1 -97 -97 +L 1 1 -96 -96 +L 1 1 -95 -95 +L 1 1 -94 -94 +L 1 1 -93 -93 +L 1 1 -92 -92 +L 1 1 -91 -91 +L 1 1 -90 -90 +L 1 1 -89 -89 +L 1 1 -88 -88 +L 1 1 -87 -87 +L 1 1 -86 -86 +L 1 1 -85 -85 +L 1 1 -84 -84 +L 1 1 -83 -83 +L 1 1 -82 -82 +L 1 1 -81 -81 +L 1 1 -80 -80 +L 1 1 -79 -79 +L 1 1 -78 -78 +L 1 1 -77 -77 +L 1 1 -76 -76 +L 1 1 -75 -75 +L 1 1 -74 -74 +L 1 1 -73 -73 +L 1 1 -72 -72 +L 1 1 -71 -71 +L 1 1 -70 -70 +L 1 1 -69 -69 +L 1 1 -68 -68 +L 1 1 -67 -67 +L 1 1 -66 -66 +L 1 1 -65 -65 +L 1 1 -64 -64 +L 1 1 -63 -63 +L 1 1 -62 -62 +L 1 1 -61 -61 +L 1 1 -60 -60 +L 1 1 -59 -59 +L 1 1 -58 -58 +L 1 1 -57 -57 +L 1 1 -56 -56 +L 1 1 -55 -55 +L 1 1 -54 -54 +L 1 1 -53 -53 +L 1 1 -52 -52 +L 1 1 -51 -51 +L 1 1 -50 -50 +L 1 1 -49 -49 +L 1 1 -48 -48 +L 1 1 -47 -47 +L 1 1 -46 -46 +L 1 1 -45 -45 +L 1 1 -44 -44 +L 1 1 -43 -43 +L 1 1 -42 -42 +L 1 1 -41 -41 +L 1 1 -40 -40 +L 1 1 -39 -39 +L 1 1 -38 -38 +L 1 1 -37 -37 +L 1 1 -36 -36 +L 1 1 -35 -35 +L 1 1 -34 -34 +L 1 1 -33 -33 +L 1 1 -32 -32 +L 1 1 -31 -31 +L 1 1 -30 -30 +L 1 1 -29 -29 +L 1 1 -28 -28 +L 1 1 -27 -27 +L 1 1 -26 -26 +L 1 1 -25 -25 +L 1 1 -24 -24 +L 1 1 -23 -23 +L 1 1 -22 -22 +L 1 1 -21 -21 +L 1 1 -20 -20 +L 1 1 -19 -19 +L 1 1 -18 -18 +L 1 1 -17 -17 +L 1 1 -16 -16 +L 1 1 -15 -15 +L 1 1 -14 -14 +L 1 1 -13 -13 +L 1 1 -12 -12 +L 1 1 -11 -11 +L 1 1 -10 -10 +L 1 1 -9 -9 +L 1 1 -8 -8 +L 1 1 -7 -7 +L 1 1 -6 -6 +L 1 1 -5 -5 +L 1 1 -4 -4 +L 1 1 -3 -3 +L 1 1 -2 -2 +L 1 1 -1 -1 +L 1 1 0 0 +L 1 1 1 1 +L 1 1 2 2 +L 1 1 3 3 +L 1 1 4 4 +L 1 1 5 5 +L 1 1 6 6 +L 1 1 7 7 +L 1 1 8 8 +L 1 1 9 9 +L 1 1 10 10 +L 1 1 11 11 +L 1 1 12 12 +L 1 1 13 13 +L 1 1 14 14 +L 1 1 15 15 +L 1 1 16 16 +L 1 1 17 17 +L 1 1 18 18 +L 1 1 19 19 +L 1 1 20 20 +L 1 1 21 21 +L 1 1 22 22 +L 1 1 23 23 +L 1 1 24 24 +L 1 1 25 25 +L 1 1 26 26 +L 1 1 27 27 +L 1 1 28 28 +L 1 1 29 29 +L 1 1 30 30 +L 1 1 31 31 +L 1 1 32 32 +L 1 1 33 33 +L 1 1 34 34 +L 1 1 35 35 +L 1 1 36 36 +L 1 1 37 37 +L 1 1 38 38 +L 1 1 39 39 +L 1 1 40 40 +L 1 1 41 41 +L 1 1 42 42 +L 1 1 43 43 +L 1 1 44 44 +L 1 1 45 45 +L 1 1 46 46 +L 1 1 47 47 +L 1 1 48 48 +L 1 1 49 49 +L 1 1 50 50 +L 1 1 51 51 +L 1 1 52 52 +L 1 1 53 53 +L 1 1 54 54 +L 1 1 55 55 +L 1 1 56 56 +L 1 1 57 57 +L 1 1 58 58 +L 1 1 59 59 +L 1 1 60 60 +L 1 1 61 61 +L 1 1 62 62 +L 1 1 63 63 +L 1 1 64 64 +L 1 1 65 65 +L 1 1 66 66 +L 1 1 67 67 +L 1 1 68 68 +L 1 1 69 69 +L 1 1 70 70 +L 1 1 71 71 +L 1 1 72 72 +L 1 1 73 73 +L 1 1 74 74 +L 1 1 75 75 +L 1 1 76 76 +L 1 1 77 77 +L 1 1 78 78 +L 1 1 79 79 +L 1 1 80 80 +L 1 1 81 81 +L 1 1 82 82 +L 1 1 83 83 +L 1 1 84 84 +L 1 1 85 85 +L 1 1 86 86 +L 1 1 87 87 +L 1 1 88 88 +L 1 1 89 89 +L 1 1 90 90 +L 1 1 91 91 +L 1 1 92 92 +L 1 1 93 93 +L 1 1 94 94 +L 1 1 95 95 +L 1 1 96 96 +L 1 1 97 97 +L 1 1 98 98 +L 1 1 99 99 +L 1 1 100 100 +L 1 1 101 101 +L 1 1 102 102 +L 1 1 103 103 +L 1 1 104 104 +L 1 1 105 105 +L 1 1 106 106 +L 1 1 107 107 +L 1 1 108 108 +L 1 1 109 109 +L 1 1 110 110 +L 1 1 111 111 +L 1 1 112 112 +L 1 1 113 113 +L 1 1 114 114 +L 1 1 115 115 +L 1 1 116 116 +L 1 1 117 117 +L 1 1 118 118 +L 1 1 119 119 +L 1 1 120 120 +L 1 1 121 121 +L 1 1 122 122 +L 1 1 123 123 +L 1 1 124 124 +L 1 1 125 125 +L 1 1 126 126 +L 1 1 127 127 diff --git a/crates/lance-graph-planner/src/nars/mod.rs b/crates/lance-graph-planner/src/nars/mod.rs index eff63839f..bc780a721 100644 --- a/crates/lance-graph-planner/src/nars/mod.rs +++ b/crates/lance-graph-planner/src/nars/mod.rs @@ -19,6 +19,7 @@ pub mod meta_basin; pub mod reach_out; pub mod regulate; pub mod scent; +pub mod scent_learn; pub mod stance; pub mod support; pub mod tactic_select; diff --git a/crates/lance-graph-planner/src/nars/scent_learn.rs b/crates/lance-graph-planner/src/nars/scent_learn.rs new file mode 100644 index 000000000..cecc3391c --- /dev/null +++ b/crates/lance-graph-planner/src/nars/scent_learn.rs @@ -0,0 +1,1062 @@ +//! `scent_learn`: a learned ordering of C3b scent leads (D-EPI-COT C3c). +//! +//! [`super::scent::scent`] ranks candidate mediators by status, then by +//! uncertainty. Leads with the same status and uncertainty are ordered by +//! mediator id, which carries no information. [`ScentLearner`] learns from +//! investigation outcomes which leads in a status group are worth testing +//! first. +//! +//! # What it is and is not +//! +//! - It reorders leads **within** a C3b status group and never across one. +//! A contested lead (a mature link with contradicting evidence) therefore +//! still comes before a settled one, however the weights move (PZ8). +//! - Its output is an order. It never writes truth, Epi5 bits, CE64 bits or +//! the ledger, and a prediction is never recorded as an observation (PZ4). +//! - One outcome source trains it once; a replay is refused (PZ6). +//! +//! # Numerics +//! +//! The arithmetic consumes merged ndarray kernels and adds none: +//! +//! - Weights and steps are [`EpiLane9`] (Codec9, DR512 sign-magnitude). +//! `NEVER_TOUCHED` means "feature never seen", distinct from a learned +//! `+0`. +//! - A signed residual is encoded with `Codec9::::encode_touched`. +//! A touched negative value that rounds to zero is `+0`, never +//! `NEVER_TOUCHED` (PZ2). +//! - The learning rate is applied by `ndarray::simd::lut9`, which only +//! evaluates odd functions. Only a symmetric rule is prepared; +//! an asymmetric or biased rule is refused (PZ3). +//! - The rate table is built with TFLite Micro's `LUTPopulateInt8` recipe +//! (`common.h`): code → `x·η` → `TfLiteRound` (ties away from zero) → +//! saturate. `data/tflm_lut_step_golden.txt` was generated by +//! `docs/research/epistemic-cot/oracles/tflm_lut_step_oracle.cc`, compiled +//! against tflite-micro. The table matches it on every int8 code +//! (magnitudes 0..=128). Magnitudes 129..=255 use the same formula but have +//! no TFLM counterpart. +//! - A prepared table carries the [`ModelSpec`] it was built for. A learner +//! refuses a table built for a different schema, calibration or rate (PZ7). +//! The spec is transient and in memory. It is not a generation store and not +//! the EXO-152 PZL-4/G1 binding. + +use std::collections::BTreeSet; + +use ndarray::hpc::codec8::{Calibration, TiesAway}; +use ndarray::hpc::codec9::{Codec9, EpiLane9}; +use ndarray::simd::lut9; + +use super::scent::{Lead, LeadStatus, Link}; + +/// Feature schema version. Changing [`features`] must bump it. +pub const SCHEMA: u16 = 1; +/// Number of features per lead. +pub const FEATURES: usize = 5; +/// `|score| / SCORE_SCALE` is the prediction, clamped to ±127. Policy pin. +pub const SCORE_SCALE: i32 = 512; +/// Target for an outcome: `+TARGET` hydrated, `-TARGET` refuted. +pub const TARGET: i16 = 127; + +/// Unit symmetric calibration: one code step is 1.0. +pub struct Unit; +impl Calibration for Unit { + const SCALE: f32 = 1.0; + const ZERO_POINT: i32 = 0; +} + +/// The codec. Ties away from zero, as TFLite (`std::round`). +pub type Q = Codec9; + +/// A learning rate `num/den` with `0 < num <= den`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Rate { + /// Numerator. + pub num: u8, + /// Denominator. + pub den: u8, +} + +/// What a prepared table and a learner must agree on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ModelSpec { + /// Feature schema version ([`SCHEMA`]). + pub schema: u16, + /// Bits of the codec scale (`Unit::SCALE`). + pub scale_bits: u32, + /// The learning rate. + pub rate: Rate, +} + +impl ModelSpec { + /// The spec for `rate` under the current schema and codec. + #[must_use] + pub fn new(rate: Rate) -> Self { + ModelSpec { + schema: SCHEMA, + scale_bits: Unit::SCALE.to_bits(), + rate, + } + } +} + +/// An update rule offered for preparation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateRule { + /// `step = η · residual`: odd, prepared. + Symmetric(Rate), + /// Different rates for positive and negative residuals. + Asymmetric { + /// Rate for positive residuals. + gain: Rate, + /// Rate for negative residuals. + loss: Rate, + }, + /// `step = η · residual + bias`. + Affine { + /// The rate. + rate: Rate, + /// Constant added to every step. + bias: i8, + }, +} + +/// Why nothing was prepared or learned. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LearnRefusal { + /// The rule is not an odd function, so `lut9` cannot evaluate it. + NotOdd, + /// The rate is zero or above one. + BadRate, + /// The table was prepared for a different model. + StaleTable { + /// The table's spec. + table: ModelSpec, + /// The learner's spec. + model: ModelSpec, + }, +} + +/// A rate table bound to the spec it was built for. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreparedStep { + spec: ModelSpec, + table: [u8; 256], +} + +impl PreparedStep { + /// The spec this table was built for. + #[must_use] + pub fn spec(&self) -> ModelSpec { + self.spec + } + + /// The magnitude table `lut9` reads. + #[must_use] + pub fn table(&self) -> &[u8; 256] { + &self.table + } +} + +/// Build the rate table for `rule`, or refuse a rule `lut9` cannot evaluate. +/// +/// # Errors +/// [`LearnRefusal::NotOdd`] for an asymmetric or biased rule; +/// [`LearnRefusal::BadRate`] for a zero rate or one above 1. +pub fn prepare(rule: UpdateRule) -> Result { + let rate = match rule { + UpdateRule::Symmetric(r) => r, + UpdateRule::Asymmetric { gain, loss } if gain == loss => gain, + UpdateRule::Affine { rate, bias: 0 } => rate, + UpdateRule::Asymmetric { .. } | UpdateRule::Affine { .. } => { + return Err(LearnRefusal::NotOdd) + } + }; + if rate.num == 0 || rate.num > rate.den { + return Err(LearnRefusal::BadRate); + } + // TFLM LUTPopulateInt8 at unit scale: transform in f32, TfLiteRound + // (ties away from zero), saturate. η ≤ 1 keeps every entry in 0..=255. + let eta = f32::from(rate.num) / f32::from(rate.den); + let table = core::array::from_fn(|m| (m as f32 * eta).round() as u8); + Ok(PreparedStep { + spec: ModelSpec::new(rate), + table, + }) +} + +/// What one investigation found. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Outcome { + /// The mediator hydrated the edge. + Hydrated, + /// The missing link was not there. + Refuted, +} + +/// What a learning call did. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Learned { + /// Weights moved; the residual was this. + Updated { + /// `target − prediction`. + residual: i16, + }, + /// The outcome's source already trained this learner; nothing moved. + Replayed, +} + +fn unit(x: f32) -> u8 { + (x.clamp(0.0, 1.0) * 255.0).round() as u8 +} + +/// The link a lead is anchored on: in the chain and with evidence. A testable +/// lead has exactly one; a settled or contested lead uses the weaker one. +fn anchor(lead: &Lead) -> Option<&Link> { + let anchored = |l: &&Link| l.in_chain && l.truth.is_some(); + let conf = |l: &Link| l.truth.map_or(0.0, |t| t.confidence); + [&lead.first, &lead.second] + .into_iter() + .filter(anchored) + .min_by(|a, b| conf(a).total_cmp(&conf(b))) +} + +/// Feature magnitudes of a lead, 0..=255 each. 0 means "absent": the weight +/// is not consulted and not updated. +/// +/// | # | feature | +/// |---|---| +/// | 0 | bias (255) | +/// | 1 | anchor confidence | +/// | 2 | anchor frequency | +/// | 3 | uncertainty of the link to investigate | +/// | 4 | links with evidence (0, 127, 254) | +#[must_use] +pub fn features(lead: &Lead) -> [u8; FEATURES] { + let a = anchor(lead).and_then(|l| l.truth); + let evidenced = u8::from(lead.first.truth.is_some()) + u8::from(lead.second.truth.is_some()); + [ + 255, + a.map_or(0, |t| unit(t.confidence)), + a.map_or(0, |t| unit(t.frequency)), + unit(lead.uncertainty), + evidenced * 127, + ] +} + +/// Learned ordering of scent leads. In memory, transient. +#[derive(Debug, Clone)] +pub struct ScentLearner { + spec: ModelSpec, + weights: [EpiLane9; FEATURES], + seen: BTreeSet, +} + +impl ScentLearner { + /// A learner that has seen nothing: every weight is `NEVER_TOUCHED`. + #[must_use] + pub fn new(spec: ModelSpec) -> Self { + ScentLearner { + spec, + weights: [EpiLane9::NEVER_TOUCHED; FEATURES], + seen: BTreeSet::new(), + } + } + + /// The learner's spec. + #[must_use] + pub fn spec(&self) -> ModelSpec { + self.spec + } + + /// The weights. + #[must_use] + pub fn weights(&self) -> &[EpiLane9; FEATURES] { + &self.weights + } + + /// `Σ wᵢ·xᵢ` over touched weights and present features. + #[must_use] + pub fn score(&self, lead: &Lead) -> i32 { + features(lead) + .iter() + .zip(&self.weights) + .map(|(&x, w)| i32::from(w.value().unwrap_or(0)) * i32::from(x)) + .sum() + } + + /// The prediction in `-127..=127`. + #[must_use] + pub fn predict(&self, lead: &Lead) -> i16 { + (self.score(lead) / SCORE_SCALE).clamp(-127, 127) as i16 + } + + /// Reorder `leads` (in `scent` order) by score within each status group. + /// Equal scores keep the `scent` order; groups keep the `scent` order. + #[must_use] + pub fn rank(&self, leads: &[Lead]) -> Vec { + let group = |s: LeadStatus| match s { + LeadStatus::Testable => 0u8, + LeadStatus::Contested => 1, + LeadStatus::Open => 2, + LeadStatus::Settled => 3, + }; + let mut out = leads.to_vec(); + out.sort_by(|a, b| { + group(a.status) + .cmp(&group(b.status)) + .then(self.score(b).cmp(&self.score(a))) + }); + out + } + + /// Learn from one investigation of `lead`, reported by `source`. + /// + /// # Errors + /// [`LearnRefusal::StaleTable`] when `step` was prepared for another spec. + pub fn learn( + &mut self, + step: &PreparedStep, + lead: &Lead, + outcome: Outcome, + source: u32, + ) -> Result { + if step.spec != self.spec { + return Err(LearnRefusal::StaleTable { + table: step.spec, + model: self.spec, + }); + } + if !self.seen.insert(source) { + return Ok(Learned::Replayed); + } + let target = match outcome { + Outcome::Hydrated => TARGET, + Outcome::Refuted => -TARGET, + }; + let residual = target - self.predict(lead); + let x = features(lead); + + // Raw signed deltas as Codec9 lanes; absent features stay untouched. + let mut mags = [0u8; FEATURES]; + let mut pres = [0u64; 1]; + for (i, &xi) in x.iter().enumerate() { + let lane = if xi == 0 { + EpiLane9::NEVER_TOUCHED + } else { + Q::encode_touched(f32::from(residual) * f32::from(xi) / 255.0) + .expect("finite by construction") + }; + mags[i] = lane.magnitude(); + pres[0] |= u64::from(lane.presence()) << i; + } + // η · delta through the odd table. + let mut out = [0u8; FEATURES]; + let mut out_pres = [0u64; 1]; + lut9(&mags, &pres, &step.table, &mut out, &mut out_pres); + + for (i, w) in self.weights.iter_mut().enumerate() { + let code = u16::from(out[i]) | (((out_pres[0] >> i) & 1) as u16) << 8; + let Some(s) = EpiLane9::from_codeword(code).and_then(EpiLane9::value) else { + continue; // feature absent: weight untouched + }; + let next = (w.value().unwrap_or(0) + s).clamp(-255, 255); + *w = Q::encode_touched(f32::from(next)).expect("finite"); + } + Ok(Learned::Updated { residual }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chain_replay::ChainStep; + use crate::nars::belief::{CStmt, Copula}; + use crate::nars::scent::scent; + use crate::nars::support::{Ablation, DerivationLedger}; + use crate::nars::truth::TruthValue; + use crate::pearl::{hydrate, Hydration, Reading}; + use causal_edge::edge::{CausalEdge64, InferenceType}; + use causal_edge::{CausalMask, PlasticityState}; + use lance_graph_contract::band_reading::EdgeProvenance; + use lance_graph_contract::class_view::ClassId; + use lance_graph_contract::epistemic_state5::{ + Certification3, Epi5Declarations, Epi5Gen, Epi5Reading, EpistemicState5, Topology2, + }; + use lance_graph_contract::rail_geometry::RailAxis; + + const QUARTER: Rate = Rate { num: 1, den: 4 }; + const HALF: Rate = Rate { num: 1, den: 2 }; + const RATES: [Rate; 4] = [ + QUARTER, + HALF, + Rate { num: 3, den: 4 }, + Rate { num: 1, den: 1 }, + ]; + + // ── numerics ──────────────────────────────────────────────────────── + + /// The table equals TFLite Micro's `LUTPopulateInt8` on every int8 code: + /// `table[v]` for `v >= 0`, `-table[-v]` for `v < 0` (oddness). + #[test] + fn the_step_table_matches_tflm_on_every_int8_code() { + let golden = include_str!("data/tflm_lut_step_golden.txt"); + let mut rows = 0; + for line in golden.lines() { + let f: Vec = line + .split(' ') + .skip(1) + .map(|t| t.parse().unwrap()) + .collect(); + let (num, den, v, tflm) = (f[0] as u8, f[1] as u8, f[2], f[3]); + let t = prepare(UpdateRule::Symmetric(Rate { num, den })).unwrap(); + let ours = if v >= 0 { + i32::from(t.table()[v as usize]) + } else { + -i32::from(t.table()[(-v) as usize]) + }; + assert_eq!(ours, tflm, "rate {num}/{den}, code {v}"); + rows += 1; + } + assert_eq!(rows, 4 * 256); + } + + fn lane_of(code: u16) -> EpiLane9 { + EpiLane9::from_codeword(code).unwrap() + } + + fn step_one(step: &PreparedStep, lane: EpiLane9) -> EpiLane9 { + let (mut out, mut out_p) = ([0u8; 1], [0u64; 1]); + lut9( + &[lane.magnitude()], + &[u64::from(lane.presence())], + step.table(), + &mut out, + &mut out_p, + ); + lane_of(u16::from(out[0]) | ((out_p[0] & 1) as u16) << 8) + } + + /// PZ1. Every one of the 512 codewords through the prepared step equals + /// an independent f64 oracle: `sign(v)·round_away(|v|·η)`, zero as `+0`, + /// `NEVER_TOUCHED` unchanged. + #[test] + fn pz1_the_step_matches_a_scalar_oracle_on_every_codeword() { + for rate in RATES { + let step = prepare(UpdateRule::Symmetric(rate)).unwrap(); + let eta = f64::from(rate.num) / f64::from(rate.den); + for code in 0..512u16 { + let lane = lane_of(code); + let got = step_one(&step, lane); + let want = match lane.value() { + None => EpiLane9::NEVER_TOUCHED, + Some(v) => { + let m = (f64::from(v.unsigned_abs()) * eta).round() as u8; + let positive = v >= 0 || m == 0; + lane_of(u16::from(m) | u16::from(positive) << 8) + } + }; + assert_eq!(got, want, "rate {rate:?}, code {code:#x}"); + } + } + } + + /// PZ2. A touched negative delta that rounds to zero is `+0`, not + /// "never touched", both at encode and after the rate table. + #[test] + fn pz2_a_negative_delta_rounding_to_zero_is_touched_plus_zero() { + let tiny = Q::encode_touched(-0.4).unwrap(); + assert!(!tiny.never_touched()); + assert_eq!(tiny.value(), Some(0)); + let step = prepare(UpdateRule::Symmetric(QUARTER)).unwrap(); + let minus_one = Q::encode_touched(-1.0).unwrap(); + let out = step_one(&step, minus_one); + assert!(!out.never_touched(), "-1 · 1/4 rounds to 0 but was touched"); + assert_eq!(out.value(), Some(0)); + assert!(step_one(&step, EpiLane9::NEVER_TOUCHED).never_touched()); + } + + /// PZ3. `lut9` evaluates odd functions only. An asymmetric or biased + /// rule is refused, not mapped; their degenerate odd forms are admitted. + #[test] + fn pz3_only_odd_rules_are_prepared() { + assert_eq!( + prepare(UpdateRule::Asymmetric { + gain: HALF, + loss: QUARTER + }), + Err(LearnRefusal::NotOdd) + ); + assert_eq!( + prepare(UpdateRule::Affine { + rate: HALF, + bias: 1 + }), + Err(LearnRefusal::NotOdd) + ); + assert!(prepare(UpdateRule::Asymmetric { + gain: HALF, + loss: HALF + }) + .is_ok()); + assert!(prepare(UpdateRule::Affine { + rate: HALF, + bias: 0 + }) + .is_ok()); + assert_eq!( + prepare(UpdateRule::Symmetric(Rate { num: 0, den: 4 })), + Err(LearnRefusal::BadRate) + ); + assert_eq!( + prepare(UpdateRule::Symmetric(Rate { num: 5, den: 4 })), + Err(LearnRefusal::BadRate) + ); + } + + /// PZ7. A table prepared for another rate or schema refuses. + #[test] + fn pz7_a_table_for_another_model_refuses() { + let lead = fixed_lead(0.9, 0.8); + let mut l = ScentLearner::new(ModelSpec::new(QUARTER)); + let half = prepare(UpdateRule::Symmetric(HALF)).unwrap(); + assert!(matches!( + l.learn(&half, &lead, Outcome::Hydrated, 1), + Err(LearnRefusal::StaleTable { .. }) + )); + let mut other_schema = ModelSpec::new(QUARTER); + other_schema.schema += 1; + let mut l2 = ScentLearner::new(other_schema); + let quarter = prepare(UpdateRule::Symmetric(QUARTER)).unwrap(); + assert!(matches!( + l2.learn(&quarter, &lead, Outcome::Hydrated, 1), + Err(LearnRefusal::StaleTable { .. }) + )); + assert!(l.weights().iter().all(|w| w.never_touched())); + assert!(l.learn(&quarter, &lead, Outcome::Hydrated, 1).is_ok()); + } + + /// The whole update against an independent f64 implementation. + #[test] + fn pz1_the_update_matches_a_scalar_oracle() { + let step = prepare(UpdateRule::Symmetric(Rate { num: 3, den: 4 })).unwrap(); + let mut l = ScentLearner::new(step.spec()); + let mut w = [None::; FEATURES]; + let script = [ + (fixed_lead(0.9, 0.8), Outcome::Hydrated), + (fixed_lead(0.3, 0.7), Outcome::Refuted), + (fixed_lead(0.6, 0.9), Outcome::Refuted), + (fixed_lead(0.95, 0.6), Outcome::Hydrated), + (fixed_lead(0.2, 0.6), Outcome::Refuted), + ]; + for (src, (lead, outcome)) in script.iter().enumerate() { + let x = features(lead); + let score: i64 = x + .iter() + .zip(&w) + .map(|(&xi, wi)| i64::from(wi.unwrap_or(0)) * i64::from(xi)) + .sum(); + let p = (score / i64::from(SCORE_SCALE)).clamp(-127, 127); + let t = if *outcome == Outcome::Hydrated { + 127 + } else { + -127 + }; + let r = t - p; + for i in 0..FEATURES { + if x[i] == 0 { + continue; + } + let d = (r as f64 * f64::from(x[i]) / 255.0) + .round() + .clamp(-255.0, 255.0); + let s = d.signum() * (d.abs() * 0.75).round(); + w[i] = Some((w[i].unwrap_or(0) + s as i32).clamp(-255, 255)); + } + l.learn(&step, lead, *outcome, src as u32).unwrap(); + let got: Vec> = l + .weights() + .iter() + .map(|e| e.value().map(i32::from)) + .collect(); + assert_eq!(got, w.to_vec(), "after step {src}"); + } + } + + /// An absent feature (here: no anchor) is neither read nor written: its + /// weight stays `NEVER_TOUCHED`. + #[test] + fn an_absent_feature_stays_never_touched() { + let step = prepare(UpdateRule::Symmetric(HALF)).unwrap(); + let mut l = ScentLearner::new(step.spec()); + let open = Lead { + mediator: 1, + first: Link { + truth: None, + in_chain: false, + }, + second: Link { + truth: None, + in_chain: false, + }, + hydration: Hydration::ProposedOnly, + status: LeadStatus::Open, + uncertainty: 1.0, + }; + l.learn(&step, &open, Outcome::Refuted, 1).unwrap(); + let w = l.weights(); + assert!(!w[0].never_touched() && !w[3].never_touched()); + assert!(w[1].never_touched() && w[2].never_touched() && w[4].never_touched()); + } + + /// PZ6. One source trains once; 63 replays move nothing. + #[test] + fn pz6_a_replayed_source_trains_once() { + let step = prepare(UpdateRule::Symmetric(HALF)).unwrap(); + let lead = fixed_lead(0.9, 0.8); + let mut once = ScentLearner::new(step.spec()); + once.learn(&step, &lead, Outcome::Hydrated, 7).unwrap(); + let mut many = once.clone(); + for _ in 0..63 { + assert_eq!( + many.learn(&step, &lead, Outcome::Hydrated, 7), + Ok(Learned::Replayed) + ); + } + assert_eq!(many.weights(), once.weights()); + } + + fn fixed_lead(conf: f32, freq: f32) -> Lead { + Lead { + mediator: 1, + first: Link { + truth: Some(TruthValue::new(freq, conf)), + in_chain: true, + }, + second: Link { + truth: None, + in_chain: false, + }, + hydration: Hydration::Partial, + status: LeadStatus::Testable, + uncertainty: 1.0, + } + } + + // ── fixture worlds ────────────────────────────────────────────────── + + const CLASS: ClassId = 0x0902; + const RAIL: RailAxis = RailAxis::Taxonomy; + const A: u16 = 10; + const Y: u16 = 30; + + fn imp(s: u16, p: u16) -> CStmt { + CStmt { + s, + cop: Copula::Impl, + p, + } + } + + fn decls() -> Epi5Declarations { + let mut d = Epi5Declarations::new(); + d.declare( + CLASS, + RAIL, + Epi5Reading { + generation: Epi5Gen::V1, + }, + ); + d + } + + fn reading(d: &Epi5Declarations) -> Reading<'_> { + Reading { + declarations: d, + class: CLASS, + rail: RAIL, + provenance: EdgeProvenance::V2Stamped, + } + } + + fn edge(s: u16, o: u16, t: Topology2, c: Certification3) -> CausalEdge64 { + CausalEdge64::pack( + s as u8, + 0x91, + o as u8, + 200, + 200, + CausalMask::SO, + causal_edge::Orientation::from_code(0b101), + InferenceType::Deduction, + PlasticityState::S_HOT, + 0, + ) + .with_epistemic_raw5(EpistemicState5::new(Epi5Gen::V1, t, c).raw()) + } + + fn step(s: u16, o: u16) -> ChainStep { + ( + 0xFF, + edge(s, o, Topology2::Direct, Certification3::Associated), + ) + } + + fn query() -> CausalEdge64 { + edge(A, Y, Topology2::IndirectUnknown, Certification3::Causes) + } + + /// SplitMix64, deterministic. + struct Rng(u64); + impl Rng { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + fn unit(&mut self, lo: f32, hi: f32) -> f32 { + lo + (hi - lo) * ((self.next() >> 40) as f32 / (1u64 << 24) as f32) + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n as u64) as usize + } + } + + #[derive(Clone, Copy)] + struct Shape { + decoys: usize, + /// The known link is `A → B` (true) or `B → Y` (false). + first_known: bool, + /// Whether the true mediator's anchor confidence differs from decoys'. + informative: bool, + } + + /// One true mediator and `decoys` candidates, each with one grounded, + /// chain-bound link. Every candidate is Testable with uncertainty 1.0, + /// so `scent` orders them by id alone. + struct World { + ledger: DerivationLedger, + sealed: Vec, + candidates: Vec, + truth: u16, + shape: Shape, + next_source: u32, + } + + impl World { + fn new(seed: u64, shape: Shape) -> Self { + let mut rng = Rng(seed); + let mut pool: Vec = (40..=200).collect(); + let mut candidates = Vec::new(); + for _ in 0..=shape.decoys { + candidates.push(pool.swap_remove(rng.below(pool.len()))); + } + let truth = candidates[rng.below(candidates.len())]; + let mut ledger = DerivationLedger::new(); + let mut sealed = Vec::new(); + let mut src = 1; + for &c in &candidates { + let conf = if c == truth || !shape.informative { + rng.unit(0.15, 0.95) + } else { + rng.unit(0.15, 0.45) + }; + let conf = if c == truth && shape.informative { + rng.unit(0.8, 0.95) + } else { + conf + }; + let freq = rng.unit(0.6, 0.95); + let (s, p) = if shape.first_known { (A, c) } else { (c, Y) }; + ledger.observe(imp(s, p), TruthValue::new(freq, conf), src); + src += 1; + sealed.push(step(s, p)); + } + World { + ledger, + sealed, + candidates, + truth, + shape, + next_source: 1000, + } + } + + fn leads(&self, d: &Epi5Declarations) -> Vec { + scent( + &self.ledger, + query(), + imp(A, Y), + &self.candidates, + &self.sealed, + reading(d), + ) + .unwrap() + } + + /// Observe `m`'s missing link. The source id identifies this outcome. + fn investigate(&mut self, m: u16) -> (Outcome, u32) { + let src = self.next_source; + self.next_source += 1; + if m != self.truth { + return (Outcome::Refuted, src); + } + let (s, p) = if self.shape.first_known { + (m, Y) + } else { + (A, m) + }; + self.ledger + .observe(imp(s, p), TruthValue::new(0.9, 0.9), src); + self.sealed.push(step(s, p)); + (Outcome::Hydrated, src) + } + + fn hydrated(&self, d: &Epi5Declarations) -> bool { + hydrate( + query(), + (A as u8, self.truth as u8, Y as u8), + &self.sealed, + reading(d), + ) + .unwrap() + .1 == Hydration::Hydrated + } + } + + #[derive(Clone, Copy)] + enum Policy<'a> { + Learned(&'a ScentLearner), + Scent, + RoundRobin, + Random(u64), + } + + /// Investigations until the true mediator hydrates. + fn run(seed: u64, shape: Shape, policy: Policy<'_>, d: &Epi5Declarations) -> usize { + let mut w = World::new(seed, shape); + let mut visited: Vec = Vec::new(); + let mut rng = Rng(seed ^ 0xA5A5); + for n in 1..=w.candidates.len() { + let fresh: Vec = w + .leads(d) + .into_iter() + .filter(|l| !visited.contains(&l.mediator)) + .collect(); + let pick = match policy { + Policy::Learned(l) => l.rank(&fresh)[0].mediator, + Policy::Scent => fresh[0].mediator, + Policy::RoundRobin => *w.candidates.iter().find(|c| !visited.contains(c)).unwrap(), + Policy::Random(s) => { + let _ = s; + fresh[rng.below(fresh.len())].mediator + } + }; + visited.push(pick); + w.investigate(pick); + if w.hydrated(d) { + return n; + } + } + usize::MAX + } + + /// Train online on `worlds` seeds of `shape`, learning from every + /// investigation of the learner's own choice. + fn train(shape: Shape, seeds: std::ops::Range, d: &Epi5Declarations) -> ScentLearner { + let step = prepare(UpdateRule::Symmetric(QUARTER)).unwrap(); + let mut l = ScentLearner::new(step.spec()); + for seed in seeds { + let mut w = World::new(seed, shape); + let mut visited: Vec = Vec::new(); + loop { + let fresh: Vec = w + .leads(d) + .into_iter() + .filter(|x| !visited.contains(&x.mediator)) + .collect(); + let lead = l.rank(&fresh)[0].clone(); + visited.push(lead.mediator); + let (outcome, src) = w.investigate(lead.mediator); + l.learn(&step, &lead, outcome, src_key(seed, src)).unwrap(); + if outcome == Outcome::Hydrated { + break; + } + } + } + l + } + + fn src_key(seed: u64, src: u32) -> u32 { + (seed as u32) << 12 | src + } + + fn mean(shape: Shape, seeds: std::ops::Range, p: Policy<'_>, d: &Epi5Declarations) -> f64 { + let n = seeds.end - seeds.start; + seeds.map(|s| run(s, shape, p, d) as f64).sum::() / n as f64 + } + + const TRAIN: Shape = Shape { + decoys: 3, + first_known: true, + informative: true, + }; + /// Held out: twice as many decoys, the known link on the other side, + /// seeds disjoint from training. + const HELD_OUT: Shape = Shape { + decoys: 7, + first_known: false, + informative: true, + }; + + /// PZ5. Trained on one world shape, the learner finds the true mediator + /// on held-out worlds of another shape in fewer investigations than + /// `scent`'s id order, round-robin or random. + #[test] + fn pz5_learned_order_beats_source_blind_baselines_on_held_out_worlds() { + let d = decls(); + let l = train(TRAIN, 0..40, &d); + let test = 1000..1060; + let learned = mean(HELD_OUT, test.clone(), Policy::Learned(&l), &d); + let by_scent = mean(HELD_OUT, test.clone(), Policy::Scent, &d); + let rr = mean(HELD_OUT, test.clone(), Policy::RoundRobin, &d); + let random = mean(HELD_OUT, test.clone(), Policy::Random(7), &d); + eprintln!("held-out mean investigations: learned {learned:.2}, scent {by_scent:.2}, round-robin {rr:.2}, random {random:.2}"); + assert!(learned < 1.5, "learned {learned}"); + for (name, b) in [("scent", by_scent), ("round-robin", rr), ("random", random)] { + assert!(b > 3.0, "{name} {b}"); + } + } + + /// The silent twin of PZ5: where the anchor carries no information, the + /// learner is no better than the baselines. + #[test] + fn pz5_no_gain_where_the_features_carry_no_information() { + let d = decls(); + let blind = Shape { + informative: false, + ..TRAIN + }; + let blind_test = Shape { + informative: false, + ..HELD_OUT + }; + let l = train(blind, 0..40, &d); + let test = 1000..1060; + let learned = mean(blind_test, test.clone(), Policy::Learned(&l), &d); + let random = mean(blind_test, test.clone(), Policy::Random(7), &d); + eprintln!("uninformative: learned {learned:.2}, random {random:.2}"); + assert!(learned > 3.0, "learned {learned}"); + assert!( + (learned - random).abs() < 1.5, + "learned {learned}, random {random}" + ); + } + + /// An untrained learner reproduces `scent`'s order exactly. + #[test] + fn an_untrained_learner_keeps_the_scent_order() { + let d = decls(); + let w = World::new(3, HELD_OUT); + let leads = w.leads(&d); + let l = ScentLearner::new(ModelSpec::new(QUARTER)); + assert_eq!(l.rank(&leads), leads); + } + + /// PZ8. However the weights move, a contested lead stays ahead of a + /// settled one: learning orders within a status group only. + #[test] + fn pz8_a_contested_lead_stays_ahead_of_a_settled_one() { + let d = decls(); + let l = train(TRAIN, 0..40, &d); + let settled = Lead { + mediator: 2, + first: Link { + truth: Some(TruthValue::new(0.9, 0.95)), + in_chain: true, + }, + second: Link { + truth: Some(TruthValue::new(0.9, 0.95)), + in_chain: true, + }, + hydration: Hydration::Hydrated, + status: LeadStatus::Settled, + uncertainty: 0.3, + }; + let contested = Lead { + mediator: 3, + first: Link { + truth: Some(TruthValue::new(0.9, 0.95)), + in_chain: true, + }, + second: Link { + truth: Some(TruthValue::new(0.1, 0.2)), + in_chain: true, + }, + hydration: Hydration::Hydrated, + status: LeadStatus::Contested, + uncertainty: 0.6, + }; + assert!( + l.score(&settled) > l.score(&contested), + "fixture must test the guard" + ); + let order = l.rank(&[contested.clone(), settled.clone()]); + assert_eq!(order[0].mediator, contested.mediator); + } + + /// PZ4. Training changes no evidence, no edge and no scent output. + #[test] + fn pz4_learning_writes_no_evidence() { + let d = decls(); + let w = World::new(5, HELD_OUT); + let before = ( + w.leads(&d), + w.ledger.pooled(imp(A, Y), &Ablation::none()).unwrap(), + ); + let e = query(); + let _l = train(TRAIN, 0..40, &d); + let after = ( + w.leads(&d), + w.ledger.pooled(imp(A, Y), &Ablation::none()).unwrap(), + ); + assert_eq!(before, after); + assert_eq!(e, query()); + assert!(after.1.is_none(), "no observation of A → Y was created"); + } + + /// Cost of one decision, reported, not gated. Run with + /// `--release --ignored --nocapture`. + #[test] + #[ignore] + fn cost_probe() { + let d = decls(); + let l = train(TRAIN, 0..40, &d); + let w = World::new(1000, HELD_OUT); + let leads = w.leads(&d); + let step = prepare(UpdateRule::Symmetric(QUARTER)).unwrap(); + let n = 100_000u32; + let t = std::time::Instant::now(); + let mut acc = 0u16; + for _ in 0..n { + acc ^= std::hint::black_box(l.rank(std::hint::black_box(&leads)))[0].mediator; + } + let rank_ns = t.elapsed().as_nanos() as f64 / f64::from(n); + let t = std::time::Instant::now(); + for i in 0..n { + let mut m = l.clone(); + let _ = std::hint::black_box(m.learn(&step, &leads[0], Outcome::Refuted, i)); + } + let learn_ns = t.elapsed().as_nanos() as f64 / f64::from(n); + let t = std::time::Instant::now(); + for _ in 0..1000 { + std::hint::black_box(w.leads(&d)); + } + let scent_ns = t.elapsed().as_nanos() as f64 / 1000.0; + eprintln!( + "{} leads: rank {rank_ns:.0} ns, learn (incl. clone) {learn_ns:.0} ns, scent {scent_ns:.0} ns ({acc})", + leads.len() + ); + } +} diff --git a/docs/research/epistemic-cot/oracles/tflm_lut_step_oracle.cc b/docs/research/epistemic-cot/oracles/tflm_lut_step_oracle.cc new file mode 100644 index 000000000..c8a54dd27 --- /dev/null +++ b/docs/research/epistemic-cot/oracles/tflm_lut_step_oracle.cc @@ -0,0 +1,23 @@ +// Generator of crates/lance-graph-planner/src/nars/data/tflm_lut_step_golden.txt. +// Built against the real TFLite-micro sources (AdaWorldAPI/tflite-micro @ 5f64e4a): +// g++ -std=c++17 -O1 -I tflm_lut_step_oracle.cc +// ./a.out > tflm_lut_step_golden.txt +// Each line: `L `, for the +// learning step f(x) = x * (num/den) at unit scale, zero point 0 (Sym). +// Not built by cargo; kept so the fixture can be regenerated. +#include +#include +#include "tensorflow/lite/micro/kernels/internal/common.h" +using namespace tflite; +static float eta; +static float step(float x) { return x * eta; } +int main() { + const int rates[][2] = {{1, 4}, {1, 2}, {3, 4}, {1, 1}}; + for (auto& r : rates) { + eta = (float)r[0] / (float)r[1]; + int8_t lut[256]; + LUTPopulate(1.0f, 0, 1.0f, 0, step, lut); + for (int v = -128; v <= 127; ++v) + printf("L %d %d %d %d\n", r[0], r[1], v, (int)lut[(uint8_t)(int8_t)v]); + } +} From 97d0fcc1786137d5f354d5cfda4d3b67f395d215 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 19:36:49 +0000 Subject: [PATCH 2/7] planner: scent_learn test for a present feature with a zero step Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMPWTwovjYJEUMuGPb4 --- .../lance-graph-planner/src/nars/scent_learn.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/lance-graph-planner/src/nars/scent_learn.rs b/crates/lance-graph-planner/src/nars/scent_learn.rs index cecc3391c..cbed5233d 100644 --- a/crates/lance-graph-planner/src/nars/scent_learn.rs +++ b/crates/lance-graph-planner/src/nars/scent_learn.rs @@ -475,6 +475,23 @@ mod tests { assert!(step_one(&step, EpiLane9::NEVER_TOUCHED).never_touched()); } + /// PZ2 in the learner. A present feature whose delta rounds to zero is + /// marked seen (`+0`), not left "never touched". Here x = 1 and the + /// residual is 127, so the raw delta is 127/255 ≈ 0.498, which rounds to 0. + #[test] + fn pz2_a_present_feature_with_a_zero_step_is_marked_seen() { + let step = prepare(UpdateRule::Symmetric(HALF)).unwrap(); + let mut l = ScentLearner::new(step.spec()); + let mut lead = fixed_lead(0.9, 0.8); + lead.uncertainty = 0.002; + assert_eq!(features(&lead)[3], 1, "fixture must give x = 1"); + assert_eq!( + l.learn(&step, &lead, Outcome::Hydrated, 1), + Ok(Learned::Updated { residual: 127 }) + ); + assert_eq!(l.weights()[3].value(), Some(0)); + } + /// PZ3. `lut9` evaluates odd functions only. An asymmetric or biased /// rule is refused, not mapped; their degenerate odd forms are admitted. #[test] From 9a11e129c9caadfa21b463d8d440ac5bfe17a6f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 19:38:03 +0000 Subject: [PATCH 3/7] docs/board: D-EPI-COT C3c learned scent ordering (doc, falsifier map, entry) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMPWTwovjYJEUMuGPb4 --- .../entries/2026-10-28-d-epi-cot-c3c-learn.md | 22 ++++ .claude/board/entries/README.md | 3 +- docs/research/epistemic-cot/c3c-learning.md | 106 ++++++++++++++++++ 3 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 .claude/board/entries/2026-10-28-d-epi-cot-c3c-learn.md create mode 100644 docs/research/epistemic-cot/c3c-learning.md diff --git a/.claude/board/entries/2026-10-28-d-epi-cot-c3c-learn.md b/.claude/board/entries/2026-10-28-d-epi-cot-c3c-learn.md new file mode 100644 index 000000000..57ca033aa --- /dev/null +++ b/.claude/board/entries/2026-10-28-d-epi-cot-c3c-learn.md @@ -0,0 +1,22 @@ +# D-EPI-COT-C3C: learned ordering of scent leads over Codec9/lut9 + +**Status:** TEST-PINNED (fixture worlds). Code: `crates/lance-graph-planner/src/nars/scent_learn.rs`. Doc: `docs/research/epistemic-cot/c3c-learning.md`. + +## Decision + +- `ScentLearner` reorders C3b leads within a status group, learning from investigation outcomes. Its output is an order, with no truth, Epi5, CE64 or ledger write. +- It consumes merged ndarray `codec9::EpiLane9`, `Codec9` and `simd::lut9` and adds no kernel. +- The rate table follows TFLM `LUTPopulateInt8` and matches a golden from tflite-micro `5f64e4a` on all 256 int8 codes for 4 rates. +- Only odd rules are prepared. A table for another spec refuses. One source trains once. + +## Evidence + +- 14 tests, plus an ignored cost probe. 9 disables, each red. +- Held-out PZ5 (separable fixture): learned 1.00 vs scent 4.00, round-robin 4.25, random 4.73. Uninformative twin: learned 4.70 vs random 4.73. +- rank 736 ns, learn 404 ns, scent 4.5 µs (8 leads, one host). + +## Open + +- The label should become "the ablation discriminated", using the ledger's `sensitivity`. +- Overlapping-evidence fixtures and real HIRO/Odoo outcomes. +- The spec binding is transient. Production generation binding stays with EXO-152 PZL-4/G1. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index ee43d7b03..f8702d3c2 100644 --- a/.claude/board/entries/README.md +++ b/.claude/board/entries/README.md @@ -25,13 +25,14 @@ index row, (3) no duplicate entry id. Checks 1 and 2 are deliberately opposite directions; the stranding this convention prevents shows up in exactly one of them, never both. -498 entries, 2026-08-06 .. 2026-10-28. +499 entries, 2026-08-06 .. 2026-10-28. | date | entry id | finding | file | |---|---|---|---| | 2026-10-28 | `thought-layers-nan-skip` | | [2026-10-28-thought-layers-nan-skip.md](2026-10-28-thought-layers-nan-skip.md) | | 2026-10-28 | `lane-layout-from-classview` | | [2026-10-28-lane-layout-from-classview.md](2026-10-28-lane-layout-from-classview.md) | | 2026-10-28 | `D-SAG-6-1` | | [2026-10-28-d-sag-6-1-audit-reference-ledger.md](2026-10-28-d-sag-6-1-audit-reference-ledger.md) | +| 2026-10-28 | `D-EPI-COT-C3C` | | [2026-10-28-d-epi-cot-c3c-learn.md](2026-10-28-d-epi-cot-c3c-learn.md) | | 2026-10-28 | `D-EPI-COT-C3B` | | [2026-10-28-d-epi-cot-c3b-scent.md](2026-10-28-d-epi-cot-c3b-scent.md) | | 2026-10-28 | `D-EPI-COT` | | [2026-10-28-d-epi-cot-c0-c2.md](2026-10-28-d-epi-cot-c0-c2.md) | | 2026-10-28 | `D-DT64-6` | | [2026-10-28-d-dt64-6-routing-bindstore-behavior.md](2026-10-28-d-dt64-6-routing-bindstore-behavior.md) | diff --git a/docs/research/epistemic-cot/c3c-learning.md b/docs/research/epistemic-cot/c3c-learning.md new file mode 100644 index 000000000..76f10708e --- /dev/null +++ b/docs/research/epistemic-cot/c3c-learning.md @@ -0,0 +1,106 @@ +# D-EPI-COT C3c — learned ordering of scent leads + +Status: TEST-PINNED on fixture worlds. Code: +`crates/lance-graph-planner/src/nars/scent_learn.rs`. Builds on C3b (`scent`). + +## Question + +C3b ranks candidate mediators by status, then by uncertainty. When several +leads tie on both, the order falls back to mediator id, which carries no +information. Can past investigation outcomes order those ties better? + +## Answer: `nars::scent_learn::ScentLearner` + +- A linear score over five lead features: bias, anchor confidence, anchor + frequency, uncertainty, and the number of links with evidence. +- The learner reorders leads **within** a status group. It never moves a lead + across groups, so a contested lead stays ahead of a settled one (PZ8). +- After each investigation it updates on the signed residual + `target − prediction`. The target is ±127: hydrated is +, refuted is −. +- Its output is an order. It holds no ledger or edge and writes neither. A + prediction is never recorded as an observation. + +## Numerics: consumer of merged ndarray kernels, no new kernel + +| piece | where | contract | +|---|---|---| +| weights, deltas, steps | `ndarray::hpc::codec9::EpiLane9` | `NEVER_TOUCHED` = feature never seen; a touched zero is `+0` | +| encoding | `Codec9::encode_touched` | ties away from zero, as TFLite; saturates at 255 | +| learning rate | `ndarray::simd::lut9` | odd functions only | +| rate table | TFLM `LUTPopulateInt8` recipe | `x·η`, `TfLiteRound`, saturate | + +**TFLM reference.** `data/tflm_lut_step_golden.txt` was generated by +`oracles/tflm_lut_step_oracle.cc`, compiled against AdaWorldAPI/tflite-micro +`5f64e4a` (`tensorflow/lite/micro/kernels/internal/common.h`, `LUTPopulate`). + +- It covers four rates (1/4, 1/2, 3/4, 1) over all 256 int8 codes. +- The SM9 table matches TFLM on every code: `table[v]` for `v ≥ 0` and + `−table[−v]` for `v < 0`. Ties-away rounding of `x·η` is odd, which is why + one magnitude table covers both signs. +- Magnitudes 129..=255 use the same formula but have no int8 TFLM counterpart. + +In-tree, ndarray's requantization chain (`simd_fixedpoint`) is byte-parity +tested against TFLM. `lut9` itself is tested against a scalar oracle, and +Codec9's never-touched code has no TFLM counterpart (TFLM reserves no int8 +code). The TFLM claim here is about this rate table only. + +**Spec binding (PZ7).** A prepared table carries `ModelSpec`, which is the +feature schema, the codec scale bits and the rate. A learner refuses a table +built for a different spec. The spec lives in memory only. It is not a +generation store and not EXO-152's PZL-4/G1 binding. + +## Falsifiers + +| item | test | disable that turns it red | +|---|---|---| +| TFLM table | `the_step_table_matches_tflm_on_every_int8_code` | ties-to-even rounding | +| PZ1 codewords | `pz1_the_step_matches_a_scalar_oracle_on_every_codeword` (all 512 codewords × 4 rates vs f64) | ties-to-even | +| PZ1 update | `pz1_the_update_matches_a_scalar_oracle` (5 steps vs an independent f64 learner) | ties-to-even; no weight write | +| PZ2 | `pz2_a_negative_delta_rounding_to_zero_is_touched_plus_zero`, `pz2_a_present_feature_with_a_zero_step_is_marked_seen` | a zero delta encoded as never-touched | +| absent feature | `an_absent_feature_stays_never_touched` | an absent feature encoded as touched | +| PZ3 | `pz3_only_odd_rules_are_prepared` | admit an asymmetric rule | +| PZ4 | `pz4_learning_writes_no_evidence` | — (no write path, by construction) | +| PZ5 | `pz5_learned_order_beats_source_blind_baselines_on_held_out_worlds` | rank ignores the score; no weight write | +| PZ5 silent twin | `pz5_no_gain_where_the_features_carry_no_information` | — | +| PZ6 | `pz6_a_replayed_source_trains_once` (63 replays) | no replay guard | +| PZ7 | `pz7_a_table_for_another_model_refuses` (rate and schema) | no spec check | +| PZ8 | `pz8_a_contested_lead_stays_ahead_of_a_settled_one` | rank across status groups | + +9 disables, each red. The PZ2 learner test was added after the "zero delta +encoded as never-touched" disable first stayed green. + +## Measured + +PZ5 trains on 40 worlds of one shape: 3 decoys, the known link `A → B`. It +tests on 60 held-out worlds of another shape: 7 decoys, the known link +`B → Y`, disjoint seeds. The table shows mean investigations to hydrate the +true mediator: + +| policy | informative fixture | uninformative twin | +|---|---|---| +| learned | 1.00 | 4.70 | +| scent (id order) | 4.00 | — | +| round-robin | 4.25 | — | +| random | 4.73 | 4.73 | + +Cost on one host, release, 8 leads (reported, not gated): + +| operation | cost | +|---|---| +| `rank` | 736 ns | +| `learn`, including a clone | 404 ns | +| `scent` | 4.5 µs | + +## What this does not show + +- **The informative fixture is separable.** True mediators have anchor + confidence 0.8–0.95 and decoys 0.15–0.45, so 1.00 measures that the learner + finds a separating feature. It does not measure performance on realistic, + overlapping evidence. +- **One fixture family, one host.** HIRO and Odoo outcomes are not used yet. +- **No discrimination target yet.** The target is "this lead hydrated". + C3c-plan §5 asks for "this ablation discriminated between competing + intermediates", which needs the ledger's `sensitivity` as the label. That + is the next step. +- **The score is a ranking.** No calibrated probability is claimed. +- **Bits 50..52 are untouched.** From 2ca94b41fe2ddf6fa341739d0abaed918679845e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 19:40:40 +0000 Subject: [PATCH 4/7] planner: scent_learn runtime stays in code space (no float, no runtime rounding) Deltas are built by integer truncation with the zero rule P = present AND (positive OR M == 0); weights add and saturate as integers. Rounding happens only when the rate table is built offline (TFLM recipe). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMPWTwovjYJEUMuGPb4 --- .../src/nars/scent_learn.rs | 103 +++++++++--------- 1 file changed, 52 insertions(+), 51 deletions(-) diff --git a/crates/lance-graph-planner/src/nars/scent_learn.rs b/crates/lance-graph-planner/src/nars/scent_learn.rs index cbed5233d..6239bca4e 100644 --- a/crates/lance-graph-planner/src/nars/scent_learn.rs +++ b/crates/lance-graph-planner/src/nars/scent_learn.rs @@ -19,18 +19,22 @@ //! //! The arithmetic consumes merged ndarray kernels and adds none: //! -//! - Weights and steps are [`EpiLane9`] (Codec9, DR512 sign-magnitude). -//! `NEVER_TOUCHED` means "feature never seen", distinct from a learned -//! `+0`. -//! - A signed residual is encoded with `Codec9::::encode_touched`. -//! A touched negative value that rounds to zero is `+0`, never -//! `NEVER_TOUCHED` (PZ2). -//! - The learning rate is applied by `ndarray::simd::lut9`, which only -//! evaluates odd functions. Only a symmetric rule is prepared; -//! an asymmetric or biased rule is refused (PZ3). -//! - The rate table is built with TFLite Micro's `LUTPopulateInt8` recipe -//! (`common.h`): code → `x·η` → `TfLiteRound` (ties away from zero) → -//! saturate. `data/tflm_lut_step_golden.txt` was generated by +//! - Weights and steps are [`EpiLane9`] (Codec9 / DR512 sign-magnitude). +//! `NEVER_TOUCHED` (P=0, M=0) means "feature never seen", distinct from a +//! learned `+0`. +//! - **Nothing at runtime rounds or roundtrips through a float.** A delta is +//! built in code space: magnitude `|r|·x / 255` by integer truncation (odd, +//! deterministic), sign from `r`. A touched delta whose magnitude truncates +//! to 0 gets P=1 by a mask rule, `P = present AND (positive OR M == 0)`, so +//! −0.49 becomes +0, never "never touched" (PZ2). This is the same zero rule +//! `lut9` applies to its own output. Weights are added and saturated as +//! integers. +//! - The learning rate is applied by `ndarray::simd::lut9`, a plain +//! table lookup that only evaluates odd functions. Only a symmetric rule is +//! prepared; an asymmetric or biased rule is refused (PZ3). +//! - Rounding happens once, offline, when the table is built. It follows +//! TFLite Micro's `LUTPopulateInt8` recipe (`common.h`): code → `x·η` → +//! `TfLiteRound` (ties away from zero) → saturate. `data/tflm_lut_step_golden.txt` was generated by //! `docs/research/epistemic-cot/oracles/tflm_lut_step_oracle.cc`, compiled //! against tflite-micro. The table matches it on every int8 code //! (magnitudes 0..=128). Magnitudes 129..=255 use the same formula but have @@ -42,8 +46,7 @@ use std::collections::BTreeSet; -use ndarray::hpc::codec8::{Calibration, TiesAway}; -use ndarray::hpc::codec9::{Codec9, EpiLane9}; +use ndarray::hpc::codec9::EpiLane9; use ndarray::simd::lut9; use super::scent::{Lead, LeadStatus, Link}; @@ -57,15 +60,8 @@ pub const SCORE_SCALE: i32 = 512; /// Target for an outcome: `+TARGET` hydrated, `-TARGET` refuted. pub const TARGET: i16 = 127; -/// Unit symmetric calibration: one code step is 1.0. -pub struct Unit; -impl Calibration for Unit { - const SCALE: f32 = 1.0; - const ZERO_POINT: i32 = 0; -} - -/// The codec. Ties away from zero, as TFLite (`std::round`). -pub type Q = Codec9; +/// Feature code for 1.0: feature magnitudes are `0..=FEATURE_SCALE`. +pub const FEATURE_SCALE: u8 = 255; /// A learning rate `num/den` with `0 < num <= den`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -81,8 +77,8 @@ pub struct Rate { pub struct ModelSpec { /// Feature schema version ([`SCHEMA`]). pub schema: u16, - /// Bits of the codec scale (`Unit::SCALE`). - pub scale_bits: u32, + /// Feature code for 1.0 ([`FEATURE_SCALE`]). + pub feature_scale: u8, /// The learning rate. pub rate: Rate, } @@ -93,7 +89,7 @@ impl ModelSpec { pub fn new(rate: Rate) -> Self { ModelSpec { schema: SCHEMA, - scale_bits: Unit::SCALE.to_bits(), + feature_scale: FEATURE_SCALE, rate, } } @@ -243,6 +239,12 @@ pub fn features(lead: &Lead) -> [u8; FEATURES] { ] } +/// A touched lane for `v` in `-255..=255`; zero is `+0`. +fn lane(v: i16) -> EpiLane9 { + let code = v.unsigned_abs() | u16::from(v >= 0) << 8; + EpiLane9::from_codeword(code).expect("|v| <= 255") +} + /// Learned ordering of scent leads. In memory, transient. #[derive(Debug, Clone)] pub struct ScentLearner { @@ -336,19 +338,21 @@ impl ScentLearner { let residual = target - self.predict(lead); let x = features(lead); - // Raw signed deltas as Codec9 lanes; absent features stay untouched. - let mut mags = [0u8; FEATURES]; - let mut pres = [0u64; 1]; + // Signed deltas in code space, no float and no rounding: + // M = |r|·x / 255 truncated, P = present AND (positive OR M == 0). + // An absent feature is P=0, M=0 (never touched) and lut9 keeps it so. + let (mut mags, mut present, mut zero) = ([0u8; FEATURES], 0u64, 0u64); for (i, &xi) in x.iter().enumerate() { - let lane = if xi == 0 { - EpiLane9::NEVER_TOUCHED - } else { - Q::encode_touched(f32::from(residual) * f32::from(xi) / 255.0) - .expect("finite by construction") - }; - mags[i] = lane.magnitude(); - pres[0] |= u64::from(lane.presence()) << i; + if xi == 0 { + continue; + } + present |= 1 << i; + let m = (u32::from(residual.unsigned_abs()) * u32::from(xi)) / u32::from(FEATURE_SCALE); + mags[i] = m.min(255) as u8; + zero |= u64::from(mags[i] == 0) << i; } + let positive = if residual >= 0 { present } else { 0 }; + let pres = [present & (positive | zero)]; // η · delta through the odd table. let mut out = [0u8; FEATURES]; let mut out_pres = [0u64; 1]; @@ -359,8 +363,7 @@ impl ScentLearner { let Some(s) = EpiLane9::from_codeword(code).and_then(EpiLane9::value) else { continue; // feature absent: weight untouched }; - let next = (w.value().unwrap_or(0) + s).clamp(-255, 255); - *w = Q::encode_touched(f32::from(next)).expect("finite"); + *w = lane((w.value().unwrap_or(0) + s).clamp(-255, 255)); } Ok(Learned::Updated { residual }) } @@ -460,24 +463,23 @@ mod tests { } } - /// PZ2. A touched negative delta that rounds to zero is `+0`, not - /// "never touched", both at encode and after the rate table. + /// PZ2. A touched negative step that comes out of the rate table as 0 + /// is `+0`, not "never touched"; an untouched input stays untouched. #[test] - fn pz2_a_negative_delta_rounding_to_zero_is_touched_plus_zero() { - let tiny = Q::encode_touched(-0.4).unwrap(); - assert!(!tiny.never_touched()); - assert_eq!(tiny.value(), Some(0)); + fn pz2_a_negative_step_to_zero_is_touched_plus_zero() { + assert_eq!(lane(0), lane_of(0x100)); + assert!(!lane(0).never_touched()); let step = prepare(UpdateRule::Symmetric(QUARTER)).unwrap(); - let minus_one = Q::encode_touched(-1.0).unwrap(); + let minus_one = lane(-1); let out = step_one(&step, minus_one); - assert!(!out.never_touched(), "-1 · 1/4 rounds to 0 but was touched"); + assert!(!out.never_touched(), "-1 · 1/4 is 0 in the table but the lane was touched"); assert_eq!(out.value(), Some(0)); assert!(step_one(&step, EpiLane9::NEVER_TOUCHED).never_touched()); } /// PZ2 in the learner. A present feature whose delta rounds to zero is /// marked seen (`+0`), not left "never touched". Here x = 1 and the - /// residual is 127, so the raw delta is 127/255 ≈ 0.498, which rounds to 0. + /// residual is 127, so the raw delta is 127/255 ≈ 0.498, which truncates to 0. #[test] fn pz2_a_present_feature_with_a_zero_step_is_marked_seen() { let step = prepare(UpdateRule::Symmetric(HALF)).unwrap(); @@ -583,9 +585,8 @@ mod tests { if x[i] == 0 { continue; } - let d = (r as f64 * f64::from(x[i]) / 255.0) - .round() - .clamp(-255.0, 255.0); + // Truncation toward zero, as an independent f64 formula. + let d = (r as f64 * f64::from(x[i]) / 255.0).trunc(); let s = d.signum() * (d.abs() * 0.75).round(); w[i] = Some((w[i].unwrap_or(0) + s as i32).clamp(-255, 255)); } From 7f3e4a1e8e22c3c9578186c80dbd01bb1cc744f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 19:41:18 +0000 Subject: [PATCH 5/7] planner: scent_learn PZ5 over 600 held-out worlds, tighter silent-twin bound Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMPWTwovjYJEUMuGPb4 --- crates/lance-graph-planner/src/nars/scent_learn.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/lance-graph-planner/src/nars/scent_learn.rs b/crates/lance-graph-planner/src/nars/scent_learn.rs index 6239bca4e..fe7f08fc0 100644 --- a/crates/lance-graph-planner/src/nars/scent_learn.rs +++ b/crates/lance-graph-planner/src/nars/scent_learn.rs @@ -472,7 +472,10 @@ mod tests { let step = prepare(UpdateRule::Symmetric(QUARTER)).unwrap(); let minus_one = lane(-1); let out = step_one(&step, minus_one); - assert!(!out.never_touched(), "-1 · 1/4 is 0 in the table but the lane was touched"); + assert!( + !out.never_touched(), + "-1 · 1/4 is 0 in the table but the lane was touched" + ); assert_eq!(out.value(), Some(0)); assert!(step_one(&step, EpiLane9::NEVER_TOUCHED).never_touched()); } @@ -935,7 +938,7 @@ mod tests { fn pz5_learned_order_beats_source_blind_baselines_on_held_out_worlds() { let d = decls(); let l = train(TRAIN, 0..40, &d); - let test = 1000..1060; + let test = 1000..1600; let learned = mean(HELD_OUT, test.clone(), Policy::Learned(&l), &d); let by_scent = mean(HELD_OUT, test.clone(), Policy::Scent, &d); let rr = mean(HELD_OUT, test.clone(), Policy::RoundRobin, &d); @@ -961,13 +964,14 @@ mod tests { ..HELD_OUT }; let l = train(blind, 0..40, &d); - let test = 1000..1060; + let test = 1000..1600; let learned = mean(blind_test, test.clone(), Policy::Learned(&l), &d); let random = mean(blind_test, test.clone(), Policy::Random(7), &d); eprintln!("uninformative: learned {learned:.2}, random {random:.2}"); - assert!(learned > 3.0, "learned {learned}"); + // 600 worlds: the standard error of each mean is about 0.1. + assert!(learned > 4.0, "learned {learned}"); assert!( - (learned - random).abs() < 1.5, + (learned - random).abs() < 0.5, "learned {learned}, random {random}" ); } From 49babc1902402f0c54f0a41d60b42471c71b6632 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 19:43:09 +0000 Subject: [PATCH 6/7] planner: scent_learn PZ2 test drives the zero rule with a negative residual Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMPWTwovjYJEUMuGPb4 --- .../lance-graph-planner/src/nars/scent_learn.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/lance-graph-planner/src/nars/scent_learn.rs b/crates/lance-graph-planner/src/nars/scent_learn.rs index fe7f08fc0..99b0c515f 100644 --- a/crates/lance-graph-planner/src/nars/scent_learn.rs +++ b/crates/lance-graph-planner/src/nars/scent_learn.rs @@ -480,21 +480,24 @@ mod tests { assert!(step_one(&step, EpiLane9::NEVER_TOUCHED).never_touched()); } - /// PZ2 in the learner. A present feature whose delta rounds to zero is - /// marked seen (`+0`), not left "never touched". Here x = 1 and the - /// residual is 127, so the raw delta is 127/255 ≈ 0.498, which truncates to 0. + /// PZ2 in the learner: −0.49 → −0 → +0. A present feature whose delta + /// truncates to zero is marked seen (`+0`), not left "never touched". + /// x = 1, so |delta| = 127/255 ≈ 0.498 truncates to 0. The residual is + /// negative, so only the zero rule (`M == 0`) can set P; a positive + /// residual would set it through the sign alone. #[test] - fn pz2_a_present_feature_with_a_zero_step_is_marked_seen() { + fn pz2_a_negative_delta_truncating_to_zero_is_marked_seen() { let step = prepare(UpdateRule::Symmetric(HALF)).unwrap(); let mut l = ScentLearner::new(step.spec()); let mut lead = fixed_lead(0.9, 0.8); lead.uncertainty = 0.002; assert_eq!(features(&lead)[3], 1, "fixture must give x = 1"); assert_eq!( - l.learn(&step, &lead, Outcome::Hydrated, 1), - Ok(Learned::Updated { residual: 127 }) + l.learn(&step, &lead, Outcome::Refuted, 1), + Ok(Learned::Updated { residual: -127 }) ); - assert_eq!(l.weights()[3].value(), Some(0)); + assert_eq!(l.weights()[3], lane(0)); + assert!(!l.weights()[3].never_touched()); } /// PZ3. `lut9` evaluates odd functions only. An asymmetric or biased From 9d8fe7b658a36e14b2db617e10165b6243c41c96 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 19:44:32 +0000 Subject: [PATCH 7/7] docs/board: C3c numerics without runtime rounding, 600-world PZ5, NSL plan crosswalk Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMPWTwovjYJEUMuGPb4 --- .../entries/2026-10-28-d-epi-cot-c3c-learn.md | 9 ++- docs/research/epistemic-cot/c3c-learning.md | 73 ++++++++++++++----- 2 files changed, 59 insertions(+), 23 deletions(-) diff --git a/.claude/board/entries/2026-10-28-d-epi-cot-c3c-learn.md b/.claude/board/entries/2026-10-28-d-epi-cot-c3c-learn.md index 57ca033aa..d926afcac 100644 --- a/.claude/board/entries/2026-10-28-d-epi-cot-c3c-learn.md +++ b/.claude/board/entries/2026-10-28-d-epi-cot-c3c-learn.md @@ -5,18 +5,19 @@ ## Decision - `ScentLearner` reorders C3b leads within a status group, learning from investigation outcomes. Its output is an order, with no truth, Epi5, CE64 or ledger write. -- It consumes merged ndarray `codec9::EpiLane9`, `Codec9` and `simd::lut9` and adds no kernel. +- It consumes merged ndarray `codec9::EpiLane9` and `simd::lut9` and adds no kernel. The runtime has no float roundtrip and no rounding: deltas truncate in code space, and the zero rule is `P = present AND (positive OR M == 0)`, so −0.49 → +0. The rate table is the only rounding, done offline. - The rate table follows TFLM `LUTPopulateInt8` and matches a golden from tflite-micro `5f64e4a` on all 256 int8 codes for 4 rates. - Only odd rules are prepared. A table for another spec refuses. One source trains once. ## Evidence -- 14 tests, plus an ignored cost probe. 9 disables, each red. -- Held-out PZ5 (separable fixture): learned 1.00 vs scent 4.00, round-robin 4.25, random 4.73. Uninformative twin: learned 4.70 vs random 4.73. -- rank 736 ns, learn 404 ns, scent 4.5 µs (8 leads, one host). +- 14 tests, plus an ignored cost probe. 10 disables, each red. +- Held-out PZ5, 600 worlds, separable fixture: learned 1.00 vs scent 4.51, round-robin 4.55, random 4.47. Uninformative twin: learned 4.59 vs random 4.47. +- rank 706 ns, learn 425 ns, scent 3.0 µs (8 leads, one host). ## Open - The label should become "the ablation discriminated", using the ledger's `sensitivity`. - Overlapping-evidence fixtures and real HIRO/Odoo outcomes. +- `lance-graph-nsl` is gated on the NSL H7 oracle and the H1 G01–G12 fixtures; it is not built here. - The spec binding is transient. Production generation binding stays with EXO-152 PZL-4/G1. diff --git a/docs/research/epistemic-cot/c3c-learning.md b/docs/research/epistemic-cot/c3c-learning.md index 76f10708e..1d2e2eb29 100644 --- a/docs/research/epistemic-cot/c3c-learning.md +++ b/docs/research/epistemic-cot/c3c-learning.md @@ -20,14 +20,21 @@ information. Can past investigation outcomes order those ties better? - Its output is an order. It holds no ledger or edge and writes neither. A prediction is never recorded as an observation. -## Numerics: consumer of merged ndarray kernels, no new kernel +## Numerics: no runtime rounding, no float roundtrip | piece | where | contract | |---|---|---| -| weights, deltas, steps | `ndarray::hpc::codec9::EpiLane9` | `NEVER_TOUCHED` = feature never seen; a touched zero is `+0` | -| encoding | `Codec9::encode_touched` | ties away from zero, as TFLite; saturates at 255 | -| learning rate | `ndarray::simd::lut9` | odd functions only | -| rate table | TFLM `LUTPopulateInt8` recipe | `x·η`, `TfLiteRound`, saturate | +| weights, deltas, steps | `ndarray::hpc::codec9::EpiLane9` | `NEVER_TOUCHED` (P=0, M=0) = feature never seen; a touched zero is `+0` | +| delta | integer, in code space | `M = |r|·x / 255` truncated (odd, deterministic); sign from `r` | +| zero rule | bit masks | `P = present AND (positive OR M == 0)`: −0.49 → −0 → +0, never "never touched" | +| learning rate | `ndarray::simd::lut9` | a plain lookup of an odd table; same zero rule on its output | +| weights | integer add, saturate to ±255 | written back as an SM9 codeword, no re-encode | +| rate table | built once, offline | TFLM `LUTPopulateInt8`: `x·η`, `TfLiteRound` (ties away), saturate | + +The only rounding is when the rate table is built, which happens once per +spec. The hot path is integer arithmetic, two bit masks and one `lut9` call. +An earlier draft encoded each delta and weight through `f32` and +`Codec9::encode_touched`. That was a runtime float roundtrip and is gone. **TFLM reference.** `data/tflm_lut_step_golden.txt` was generated by `oracles/tflm_lut_step_oracle.cc`, compiled against AdaWorldAPI/tflite-micro @@ -45,7 +52,7 @@ Codec9's never-touched code has no TFLM counterpart (TFLM reserves no int8 code). The TFLM claim here is about this rate table only. **Spec binding (PZ7).** A prepared table carries `ModelSpec`, which is the -feature schema, the codec scale bits and the rate. A learner refuses a table +feature schema, the feature scale (255 = 1.0) and the rate. A learner refuses a table built for a different spec. The spec lives in memory only. It is not a generation store and not EXO-152's PZL-4/G1 binding. @@ -55,8 +62,8 @@ generation store and not EXO-152's PZL-4/G1 binding. |---|---|---| | TFLM table | `the_step_table_matches_tflm_on_every_int8_code` | ties-to-even rounding | | PZ1 codewords | `pz1_the_step_matches_a_scalar_oracle_on_every_codeword` (all 512 codewords × 4 rates vs f64) | ties-to-even | -| PZ1 update | `pz1_the_update_matches_a_scalar_oracle` (5 steps vs an independent f64 learner) | ties-to-even; no weight write | -| PZ2 | `pz2_a_negative_delta_rounding_to_zero_is_touched_plus_zero`, `pz2_a_present_feature_with_a_zero_step_is_marked_seen` | a zero delta encoded as never-touched | +| PZ1 update | `pz1_the_update_matches_a_scalar_oracle` (5 steps vs an independent f64 learner) | ties-to-even; no weight write; a float roundtrip with rounding for the delta | +| PZ2 | `pz2_a_negative_step_to_zero_is_touched_plus_zero`, `pz2_a_negative_delta_truncating_to_zero_is_marked_seen` | drop `M == 0` from the zero rule | | absent feature | `an_absent_feature_stays_never_touched` | an absent feature encoded as touched | | PZ3 | `pz3_only_odd_rules_are_prepared` | admit an asymmetric rule | | PZ4 | `pz4_learning_writes_no_evidence` | — (no write path, by construction) | @@ -66,30 +73,58 @@ generation store and not EXO-152's PZL-4/G1 binding. | PZ7 | `pz7_a_table_for_another_model_refuses` (rate and schema) | no spec check | | PZ8 | `pz8_a_contested_lead_stays_ahead_of_a_settled_one` | rank across status groups | -9 disables, each red. The PZ2 learner test was added after the "zero delta -encoded as never-touched" disable first stayed green. +10 disables, each red. The zero-rule disable stayed green twice before its +test bound it. The first time, no fixture had a delta that rounds to zero. +The second time, the fixture's residual was positive, so the sign set P on +its own. The test now uses a negative residual, which is the −0.49 case. ## Measured PZ5 trains on 40 worlds of one shape: 3 decoys, the known link `A → B`. It -tests on 60 held-out worlds of another shape: 7 decoys, the known link +tests on 600 held-out worlds of another shape: 7 decoys, the known link `B → Y`, disjoint seeds. The table shows mean investigations to hydrate the -true mediator: +true mediator. The standard error is about 0.1. | policy | informative fixture | uninformative twin | |---|---|---| -| learned | 1.00 | 4.70 | -| scent (id order) | 4.00 | — | -| round-robin | 4.25 | — | -| random | 4.73 | 4.73 | +| learned | 1.00 | 4.59 | +| scent (id order) | 4.51 | 4.51 | +| round-robin | 4.55 | 4.55 | +| random | 4.47 | 4.47 | + +On 60 held-out worlds, the uninformative twin first read 3.77 against +random's 4.73. On 600 worlds the gap is gone, and the learned weights stay +near zero (1, 2, −3, 1, 3), so the 3.77 was sampling noise. The test now uses +600 worlds with a 0.5 bound. Cost on one host, release, 8 leads (reported, not gated): | operation | cost | |---|---| -| `rank` | 736 ns | -| `learn`, including a clone | 404 ns | -| `scent` | 4.5 µs | +| `rank` | 706 ns | +| `learn`, including a clone | 425 ns | +| `scent` | 3.0 µs | + +## NSL research plans (AdaWorldAPI/neural-structured-learning #1–#8) + +The NSL plans are documentation-only research proposals dated 2026-10-25, +before `lut9` merged. None of them mention `lut9`. Where they apply here: + +- **05a, certified pair LUT:** tables are generated and certified offline, and + a query is a plain array lookup with no runtime rounding or libm call. C3c + follows that. Its rate table has rational ties (for example 2·¼ = 0.5) that + 05a would call undecidable without a rule. Here the rule is TFLM's ties + away from zero, and the table is pinned against TFLM. +- **Scratchpad §5:** keep the TFLM rounding contract (ndarray #375) and the + Codec8/Codec9 code spaces. C3c adds no codec and no rounding mode. +- **H1 (graph focus, 01/01a):** NSL's graph regularization uses structure as + a training signal. C3c's learner is a different signal: investigation + outcomes. A graph-regularized learner, where leads that share an anchor + source are pulled toward similar scores, is a possible later step. It is + not built here. +- **`lance-graph-nsl`:** the NSL scratchpad gates any lance-graph + implementation on a frozen NSL oracle (H7) and the G01–G12 neighbor + fixtures (H1). Neither exists yet, so this PR adds no NSL crate. ## What this does not show