Skip to main content

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}