ferrotherm_cloud/hitachi.rs
1//! Hitachi's CMOS annealing machine, through Annealing Cloud Web.
2//!
3//! This is real fabricated Ising silicon reachable from a free public API, and essentially nobody
4//! has used it — two papers in all of OpenAlex mention the service. It is therefore the cheapest
5//! real fabric in the world to support, and supporting it is what makes "universal" mean something
6//! checkable rather than rhetorical.
7//!
8//! # Getting your own credentials
9//!
10//! This crate ships no token and no account. It talks to **your** Annealing Cloud Web login, which
11//! you set up yourself, and it does nothing at all until you do.
12//!
13//! 1. Request an access token at
14//! <https://annealing-cloud.com/en/web-api/token-request.html>. The form asks for an **email
15//! address** and a **country**, and requires agreeing to two conditions: that you will not use
16//! the site or its output data for any purpose including the development of weapons of mass
17//! destruction (their Terms of Use, Section 8, Export Controls), and that you consent to the
18//! collection of personal information under those Terms. Intended use and user type are
19//! optional fields.
20//! 2. The administrator emails the token back to you. The token-request page does not state how
21//! long that takes, nor any usage limits; the service homepage describes the Web API as free.
22//! 3. Put it in your environment and never in a file you commit:
23//!
24//! ```sh
25//! export ACW_TOKEN=<the token they emailed you>
26//! cargo run --release -p ferrotherm-cloud --example hitachi_run
27//! ```
28//!
29//! [`Hitachi::from_env`] reads exactly that one variable and returns `Err` when it is unset — it
30//! does not fall back to a bundled key, a config file or a credential store, because there are
31//! none. [`Hitachi::new`] takes the token directly if you would rather source it yourself.
32//!
33//! Read the API you are calling before you call it:
34//! <https://annealing-cloud.com/en/web-api/reference/v2.html>.
35//!
36//! # The conventions, measured rather than assumed
37//!
38//! **The sign is inverted.** Their energy is `Σ pᵢⱼ sᵢsⱼ`, *minimised*, so a positive coefficient is
39//! **antiferromagnetic**. ferrotherm's is `-Σ Jᵢⱼ sᵢsⱼ`, where a positive coupling is
40//! ferromagnetic. Every weight negates crossing this boundary.
41//!
42//! That was established empirically on the first call, not read off a document: four positive
43//! couplings on a 2×2 block came back as a checkerboard at energy −4. A sign error here produces
44//! entirely plausible output that is wrong on every problem, so it is worth the one request.
45//!
46//! **The topology is a King's graph.** Sites are grid coordinates and neighbours are the eight
47//! surrounding cells — orthogonal *and* diagonal. Coupling two non-adjacent coordinates is an error,
48//! not a silently ignored term. A vertex's own field is expressed as a self-coupling, `x0 == x1`
49//! and `y0 == y1`.
50//!
51//! **The ASIC stores coefficients in four bits.** `-7 ≤ p ≤ 7`, integers. That is the binding
52//! constraint on the machine and it is exactly the class of limit [`ferrotherm::fabric`] exists to
53//! declare: a model quantised into it still runs, it just answers a different question.
54
55use ferrotherm::fabric::{Device, Fabric, Topology, Unsupported};
56use ferrotherm::embed::Embedding;
57use ferrotherm::ftp::Program;
58use ferrotherm::ledger::{Ledger, Prices};
59use ferrotherm::schedule::Schedule;
60
61/// Which machine to run on.
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
63pub enum Machine {
64 /// The CMOS annealing ASIC. 384×384 sites, four-bit coefficients.
65 Asic,
66 /// GPU, 32-bit integer coefficients. 512×512 sites.
67 GpuInt,
68 /// GPU, 32-bit float coefficients. 512×512 sites.
69 GpuFloat,
70}
71
72impl Machine {
73 fn code(self) -> u32 {
74 match self {
75 Machine::Asic => 5,
76 Machine::GpuInt => 3,
77 Machine::GpuFloat => 4,
78 }
79 }
80 /// Grid side. Sites are `side × side`.
81 pub fn side(self) -> usize {
82 match self {
83 Machine::Asic => 384,
84 _ => 512,
85 }
86 }
87 /// How this machine stores a coefficient.
88 ///
89 /// The GPU float path is float32, not full `f64`, and saying `None` for it — as this did —
90 /// claimed every `f64` arrives intact. It does not: a coefficient needing more than 24
91 /// significand bits is rounded on the way in.
92 fn precision(self) -> ferrotherm::fabric::Precision {
93 use ferrotherm::fabric::Precision;
94 match self {
95 // -7..=7 is four bits with a sign
96 Machine::Asic => Precision::Fixed { bits: 4 },
97 Machine::GpuInt => Precision::Fixed { bits: 32 },
98 Machine::GpuFloat => Precision::Float { mantissa: 24 },
99 }
100 }
101 fn coefficient_limit(self) -> f64 {
102 self.range().hi
103 }
104
105 /// What this machine can represent, in the shared vocabulary every fabric uses.
106 ///
107 /// The ASIC and the integer GPU take WHOLE NUMBERS; a bit count alone cannot say that, which is
108 /// why this is separate from `coupling_bits`. A program with `J = 0.5` is representable on
109 /// neither, and knowing that before submitting is the difference between a refused job and a
110 /// wrong answer.
111 fn range(self) -> ferrotherm::fabric::Range {
112 use ferrotherm::fabric::Range;
113 match self {
114 Machine::Asic => Range::integers(-7.0, 7.0),
115 Machine::GpuInt => Range::integers(-2_147_483_647.0, 2_147_483_647.0),
116 Machine::GpuFloat => Range::continuous(-3.402_823e38, 3.402_823e38),
117 }
118 }
119}
120
121/// A ferrotherm spin index laid out on the machine's grid.
122///
123/// Spin `i` sits at `(i % side, i / side)`. Any model whose couplings are not between King-adjacent
124/// sites under that layout is refused rather than embedded — embedding is a compiler pass, and
125/// doing it silently inside a driver is how a caller ends up solving a different problem.
126fn coord(i: usize, side: usize) -> (usize, usize) {
127 (i % side, i / side)
128}
129
130fn king_adjacent(a: (usize, usize), b: (usize, usize)) -> bool {
131 let dx = a.0.abs_diff(b.0);
132 let dy = a.1.abs_diff(b.1);
133 dx <= 1 && dy <= 1 && (dx | dy) != 0
134}
135
136/// Why a model could not be laid out on the grid.
137#[derive(Clone, Debug, PartialEq)]
138pub enum LayoutError {
139 NotAdjacent { i: usize, j: usize, a: (usize, usize), b: (usize, usize) },
140 OutOfGrid { i: usize },
141 CoefficientRange { value: f64, limit: f64 },
142}
143
144impl core::fmt::Display for LayoutError {
145 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
146 match self {
147 LayoutError::NotAdjacent { i, j, a, b } => write!(
148 f,
149 "spins {i} and {j} land at {a:?} and {b:?}, which are not King-adjacent on this \
150 machine's grid. Embed the model onto the grid first; a driver that placed it for \
151 you would be choosing an embedding you did not see"
152 ),
153 LayoutError::OutOfGrid { i } => write!(f, "spin {i} falls outside the grid"),
154 LayoutError::CoefficientRange { value, limit } => write!(
155 f,
156 "coefficient {value} exceeds this machine's range of ±{limit}; requantize the \
157 program for this fabric before submitting"
158 ),
159 }
160 }
161}
162
163/// Annealing Cloud Web's public solve endpoint — the service this driver is named for.
164///
165/// It is a default, not a fixture: [`Hitachi::with_endpoint`] replaces it, which is what makes a
166/// local mock or a proxy possible. Before this was a field the address was written into the call
167/// site, so the destination was the library's choice rather than the caller's.
168pub const ACW_ENDPOINT: &str = "https://annealing-cloud.com/api/v2/solve";
169
170/// The Hitachi annealer as a backend.
171///
172/// # Nothing here contacts anyone until you set it up
173///
174/// Constructing a `Hitachi`, describing its [`fabric`](Device::fabric), laying a program out on the
175/// grid and embedding one all run entirely locally. The single network call in this crate lives in
176/// [`Device::run`], and reaching it takes a token you supplied, a program you laid out, and a call
177/// you made. There is no ambient default: [`Hitachi::from_env`] returns `Err` when `ACW_TOKEN` is
178/// unset rather than falling back to anything, and the crate reads no other environment variable,
179/// no config file and no credential store.
180pub struct Hitachi {
181 token: String,
182 endpoint: String,
183 machine: Machine,
184 model: Vec<[i64; 4]>, // x0, y0, x1, y1
185 coeff: Vec<f64>,
186 spins: usize,
187 ledger: Ledger,
188 /// Last raw energies returned, in the machine's own sign convention.
189 pub last_energies: Vec<f64>,
190 /// Nanoseconds the machine reported for the last run.
191 pub last_execution_ns: u64,
192}
193
194impl Hitachi {
195 /// `token` comes from Annealing Cloud Web. Read it from the environment; do not commit it.
196 pub fn new(token: impl Into<String>, machine: Machine) -> Hitachi {
197 Hitachi {
198 token: token.into(),
199 endpoint: String::from(ACW_ENDPOINT),
200 machine,
201 model: Vec::new(),
202 coeff: Vec::new(),
203 spins: 0,
204 ledger: Ledger::default(),
205 last_energies: Vec::new(),
206 last_execution_ns: 0,
207 }
208 }
209
210 /// From `ACW_TOKEN` in the environment.
211 pub fn from_env(machine: Machine) -> Result<Hitachi, String> {
212 std::env::var("ACW_TOKEN")
213 .map(|t| Hitachi::new(t, machine))
214 .map_err(|_| "set ACW_TOKEN to an Annealing Cloud Web token".to_string())
215 }
216
217 /// Send somewhere other than [`ACW_ENDPOINT`] — a mock, a proxy, or a self-hosted instance.
218 ///
219 /// The address a driver posts to is the caller's decision, not the driver's. Without this the
220 /// only way to exercise [`Device::run`] at all was to send a real job to a third party, which
221 /// is why nothing in this crate's tests has ever covered it.
222 pub fn with_endpoint(mut self, url: impl Into<String>) -> Hitachi {
223 self.endpoint = url.into();
224 self
225 }
226
227 /// Where this instance will post. See [`Hitachi::with_endpoint`].
228 pub fn endpoint(&self) -> &str {
229 &self.endpoint
230 }
231
232 /// Place a program on the grid, embedding it if it does not already fit.
233 ///
234 /// [`Hitachi::layout`] requires a program whose couplings are already King-adjacent under the
235 /// row-major layout — which is a real constraint on the caller and, until `ferrotherm::embed`
236 /// existed, one this driver could only refuse. This tries that first, and when it fails uses
237 /// minor embedding to find a placement.
238 ///
239 /// Returns the embedding, which is needed to read the answer back: a variable may now occupy
240 /// several sites, and [`ferrotherm::embed::unembed`] turns those back into one value and says
241 /// which chains broke.
242 ///
243 /// The embedded model is not the model that was written. Chains add couplings at
244 /// `chain_strength`, and a program that fitted the grid already is placed unchanged, so the
245 /// returned embedding is the identity and the distinction costs nothing.
246 pub fn place(&mut self, p: &Program) -> Result<Embedding, String> {
247 let side = self.machine.side();
248 if self.layout(p).is_ok() {
249 return Ok(Embedding {
250 chains: (0..p.spins).map(|i| vec![i]).collect(),
251 sites: side * side,
252 });
253 }
254
255 let logical = p.to_graph().map_err(|e| e.to_string())?;
256 let hardware = ferrotherm::embed::topology::king(side);
257 let e = ferrotherm::embed::embed(&logical, &hardware, 0).ok_or_else(|| {
258 format!(
259 "no King-graph placement found for {} variables on this {side}x{side} machine. \
260 That is 'not found', not 'impossible' -- minor embedding is NP-hard and this is a \
261 heuristic. A different seed or a smaller model may succeed",
262 p.spins
263 )
264 })?;
265
266 let placed = ferrotherm::embed::apply(&logical, &hardware, &e);
267 let program = Program::from_graph(&placed.graph, &Schedule::geometric(0.05, 6.0, 40, 20));
268 self.layout(&program).map_err(|err| {
269 format!("the embedded program still does not fit: {err}")
270 })?;
271 Ok(e)
272 }
273
274 /// Lay a program out on the grid, negating every weight for their sign convention.
275 ///
276 /// Requires every coupling to be King-adjacent already. [`Hitachi::place`] embeds when it is
277 /// not, and is what a caller who has not laid their model out by hand wants.
278 pub fn layout(&mut self, p: &Program) -> Result<(), LayoutError> {
279 let side = self.machine.side();
280 let lim = self.machine.coefficient_limit();
281 self.model.clear();
282 self.coeff.clear();
283 self.spins = p.spins;
284
285 let range = self.machine.range();
286 // Named for what it does. It was called `push` and took both coordinates, neither of
287 // which it used -- the actual pushing happens at each call site -- so every reader had to
288 // check whether a coupling was being written twice. It was not.
289 let check_range = |w: f64| -> Result<(), LayoutError> {
290 // Against the same Range the fabric declares, rather than a magnitude comparison of
291 // its own. `|w| <= 7` admits 3.5, which a machine storing four-bit INTEGERS cannot
292 // hold; a second, weaker copy of a limit is how the two drift apart.
293 if !range.holds(w) {
294 return Err(LayoutError::CoefficientRange { value: w, limit: lim });
295 }
296 Ok(())
297 };
298
299 for (i, h) in &p.bias {
300 if *i >= side * side {
301 return Err(LayoutError::OutOfGrid { i: *i });
302 }
303 let a = coord(*i, side);
304 // their sign is inverted, so our -h·s becomes their +(-h)·s
305 let w = -*h;
306 check_range(w)?;
307 self.model.push([a.0 as i64, a.1 as i64, a.0 as i64, a.1 as i64]);
308 self.coeff.push(w);
309 }
310
311 for f in &p.factors {
312 let vars: Vec<usize> = f.vars().collect();
313 if vars.len() != 2 {
314 continue; // arity is checked by the Fabric; this is the layout pass
315 }
316 let (i, j) = (vars[0], vars[1]);
317 if i >= side * side || j >= side * side {
318 return Err(LayoutError::OutOfGrid { i: i.max(j) });
319 }
320 let (a, b) = (coord(i, side), coord(j, side));
321 if !king_adjacent(a, b) {
322 return Err(LayoutError::NotAdjacent { i, j, a, b });
323 }
324 let w = -f.weight(); // sign inversion, measured
325 check_range(w)?;
326 self.model.push([a.0 as i64, a.1 as i64, b.0 as i64, b.1 as i64]);
327 self.coeff.push(w);
328 }
329 Ok(())
330 }
331
332 fn request_json(&self, num_executions: usize, schedule: &Schedule) -> String {
333 let mut model = String::from("[");
334 for (k, m) in self.model.iter().enumerate() {
335 if k > 0 {
336 model.push(',');
337 }
338 let c = self.coeff[k];
339 let c = if self.machine == Machine::GpuFloat {
340 format!("{c}")
341 } else {
342 format!("{}", c.round() as i64)
343 };
344 model.push_str(&format!("[{},{},{},{},{}]", m[0], m[1], m[2], m[3], c));
345 }
346 model.push(']');
347
348 // Their schedule is geometric in temperature; ours is geometric in beta. Convert at the
349 // boundary rather than pretending the parameter names line up.
350 let stages = schedule.stages();
351 let (b0, b1) = match (stages.first(), stages.last()) {
352 (Some(a), Some(z)) => (a.beta.max(1e-6), z.beta.max(1e-6)),
353 _ => (0.1, 10.0),
354 };
355 let steps = stages.len().clamp(1, 100);
356 let per = (schedule.total_sweeps() / steps.max(1) as u64).clamp(1, 1000);
357
358 format!(
359 "{{\"type\":{},\"num_executions\":{},\"model\":{},\
360 \"parameters\":{{\"temperature_num_steps\":{},\"temperature_step_length\":{},\
361 \"temperature_initial\":{},\"temperature_target\":{}}},\
362 \"outputs\":{{\"energies\":true,\"spins\":true,\"execution_time\":true}}}}",
363 self.machine.code(),
364 num_executions.clamp(1, 10),
365 model,
366 steps,
367 per,
368 1.0 / b0,
369 1.0 / b1,
370 )
371 }
372}
373
374impl Device for Hitachi {
375 fn fabric(&self) -> Fabric {
376 let side = self.machine.side();
377 Fabric {
378 name: match self.machine {
379 Machine::Asic => "hitachi-cmos-asic",
380 Machine::GpuInt => "hitachi-gpu-int32",
381 Machine::GpuFloat => "hitachi-gpu-float32",
382 },
383 topology: Topology::Named("king-graph"),
384 max_spins: Some(side * side),
385 max_degree: Some(8), // King's graph: orthogonal and diagonal
386 coupling_precision: self.machine.precision(),
387 field_precision: self.machine.precision(),
388 supports_field: true,
389 max_arity: 2,
390 // Spin i sits at (i % side, i / side) and couplings must already be King-adjacent;
391 // the driver refuses anything else rather than embedding it, so placement is native by
392 // construction and the caller does their own embedding beforehand.
393 native_placement: true,
394 unstated: &[],
395 coupling_range: Some(self.machine.range()),
396 field_range: Some(self.machine.range()),
397 uniform_couplings: false,
398 // NOT Z1_SPICE. This is Hitachi's CMOS annealing ASIC; Z1 is Extropic's, and it has
399 // not been characterised. Declaring one vendor's pre-silicon SPICE estimates as
400 // another vendor's measured cost produced a joules figure that looked exactly like a
401 // real one -- which is the whole failure mode the ledger exists to prevent.
402 //
403 // This review did not locate published per-operation energy for Annealing Cloud Web's
404 // hardware. `unstated` is what that fact looks like in the type system.
405 prices: Prices::UNSTATED,
406 }
407 }
408
409 fn program(&mut self, p: &Program) -> Vec<Unsupported> {
410 let bad = self.fabric().check(p);
411 if bad.is_empty() {
412 // A successful load flashes every node onto the device: the write the ledger
413 // is built to account for, and the one it was never charged.
414 self.ledger.writes += p.spins as u64;
415 if let Err(e) = self.layout(p) {
416 // A layout failure is a capability failure, and it now says WHAT failed. Every one
417 // of them used to come back as `TooHighDegree { degree: 0, limit: 8 }` -- which
418 // reads as "degree 0 exceeds 8", is not true of anything, and told a caller with a
419 // non-adjacent coupling nothing about their non-adjacent coupling.
420 return vec![Unsupported::Unplaceable { detail: e.to_string() }];
421 }
422 }
423 bad
424 }
425
426 fn run(&mut self, schedule: &Schedule, _seed: u64) -> Result<Vec<i8>, String> {
427 if self.model.is_empty() {
428 return Err("no program laid out".into());
429 }
430 let body = self.request_json(1, schedule);
431 let resp = ureq::post(&self.endpoint)
432 .set("Authorization", &format!("Bearer {}", self.token))
433 .set("Content-Type", "application/json")
434 .timeout(std::time::Duration::from_secs(180))
435 .send_string(&body)
436 .map_err(|e| format!("annealing cloud: {e}"))?
437 .into_string()
438 .map_err(|e| format!("reading response: {e}"))?;
439
440 let side = self.machine.side();
441 let mut state = vec![-1i8; self.spins];
442
443 // The response is small and regular; parsing it with the crate's own zero-dep reader would
444 // mean a dependency cycle, so it is scanned directly.
445 self.last_energies = scan_numbers(&resp, "\"energies\":[");
446 self.last_execution_ns = scan_numbers(&resp, "\"execution_time\":")
447 .first()
448 .copied()
449 .unwrap_or(0.0) as u64;
450
451 let triples = scan_spins(&resp);
452 for (x, y, s) in triples {
453 let i = y * side + x;
454 if i < state.len() {
455 state[i] = s;
456 }
457 }
458 self.ledger.samples += self.spins as u64;
459 self.ledger.reads += self.spins as u64;
460 Ok(state)
461 }
462
463 fn ledger(&self) -> Ledger {
464 self.ledger
465 }
466}
467
468fn scan_numbers(s: &str, after: &str) -> Vec<f64> {
469 let Some(i) = s.find(after) else { return Vec::new() };
470 let rest = &s[i + after.len()..];
471 let end = rest.find(']').unwrap_or(rest.find(',').unwrap_or(rest.len()));
472 rest[..end]
473 .split(',')
474 .filter_map(|t| t.trim().parse::<f64>().ok())
475 .collect()
476}
477
478/// Pull `[x,y,s]` triples out of the **first execution's** spins array.
479///
480/// The response nests one array per execution, so taking every triple in the document would mix
481/// executions together and produce a state that is not any single run's answer.
482fn scan_spins(s: &str) -> Vec<(usize, usize, i8)> {
483 const KEY: &str = "\"spins\":[";
484 let Some(i) = s.find(KEY) else { return Vec::new() };
485 let rest = &s[i + KEY.len()..];
486
487 // Bracket-match the first execution's block rather than guessing where it ends.
488 let mut depth = 0i32;
489 let mut end = rest.len();
490 for (k, c) in rest.char_indices() {
491 match c {
492 '[' => depth += 1,
493 ']' => {
494 depth -= 1;
495 if depth == 0 {
496 end = k + 1;
497 break;
498 }
499 }
500 _ => {}
501 }
502 }
503
504 rest[..end]
505 .split('[')
506 .filter_map(|chunk| {
507 let body = chunk.split(']').next()?;
508 let parts: Vec<&str> =
509 body.split(',').map(str::trim).filter(|p| !p.is_empty()).collect();
510 if parts.len() != 3 {
511 return None;
512 }
513 Some((
514 parts[0].parse::<usize>().ok()?,
515 parts[1].parse::<usize>().ok()?,
516 if parts[2].parse::<i64>().ok()? > 0 { 1i8 } else { -1i8 },
517 ))
518 })
519 .collect()
520}
521
522#[cfg(test)]
523mod layout_reporting_tests {
524 use super::*;
525
526 #[test]
527 fn nothing_reaches_the_network_without_a_token_and_an_endpoint_you_chose() {
528 // The guarantee, held by a test rather than by reading the code. Everything a caller can do
529 // before `run` -- construct, describe the fabric, lay out, embed -- is local, and `run`
530 // needs a token you passed and an address you can see.
531 std::env::remove_var("ACW_TOKEN");
532 assert!(
533 Hitachi::from_env(Machine::Asic).is_err(),
534 "no token means no device, rather than a device pointing somewhere by default"
535 );
536
537 // The default is the service this driver is named for, and it is visible and replaceable.
538 let d = asic();
539 assert_eq!(d.endpoint(), ACW_ENDPOINT, "the default is stated, not hidden in a call site");
540 let redirected = asic().with_endpoint("http://127.0.0.1:1/never-listening");
541 assert_eq!(redirected.endpoint(), "http://127.0.0.1:1/never-listening");
542
543 // And the local paths stay local: pointed at a port nothing can be listening on, laying a
544 // program out still succeeds. If any of this dialled out, it would fail here.
545 let mut m = asic().with_endpoint("http://127.0.0.1:1/never-listening");
546 let side = Machine::Asic.side();
547 // `factor <weight> <i> <j>`. Spins 0 and 1 are (0,0) and (1,0): King-adjacent.
548 let src = format!("ftp 1\nname local-only\nspins {}\nfactor -1 0 1\n", side + 2);
549 let p = Program::from_ftp(&src).expect("a program that fits the grid");
550 m.layout(&p).expect("layout is a local pass and must not need the network");
551 assert_eq!(m.fabric().max_spins, Some(side * side), "describing the fabric is local too");
552 }
553
554 fn asic() -> Hitachi {
555 Hitachi::new(String::from("no-token-needed-for-a-capability-check"), Machine::Asic)
556 }
557
558 #[test]
559 fn a_model_that_does_not_fit_the_grid_is_placed_rather_than_refused() {
560 // This is what the driver could not do. Spins 0 and 500 are nowhere near each other on a
561 // 384-wide grid, so `layout` refuses; `place` embeds and finds sites that ARE adjacent.
562 let p = Program::from_ftp("ftp 1\nspins 600\nfactor 1 0 500\n").unwrap();
563 let mut h = asic();
564 assert!(h.layout(&p).is_err(), "it does not fit as written");
565
566 let e = h.place(&p).expect("a King's graph has room for two coupled variables");
567 assert_eq!(e.chains.len(), 600);
568 assert!(e.chains.iter().all(|c| !c.is_empty()), "every variable got sites");
569
570 // and the placement really is one
571 let hardware = ferrotherm::embed::topology::king(384);
572 e.verify(&p.to_graph().unwrap(), &hardware).expect("place must return a valid embedding");
573 }
574
575 #[test]
576 fn a_model_already_on_the_grid_is_placed_unchanged() {
577 // The common case, and the one the driver demanded of everybody: adjacent spins on the
578 // row-major layout. It must cost nothing and change nothing.
579 let p = Program::from_ftp("ftp 1\nspins 4\nfactor 1 0 1\nfactor 1 1 2\n").unwrap();
580 let e = asic().place(&p).expect("already King-adjacent");
581 assert!(
582 e.chains.iter().enumerate().all(|(i, c)| c == &vec![i]),
583 "an identity placement, not a rearrangement: {:?}",
584 &e.chains[..4]
585 );
586 }
587
588 #[test]
589 fn a_layout_failure_says_what_failed() {
590 // Every one of these used to come back as TooHighDegree { degree: 0, limit: 8 }, which
591 // reads as "degree 0 exceeds 8" -- not true of anything, and silent about the actual cause.
592 // Spins 0 and 500 are nowhere near each other on a 384-wide grid.
593 let p = Program::from_ftp("ftp 1\nspins 600\nfactor 1 0 500\n").unwrap();
594 let bad = asic().program(&p);
595 assert_eq!(bad.len(), 1, "{bad:?}");
596 let msg = bad[0].to_string();
597 assert!(msg.contains("King-adjacent"), "it names the real problem: {msg}");
598 assert!(!msg.contains("degree"), "and not a degree that was never the issue: {msg}");
599 }
600
601 #[test]
602 fn a_fractional_coefficient_cannot_reach_a_machine_that_stores_integers() {
603 // |3.5| <= 7, so a magnitude comparison admits it. The ASIC stores four-bit INTEGERS.
604 //
605 // `layout` is called DIRECTLY here, and deliberately. Going through `program` would prove
606 // nothing about this: it runs `Fabric::check` first and only lays out when that comes back
607 // clean, so the fabric's range check catches 3.5 and the layout is never reached. A first
608 // version of this test did go through `program`, passed, and stayed passing when the
609 // layout check was reverted to the magnitude comparison -- which is a test of the wrong
610 // thing wearing the right name. The two checks are defence in depth and each must hold on
611 // its own.
612 let p = Program::from_ftp("ftp 1\nspins 2\nfactor 3.5 0 1\n").unwrap();
613 let e = asic().layout(&p).expect_err("3.5 is not a four-bit integer");
614 assert!(
615 matches!(e, LayoutError::CoefficientRange { .. }),
616 "and it is refused as a coefficient problem: {e}"
617 );
618
619 // a whole number in range lays out
620 let ok = Program::from_ftp("ftp 1\nspins 2\nfactor 3 0 1\n").unwrap();
621 assert!(asic().layout(&ok).is_ok());
622
623 // and the fabric-level check refuses it too, independently
624 let bad = asic().program(&p);
625 assert!(
626 bad.iter().any(|u| u.to_string().contains("integers -7..=7")),
627 "the outer gate names what it can hold: {bad:?}"
628 );
629 }
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635
636 #[test]
637 fn the_king_graph_is_what_it_says() {
638 assert!(king_adjacent((1, 1), (2, 2)), "diagonals count");
639 assert!(king_adjacent((1, 1), (1, 2)));
640 assert!(!king_adjacent((1, 1), (1, 1)), "a site is not its own neighbour");
641 assert!(!king_adjacent((1, 1), (1, 3)));
642 }
643
644 #[test]
645 fn the_asic_declares_its_four_bit_limit() {
646 let d = Hitachi::new("x", Machine::Asic);
647 let f = d.fabric();
648 assert_eq!(f.coupling_precision, ferrotherm::fabric::Precision::Fixed { bits: 4 },
649 "-7..=7 is four bits with a sign");
650 assert_eq!(f.max_spins, Some(384 * 384));
651 assert_eq!(f.max_degree, Some(8));
652 }
653
654 #[test]
655 fn a_non_adjacent_model_is_refused_and_says_to_embed_it() {
656 // Refusing beats placing it silently: an embedding the caller did not choose is a different
657 // problem than the one they posed.
658 let mut d = Hitachi::new("x", Machine::Asic);
659 let p = Program::from_ftp("ftp 1\nspins 20\nfactor 1 0 19\n").unwrap();
660 let e = d.layout(&p).unwrap_err();
661 assert!(matches!(e, LayoutError::NotAdjacent { .. }));
662 assert!(e.to_string().contains("Embed the model"));
663 }
664
665 #[test]
666 fn a_grid_neighbour_lays_out_and_the_sign_inverts() {
667 let mut d = Hitachi::new("x", Machine::Asic);
668 // spins 0 and 1 are (0,0) and (1,0) under the row-major layout: adjacent
669 let p = Program::from_ftp("ftp 1\nspins 4\nfactor 1 0 1\n").unwrap();
670 d.layout(&p).unwrap();
671 assert_eq!(d.model.len(), 1);
672 assert_eq!(d.coeff[0], -1.0, "our ferromagnetic +1 must cross as their -1");
673 }
674
675 #[test]
676 fn an_out_of_range_coefficient_is_refused_before_submission() {
677 let mut d = Hitachi::new("x", Machine::Asic);
678 let p = Program::from_ftp("ftp 1\nspins 4\nfactor 40 0 1\n").unwrap();
679 let e = d.layout(&p).unwrap_err();
680 assert!(matches!(e, LayoutError::CoefficientRange { .. }));
681 assert!(e.to_string().contains("requantize"));
682 }
683
684 #[test]
685 fn the_request_is_shaped_the_way_the_api_documents() {
686 let mut d = Hitachi::new("x", Machine::Asic);
687 d.layout(&Program::from_ftp("ftp 1\nspins 4\nfactor 1 0 1\n").unwrap()).unwrap();
688 let j = d.request_json(3, &Schedule::geometric(0.1, 10.0, 20, 50));
689 assert!(j.contains("\"type\":5"));
690 assert!(j.contains("\"num_executions\":3"));
691 assert!(j.contains("[0,0,1,0,-1]"), "model triple with the inverted sign: {j}");
692 assert!(j.contains("temperature_num_steps"));
693 }
694
695 #[test]
696 fn responses_are_scanned_correctly() {
697 // The exact shape the machine returned on the first real call.
698 let r = r#"{"status":0,"result":{"energies":[-4.0,-4.0],"execution_time":693447567,
699 "spins":[[[0,0,1],[1,0,-1],[0,1,-1],[1,1,1]]]},"job_id":"x"}"#;
700 assert_eq!(scan_numbers(r, "\"energies\":["), vec![-4.0, -4.0]);
701 assert_eq!(scan_numbers(r, "\"execution_time\":")[0], 693447567.0);
702 let s = scan_spins(r);
703 assert!(s.contains(&(0, 0, 1)) && s.contains(&(1, 0, -1)));
704 }
705}