Skip to content

Commit bc74899

Browse files
committed
SpaceWASM support
1 parent 642d1b6 commit bc74899

16 files changed

Lines changed: 268 additions & 169 deletions

File tree

‎.mise/config.coverage.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ version = "0.9.1"
4343
# only be asserted where that path runs. A crate belongs to exactly one list: naming it in both would let a
4444
# gap in one pipeline be answered by the other.
4545
native_cov_libs = """
46-
{{ "actix-web-config edge-toolkit et-otlp path sanitise test-helpers " ~
46+
{{ "actix-web-config edge-toolkit et-otlp math1 path sanitise test-helpers " ~
4747
"test-nats test-otlp test-recovery ws-runner-common" -}}
4848
"""
4949
wasm_cov_libs = "wasi-guest web"

‎Cargo.lock‎

Lines changed: 14 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ members = [
99
"libs/actix-web-config",
1010
"libs/edge-toolkit",
1111
"libs/et-otlp",
12+
"libs/math1",
1213
"libs/org",
1314
"libs/path",
1415
"libs/sanitise",
@@ -126,6 +127,7 @@ et-actix-web-config = { path = "libs/actix-web-config", version = "0.1.0" }
126127
et-cli = { path = "utilities/cli", version = "0.2.0" }
127128
et-firmware-modules = { path = "services/firmware-modules", version = "0.1.0" }
128129
et-hub-backplane = { path = "services/hub-backplane", version = "0.2.0" }
130+
et-math1 = { path = "libs/math1", version = "0.1.0" }
129131
et-modules-service = { path = "services/modules", version = "0.3.0", default-features = false }
130132
et-onnx = { path = "utilities/onnx", version = "0.2.0" }
131133
et-org = { path = "libs/org", version = "0.2.0" }

‎config/conftest/policy/jscpd/jscpd.rego‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ package jscpd
2828

2929
# Ceiling on the clone fingerprints recorded in config/jscpd-baseline.json.
3030
# May only ever be lowered. See the header for what raising it implies.
31-
declared_clones := 360
31+
declared_clones := 357
3232

3333
# How far under the ceiling the baseline may sit before the ceiling has to be banked downward.
3434
max_slack := 20

‎config/jscpd-baseline.json‎

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,27 +10,24 @@
1010
"03e4e07acff327e2": 1,
1111
"04199950b57990a0": 1,
1212
"04daef50421ed235": 1,
13-
"0690b4ae1dc710a4": 1,
1413
"07378241fe562817": 1,
1514
"075c84a635548077": 1,
1615
"077a1f0f3040c9ff": 1,
1716
"084d33784cf81a99": 1,
1817
"0875c25e5ebb12cc": 1,
1918
"0c04b2e97c016262": 1,
20-
"0c2432c487251d55": 1,
2119
"0c8f78b8c0d0f6b4": 1,
2220
"0ea84f1f5db88fe7": 1,
2321
"0f033dd72cb08a76": 1,
2422
"0f10e9243723cb05": 3,
23+
"0f217d26c774a6df": 1,
2524
"100c4984075d3662": 1,
2625
"100c5a39e529a691": 1,
2726
"106569d682a6a53f": 1,
2827
"109b672c3d6939d9": 1,
29-
"10b898032781bd2b": 1,
3028
"1235e9f0b50beb4c": 1,
3129
"12bdba43f82ce591": 1,
3230
"136c57b4180f7238": 1,
33-
"13d42a6bd52e4560": 1,
3431
"151943582f4d3a1c": 1,
3532
"15f330210837288e": 1,
3633
"161f873e1c1bf887": 1,
@@ -42,6 +39,8 @@
4239
"1a1e1611e4db4656": 1,
4340
"1a46175d923bf850": 3,
4441
"1a97e5c72a30152e": 1,
42+
"1ac2e9fe36333da4": 1,
43+
"1c6019601260fa2e": 1,
4544
"1c64414236b8476c": 1,
4645
"1c8cf13e10b67513": 1,
4746
"1ce17b70419170cb": 1,
@@ -80,6 +79,7 @@
8079
"3aec07afda461f58": 1,
8180
"3afe9805243f94d2": 1,
8281
"3b30dfa6b363682d": 2,
82+
"3b747fc4fc906fbf": 1,
8383
"3c0efbffa8bb6f9b": 1,
8484
"3c49721a9a85b54b": 1,
8585
"3c5c4bdf7be7387e": 1,
@@ -184,7 +184,6 @@
184184
"8390cc689d64efcb": 1,
185185
"84b3a552aa5c21c2": 1,
186186
"84e106866c27d5cb": 1,
187-
"869ce6d0bb9475d9": 1,
188187
"87009c1cbed26049": 1,
189188
"87566acb3f2550bf": 1,
190189
"879bfe322b3d676b": 1,
@@ -318,14 +317,13 @@
318317
"da812f4e54fefbf1": 1,
319318
"db4fb9ed218e4d66": 1,
320319
"dd162080de996936": 1,
321-
"dd41701c3663b9f3": 1,
322320
"ddca47a4caded0fd": 1,
323321
"de1a3233f93e1695": 1,
324322
"dfab26504ac7abe4": 1,
325323
"e152738c60f43b0e": 1,
324+
"e1badf6d4dbe69d3": 1,
326325
"e25456ad9cc7f9f9": 1,
327326
"e4b9e7313fc22777": 1,
328-
"e5085d0d3af0235b": 1,
329327
"e517097fd06d5cb5": 1,
330328
"e5481f802d5a9f84": 1,
331329
"e579cd730cd60fb8": 1,
@@ -336,6 +334,7 @@
336334
"ebb8f005bcebfcee": 1,
337335
"ec5c408a8f2c368d": 1,
338336
"ec83e5f612762cae": 1,
337+
"ecffd4667336099d": 1,
339338
"edfb18c73a139873": 1,
340339
"ef69568ef92f1b79": 1,
341340
"ef6dd6376769473d": 1,

‎libs/math1/Cargo.toml‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
[package]
2+
name = "et-math1"
3+
publish = true
4+
description = "The math1 family's FedAvg kernel and storage protocol, shared by its Rust twins and triggers"
5+
version = "0.1.0"
6+
edition.workspace = true
7+
license.workspace = true
8+
repository.workspace = true
9+
10+
[lib]
11+
doctest = false
12+
13+
[dependencies]
14+
serde = { workspace = true, features = ["alloc"] }
15+
serde_json = { workspace = true, features = ["alloc"] }
16+
thiserror.workspace = true
17+
18+
[dev-dependencies]
19+
et-ws-test-server.workspace = true
20+
serde_json = { workspace = true, features = ["std"] }
21+
22+
[lints]
23+
workspace = true

‎libs/math1/src/lib.rs‎

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
//! The math1 family's Rust: the `FedAvg` kernel, and the storage protocol its twins and triggers speak.
2+
//!
3+
//! A trigger stores the canonical input JSON as [`INPUT_FILENAME`] in its own bucket and relays the frame
4+
//! [`pointer_frame`] builds, naming it. Each twin waits for that frame, reads the input from storage, runs
5+
//! [`fed_avg`], and stores what [`output_json`] renders as [`OUTPUT_FILENAME`] in its own bucket. The kernel uses only
6+
//! `+ - * /` on f64 in a fixed order, so every runtime that follows IEEE 754 computes the same model to the bit, the
7+
//! twins in other languages included.
8+
//!
9+
//! `no_std` with `alloc`, so a twin built for a target with no standard library uses it as a browser or WASI one does.
10+
11+
#![no_std]
12+
#![expect(clippy::float_arithmetic, reason = "the FedAvg kernel is float math by design")]
13+
14+
extern crate alloc;
15+
16+
use alloc::string::{String, ToString as _};
17+
use alloc::vec::Vec;
18+
19+
use serde::Deserialize;
20+
use serde_json::{Value, json};
21+
22+
/// The `type` of the pointer frame a trigger relays.
23+
pub const INPUT_TYPE: &str = "math1-input";
24+
/// Storage object name of the canonical input, inside the trigger's bucket.
25+
pub const INPUT_FILENAME: &str = "math1-input.json";
26+
/// Storage object name each twin stores its global model as, inside its own bucket.
27+
pub const OUTPUT_FILENAME: &str = "math1-output.json";
28+
29+
/// The canonical input: per-client (feature, target) samples plus the training hyperparameters.
30+
#[derive(Debug, Deserialize)]
31+
#[non_exhaustive]
32+
pub struct Math1Input {
33+
/// Each client's (feature, target) samples.
34+
pub clients: Vec<Vec<(f64, f64)>>,
35+
/// How many rounds of local training and merging to run.
36+
pub rounds: u32,
37+
/// How many full-batch gradient-descent epochs each client runs per round.
38+
pub epochs: u32,
39+
/// The gradient-descent step size.
40+
pub learning_rate: f64,
41+
}
42+
43+
/// Where a trigger stored the input: the bucket and file a pointer frame names.
44+
#[derive(Clone, Debug, Deserialize)]
45+
#[non_exhaustive]
46+
pub struct InputPointer {
47+
/// The trigger's bucket.
48+
pub bucket: String,
49+
/// The input's name in that bucket.
50+
pub filename: String,
51+
}
52+
53+
/// Why a text frame is not the math1 input pointer.
54+
#[derive(Debug, thiserror::Error)]
55+
#[non_exhaustive]
56+
pub enum PointerError {
57+
/// The frame is not JSON, or claims to be the pointer but does not name a bucket and file.
58+
#[error(transparent)]
59+
Json(#[from] serde_json::Error),
60+
/// The frame is JSON, but a message other than the pointer.
61+
#[error("not a math1-input frame")]
62+
OtherType,
63+
}
64+
65+
/// A global model, fitting `target = weight * feature + bias`.
66+
#[derive(Clone, Copy, Debug, PartialEq)]
67+
#[non_exhaustive]
68+
pub struct Model {
69+
/// The fitted weight.
70+
pub weight: f64,
71+
/// The fitted bias.
72+
pub bias: f64,
73+
}
74+
75+
/// The pointer frame a trigger relays, naming [`INPUT_FILENAME`] in `bucket`.
76+
#[must_use]
77+
pub fn pointer_frame(bucket: &str) -> String {
78+
json!({ "type": INPUT_TYPE, "bucket": bucket, "filename": INPUT_FILENAME }).to_string()
79+
}
80+
81+
/// The input pointer the text frame `text` carries.
82+
///
83+
/// # Errors
84+
///
85+
/// When `text` is not JSON, is a message other than the pointer, or is a pointer naming no bucket and file.
86+
pub fn parse_pointer(text: &str) -> Result<InputPointer, PointerError> {
87+
let value: Value = serde_json::from_str(text)?;
88+
if value.get("type").and_then(Value::as_str) != Some(INPUT_TYPE) {
89+
return Err(PointerError::OtherType);
90+
}
91+
Ok(serde_json::from_value(value)?)
92+
}
93+
94+
/// Sample count as f64, accumulated additively to avoid an integer-to-float cast.
95+
fn sample_count(samples: &[(f64, f64)]) -> f64 {
96+
samples.iter().fold(0.0_f64, |count, _| count + 1.0_f64)
97+
}
98+
99+
/// Run the `FedAvg` simulation on `input`, returning the final global model.
100+
///
101+
/// Each round, every client starts from the global model, runs its local epochs of full-batch gradient descent, and
102+
/// the results are merged as an average weighted by each client's sample count.
103+
#[must_use]
104+
pub fn fed_avg(input: &Math1Input) -> Model {
105+
let mut weight = 0.0_f64;
106+
let mut bias = 0.0_f64;
107+
let total_samples: f64 = input
108+
.clients
109+
.iter()
110+
.fold(0.0_f64, |acc, samples| acc + sample_count(samples));
111+
for _ in 0_u32..input.rounds {
112+
let mut merged_weight = 0.0_f64;
113+
let mut merged_bias = 0.0_f64;
114+
for samples in &input.clients {
115+
let count = sample_count(samples);
116+
let mut client_weight = weight;
117+
let mut client_bias = bias;
118+
for _ in 0_u32..input.epochs {
119+
let mut grad_weight = 0.0_f64;
120+
let mut grad_bias = 0.0_f64;
121+
for &(feature, target) in samples {
122+
let residual = client_weight * feature + client_bias - target;
123+
grad_weight += residual * feature;
124+
grad_bias += residual;
125+
}
126+
client_weight -= input.learning_rate * (2.0_f64 * grad_weight / count);
127+
client_bias -= input.learning_rate * (2.0_f64 * grad_bias / count);
128+
}
129+
merged_weight += client_weight * count;
130+
merged_bias += client_bias * count;
131+
}
132+
weight = merged_weight / total_samples;
133+
bias = merged_bias / total_samples;
134+
}
135+
Model { weight, bias }
136+
}
137+
138+
/// The JSON a twin stores as [`OUTPUT_FILENAME`]: the name of the `module` that computed `model`, and the model.
139+
#[must_use]
140+
pub fn output_json(module: &str, model: Model) -> String {
141+
json!({ "module": module, "weight": model.weight, "bias": model.bias }).to_string()
142+
}

‎libs/math1/tests/math1.rs‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
//! Covers the kernel against the canonical model, each way a frame is or is not the input pointer, and the output.
2+
#![cfg(test)]
3+
4+
use et_math1::{
5+
INPUT_FILENAME, INPUT_TYPE, Math1Input, PointerError, fed_avg, output_json, parse_pointer, pointer_frame,
6+
};
7+
use et_ws_test_server::math1::{MATH1_INPUT_JSON, verify_math1_model};
8+
use serde_json::Value;
9+
10+
#[test]
11+
fn fed_avg_computes_the_canonical_model() {
12+
let input: Math1Input = serde_json::from_str(MATH1_INPUT_JSON).unwrap();
13+
let model = fed_avg(&input);
14+
verify_math1_model(model.weight, model.bias).unwrap();
15+
}
16+
17+
#[test]
18+
fn the_pointer_frame_parses_back_to_the_input_it_names() {
19+
let frame = pointer_frame("bucket-1");
20+
let value: Value = serde_json::from_str(&frame).unwrap();
21+
assert_eq!(value["type"], INPUT_TYPE);
22+
let pointer = parse_pointer(&frame).unwrap();
23+
assert_eq!(pointer.bucket, "bucket-1");
24+
assert_eq!(pointer.filename, INPUT_FILENAME);
25+
}
26+
27+
#[test]
28+
fn a_frame_that_is_not_json_is_not_a_pointer() {
29+
let error = parse_pointer("not json").unwrap_err();
30+
let expected = serde_json::from_str::<Value>("not json").unwrap_err().to_string();
31+
assert!(matches!(error, PointerError::Json(_)));
32+
assert_eq!(error.to_string(), expected);
33+
}
34+
35+
#[test]
36+
fn another_message_is_not_a_pointer() {
37+
let error = parse_pointer(r#"{"type":"et-connect-ack"}"#).unwrap_err();
38+
assert!(matches!(error, PointerError::OtherType));
39+
assert_eq!(error.to_string(), "not a math1-input frame");
40+
}
41+
42+
#[test]
43+
fn a_pointer_naming_no_file_is_malformed() {
44+
let error = parse_pointer(r#"{"type":"math1-input","bucket":"bucket-1"}"#).unwrap_err();
45+
assert!(matches!(error, PointerError::Json(_)));
46+
assert!(error.to_string().contains("filename"));
47+
}
48+
49+
#[test]
50+
fn the_output_names_the_module_and_carries_the_model() {
51+
let input: Math1Input = serde_json::from_str(MATH1_INPUT_JSON).unwrap();
52+
let model = fed_avg(&input);
53+
let output: Value = serde_json::from_str(&output_json("twin", model)).unwrap();
54+
assert_eq!(output["module"], "twin");
55+
assert_eq!(output["weight"].as_f64(), Some(model.weight));
56+
assert_eq!(output["bias"].as_f64(), Some(model.bias));
57+
}

0 commit comments

Comments
 (0)