Skip to content

Commit daa5bdf

Browse files
hyperpolymathclaude
andcommitted
feat(coprocessor): Phase 2b — 5 subprocess CAS backends
Add PARI/GP, Maxima, Singular, GAP, and Macaulay2 coprocessor backends, completing the CAS family planned for Phase 2. All are ExternalSubprocess (Tier 1) and degrade gracefully when the CAS binary is absent. CoprocessorKind: + PariGp, Maxima, Singular, Gap, Macaulay2 CoprocessorOp additions (15 variants): - PariGpFactor / PariGpZnorder / PariGpNextPrime - MaximaSimplify / MaximaFactor / MaximaDiff - SingularGroebner / SingularIdealMembership / SingularDimension - GapGroupOrder / GapIsAbelian / GapSymmetricGroupOrder - Macaulay2GroebnerBasis / Macaulay2Dimension / Macaulay2Degree CoprocessorOutcome: + Text(String) for CAS symbolic output Security: all string inputs pass character-whitelist gates before the subprocess is spawned (decimal-int only for PARI/GP integers; math-expr charset for polynomial expressions; cycle-notation charset for GAP permutations). No injection path exists through any validated input. 29 new unit tests (all passing without the CAS binaries installed); 88 coprocessor tests total green. STATE.a2ml marks Phase 2 complete. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 8cd26a5 commit daa5bdf

9 files changed

Lines changed: 1715 additions & 2 deletions

File tree

.machine_readable/6a2/STATE.a2ml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,10 @@ target-claim-id = "echidna-phase1a-tptp-atps / echidna-phase1b-frontier-fol"
4242
[[expansion-roadmap.phase]]
4343
id = "phase-2"
4444
title = "Computer-algebra (FLINT FFI + subprocess CAS family)"
45-
status = "phase-2a-complete"
45+
status = "complete"
4646
adds-backends = ["FLINT", "PARI/GP", "Maxima", "Singular", "GAP", "Macaulay2"]
4747
target-claim-id = "echidna-phase2a-cas-libraries / echidna-phase2b-cas-subprocess"
48-
notes = "Phase 2a landed 2026-04-26: FLINT FFI coprocessor backend (CoprocessorKind::FlintMath, Tier 3 LibraryWrapped, --features flint). Adds FlintPolyGcd/Mul/Rem/Content + FlintNthRoot + FlintBinomial ops, Polynomial outcome variant. 8 unit tests; factory returns None gracefully without the feature. Phase 2b (subprocess CAS: PARI/GP, Maxima, Singular, GAP, Macaulay2) still available for claim."
48+
notes = "Phase 2a landed 2026-04-26: FLINT FFI coprocessor backend (CoprocessorKind::FlintMath, Tier 3 LibraryWrapped, --features flint). Adds FlintPolyGcd/Mul/Rem/Content + FlintNthRoot + FlintBinomial ops, Polynomial outcome variant. 8 unit tests; factory returns None gracefully without the feature. Phase 2b landed 2026-04-26: 5 subprocess CAS backends (CoprocessorKind PariGp/Maxima/Singular/Gap/Macaulay2, all Tier 1 ExternalSubprocess). Adds 15 new CoprocessorOp variants (PariGpFactor/Znorder/NextPrime, MaximaSimplify/Factor/Diff, SingularGroebner/IdealMembership/Dimension, GapGroupOrder/IsAbelian/SymmetricGroupOrder, Macaulay2GroebnerBasis/Dimension/Degree), Text outcome variant. 29 new unit tests; all inject-safe via character whitelists; health degrades gracefully when binary absent. 88 coprocessor tests total passing."
4949

5050
[[expansion-roadmap.phase]]
5151
id = "phase-3"

src/rust/coprocessor/gap.rs

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
2+
// SPDX-License-Identifier: PMPL-1.0-or-later
3+
// (MPL-2.0 is automatic legal fallback until PMPL is formally recognised)
4+
5+
//! GAP group-theory coprocessor — subprocess to `gap`.
6+
//!
7+
//! Provides permutation group computations: order, abelianness test, and
8+
//! symmetric group order. Trust tier `ExternalSubprocess` (Tier 1).
9+
//!
10+
//! Permutation generators are validated against cycle-notation characters
11+
//! only: digits, `(`, `)`, `,`, and space. No letters or shell
12+
//! metacharacters can appear in a permutation. GAP is invoked with `-q`
13+
//! (quiet, no banner) and a 30-second timeout.
14+
15+
use anyhow::{anyhow, Result};
16+
use async_trait::async_trait;
17+
use std::process::Stdio;
18+
use std::time::Duration;
19+
use tokio::io::AsyncWriteExt;
20+
use tokio::process::Command;
21+
22+
use super::trust::CoprocessorTrustTier;
23+
use super::types::{
24+
CoprocessorCapabilities, CoprocessorHealth, CoprocessorKind, CoprocessorOp,
25+
CoprocessorOutcome,
26+
};
27+
use super::Coprocessor;
28+
29+
const TIMEOUT: Duration = Duration::from_secs(30);
30+
31+
pub struct GapBackend {
32+
capabilities: CoprocessorCapabilities,
33+
health: CoprocessorHealth,
34+
}
35+
36+
impl GapBackend {
37+
pub fn new() -> Self {
38+
let health = if which("gap") {
39+
CoprocessorHealth::Healthy
40+
} else {
41+
CoprocessorHealth::Unhealthy
42+
};
43+
GapBackend {
44+
capabilities: CoprocessorCapabilities {
45+
supported_ops: vec![
46+
"GapGroupOrder".into(),
47+
"GapIsAbelian".into(),
48+
"GapSymmetricGroupOrder".into(),
49+
],
50+
typical_latency_us: 200_000,
51+
deterministic: true,
52+
},
53+
health,
54+
}
55+
}
56+
}
57+
58+
fn which(name: &str) -> bool {
59+
if let Ok(path) = std::env::var("PATH") {
60+
for dir in path.split(':') {
61+
let candidate = format!("{dir}/{name}");
62+
if std::path::Path::new(&candidate).is_file() {
63+
return true;
64+
}
65+
}
66+
}
67+
false
68+
}
69+
70+
/// Accept only characters that appear in cycle notation: digits, `()`, `,`,
71+
/// and ASCII space. No letters, no shell metacharacters.
72+
fn is_safe_permutation(s: &str) -> bool {
73+
!s.is_empty()
74+
&& s.chars().all(|c| c.is_ascii_digit() || "(,) ".contains(c))
75+
}
76+
77+
/// Render a list of permutations as a comma-separated GAP argument list.
78+
/// Each entry is already validated to contain only cycle-notation chars, so
79+
/// direct interpolation is safe.
80+
fn gen_list(generators: &[String]) -> String {
81+
generators.join(",")
82+
}
83+
84+
impl Default for GapBackend {
85+
fn default() -> Self {
86+
Self::new()
87+
}
88+
}
89+
90+
#[async_trait]
91+
impl Coprocessor for GapBackend {
92+
fn kind(&self) -> CoprocessorKind {
93+
CoprocessorKind::Gap
94+
}
95+
fn capabilities(&self) -> &CoprocessorCapabilities {
96+
&self.capabilities
97+
}
98+
fn health(&self) -> CoprocessorHealth {
99+
self.health
100+
}
101+
fn trust_tier(&self) -> CoprocessorTrustTier {
102+
CoprocessorTrustTier::ExternalSubprocess
103+
}
104+
105+
async fn dispatch(&self, op: CoprocessorOp) -> Result<CoprocessorOutcome> {
106+
if self.health == CoprocessorHealth::Unhealthy {
107+
return Ok(CoprocessorOutcome::Failure(
108+
"Gap: gap not found on PATH".into(),
109+
));
110+
}
111+
match op {
112+
CoprocessorOp::GapGroupOrder { generators } => {
113+
if generators.is_empty() {
114+
return Ok(CoprocessorOutcome::Failure(
115+
"GapGroupOrder: generators list is empty".into(),
116+
));
117+
}
118+
if let Some(bad) = generators.iter().find(|g| !is_safe_permutation(g)) {
119+
return Ok(CoprocessorOutcome::Failure(format!(
120+
"GapGroupOrder: unsafe permutation '{bad}'"
121+
)));
122+
}
123+
let script = format!(
124+
"g := Group({gens});\nPrint(Order(g), \"\\n\");\nQUIT;\n",
125+
gens = gen_list(&generators)
126+
);
127+
let out = run_gap(&script).await?;
128+
Ok(CoprocessorOutcome::BigInt(out.trim().to_string()))
129+
}
130+
CoprocessorOp::GapIsAbelian { generators } => {
131+
if generators.is_empty() {
132+
return Ok(CoprocessorOutcome::Failure(
133+
"GapIsAbelian: generators list is empty".into(),
134+
));
135+
}
136+
if let Some(bad) = generators.iter().find(|g| !is_safe_permutation(g)) {
137+
return Ok(CoprocessorOutcome::Failure(format!(
138+
"GapIsAbelian: unsafe permutation '{bad}'"
139+
)));
140+
}
141+
let script = format!(
142+
"g := Group({gens});\nPrint(IsAbelian(g), \"\\n\");\nQUIT;\n",
143+
gens = gen_list(&generators)
144+
);
145+
let out = run_gap(&script).await?;
146+
let trimmed = out.trim();
147+
match trimmed {
148+
"true" => Ok(CoprocessorOutcome::Boolean(true)),
149+
"false" => Ok(CoprocessorOutcome::Boolean(false)),
150+
other => Ok(CoprocessorOutcome::Failure(format!(
151+
"GapIsAbelian: unexpected output '{other}'"
152+
))),
153+
}
154+
}
155+
CoprocessorOp::GapSymmetricGroupOrder { n } => {
156+
let script = format!(
157+
"Print(Order(SymmetricGroup({n})), \"\\n\");\nQUIT;\n"
158+
);
159+
let out = run_gap(&script).await?;
160+
Ok(CoprocessorOutcome::BigInt(out.trim().to_string()))
161+
}
162+
other => Ok(CoprocessorOutcome::Failure(format!(
163+
"Gap backend does not support {:?}",
164+
std::mem::discriminant(&other)
165+
))),
166+
}
167+
}
168+
}
169+
170+
async fn run_gap(script: &str) -> Result<String> {
171+
let mut child = Command::new("gap")
172+
.arg("-q")
173+
.stdin(Stdio::piped())
174+
.stdout(Stdio::piped())
175+
.stderr(Stdio::piped())
176+
.kill_on_drop(true)
177+
.spawn()
178+
.map_err(|e| anyhow!("gap spawn: {e}"))?;
179+
180+
if let Some(mut stdin) = child.stdin.take() {
181+
stdin
182+
.write_all(script.as_bytes())
183+
.await
184+
.map_err(|e| anyhow!("gap stdin write: {e}"))?;
185+
}
186+
187+
let out = tokio::time::timeout(TIMEOUT, child.wait_with_output())
188+
.await
189+
.map_err(|_| anyhow!("gap timed out after 30s"))?
190+
.map_err(|e| anyhow!("gap wait: {e}"))?;
191+
192+
if !out.status.success() {
193+
let stderr = String::from_utf8_lossy(&out.stderr);
194+
return Err(anyhow!(
195+
"gap exit {}: {}",
196+
out.status.code().unwrap_or(-1),
197+
stderr.lines().take(3).collect::<Vec<_>>().join(" | ")
198+
));
199+
}
200+
Ok(String::from_utf8_lossy(&out.stdout).to_string())
201+
}
202+
203+
#[cfg(test)]
204+
mod tests {
205+
use super::*;
206+
207+
#[test]
208+
fn safe_permutation_validation() {
209+
assert!(is_safe_permutation("(1,2,3)"));
210+
assert!(is_safe_permutation("(1,2)(3,4)"));
211+
assert!(is_safe_permutation("(12,34)"));
212+
assert!(!is_safe_permutation(""));
213+
assert!(!is_safe_permutation("(1,2); QUIT"));
214+
assert!(!is_safe_permutation("(a,b)"));
215+
assert!(!is_safe_permutation("(1,2)\n(3,4)"));
216+
}
217+
218+
#[tokio::test]
219+
async fn unhealthy_returns_failure_without_spawn() {
220+
let backend = GapBackend {
221+
capabilities: CoprocessorCapabilities {
222+
supported_ops: vec![],
223+
typical_latency_us: 0,
224+
deterministic: true,
225+
},
226+
health: CoprocessorHealth::Unhealthy,
227+
};
228+
let r = backend
229+
.dispatch(CoprocessorOp::GapSymmetricGroupOrder { n: 3 })
230+
.await
231+
.unwrap();
232+
assert!(matches!(r, CoprocessorOutcome::Failure(_)));
233+
}
234+
235+
#[tokio::test]
236+
async fn unsafe_permutation_rejected_before_spawn() {
237+
let backend = GapBackend {
238+
capabilities: CoprocessorCapabilities {
239+
supported_ops: vec![],
240+
typical_latency_us: 0,
241+
deterministic: true,
242+
},
243+
health: CoprocessorHealth::Healthy,
244+
};
245+
let r = backend
246+
.dispatch(CoprocessorOp::GapGroupOrder {
247+
generators: vec!["(1,2); QUIT; Print(\"pwned\")".into()],
248+
})
249+
.await
250+
.unwrap();
251+
assert!(
252+
matches!(&r, CoprocessorOutcome::Failure(msg) if msg.contains("unsafe permutation")),
253+
"got {r:?}"
254+
);
255+
}
256+
257+
#[tokio::test]
258+
async fn empty_generators_rejected() {
259+
let backend = GapBackend {
260+
capabilities: CoprocessorCapabilities {
261+
supported_ops: vec![],
262+
typical_latency_us: 0,
263+
deterministic: true,
264+
},
265+
health: CoprocessorHealth::Healthy,
266+
};
267+
let r = backend
268+
.dispatch(CoprocessorOp::GapIsAbelian { generators: vec![] })
269+
.await
270+
.unwrap();
271+
assert!(matches!(r, CoprocessorOutcome::Failure(_)));
272+
}
273+
274+
#[test]
275+
fn gen_list_format() {
276+
let gens = vec!["(1,2,3)".to_string(), "(1,2)".to_string()];
277+
assert_eq!(gen_list(&gens), "(1,2,3),(1,2)");
278+
}
279+
280+
#[test]
281+
fn backend_construction_probes_path() {
282+
let b = GapBackend::new();
283+
assert!(matches!(
284+
b.health(),
285+
CoprocessorHealth::Healthy | CoprocessorHealth::Unhealthy
286+
));
287+
assert_eq!(b.kind(), CoprocessorKind::Gap);
288+
assert_eq!(b.trust_tier(), CoprocessorTrustTier::ExternalSubprocess);
289+
}
290+
}

src/rust/coprocessor/julia_bridge.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,5 +197,12 @@ fn kind_name(k: CoprocessorKind) -> &'static str {
197197
CoprocessorKind::Graphics => "Graphics",
198198
CoprocessorKind::Audio => "Audio",
199199
CoprocessorKind::Fpga => "Fpga",
200+
// Phase 2b subprocess CAS backends. Normally backed by their own
201+
// native subprocess; names are present so this match stays exhaustive.
202+
CoprocessorKind::PariGp => "PariGp",
203+
CoprocessorKind::Maxima => "Maxima",
204+
CoprocessorKind::Singular => "Singular",
205+
CoprocessorKind::Gap => "Gap",
206+
CoprocessorKind::Macaulay2 => "Macaulay2",
200207
}
201208
}

0 commit comments

Comments
 (0)