diff --git a/crates/semath-core/src/candidate.rs b/crates/semath-core/src/candidate.rs index e7eeae3..920a749 100644 --- a/crates/semath-core/src/candidate.rs +++ b/crates/semath-core/src/candidate.rs @@ -295,10 +295,15 @@ fn command_options(name: &str, options: &mut BTreeSet<(CandidateFamily, String)> "sum" | "prod" | "int" | "iint" | "iiint" | "lim" | "forall" | "exists" => { add(options, CandidateFamily::Binder, "binder"); } - "partial" | "nabla" => { + "partial" => { add(options, CandidateFamily::Differential, "differential"); add(options, CandidateFamily::Differential, "derivative"); } + "nabla" => { + add(options, CandidateFamily::Differential, "gradient"); + add(options, CandidateFamily::Differential, "divergence"); + add(options, CandidateFamily::Differential, "curl"); + } "cdot" => { add(options, CandidateFamily::Operator, "multiplication"); add(options, CandidateFamily::Operator, "inner-product"); diff --git a/crates/semath-core/src/canonical.rs b/crates/semath-core/src/canonical.rs index b898a8f..41ce972 100644 --- a/crates/semath-core/src/canonical.rs +++ b/crates/semath-core/src/canonical.rs @@ -1065,6 +1065,27 @@ impl Parser { provenance: token.provenance, }; } + "int" | "iint" | "iiint" => return self.parse_integral(), + "nabla" => { + let token = self.next(); + let mut argument = self.parse_power(); + if matches!(self.peek(), TokenKind::Open('(')) { + let application_argument = self.parse_power(); + if let Some(applied) = + apply_argument(argument.clone(), application_argument) + { + argument = applied; + } + } + return SemanticExpr { + range: merge_range(&token.range, &argument.range), + provenance: [token.provenance, argument.provenance.clone()].concat(), + kind: SemanticExprKind::Apply { + operator: "nabla".into(), + arguments: vec![argument], + }, + }; + } "underbrace" => { let command = self.next(); let mut expression = self.parse_group_or_atom(); @@ -1150,20 +1171,53 @@ impl Parser { } _ => (self.parse_group_or_atom(), self.parse_group_or_atom()), }; - if let Some((expression, variable, order)) = derivative_parts(&numerator, &denominator) { - return SemanticExpr { - range: merge_range(&command.range, &denominator.range), - provenance: [ - command.provenance, - numerator.provenance, - denominator.provenance, - ] - .concat(), - kind: SemanticExprKind::Derivative { - expression: Box::new(expression), + if let Some(derivative) = derivative_parts(&numerator, &denominator) { + let range = merge_range(&command.range, &denominator.range); + let provenance = [ + command.provenance, + numerator.provenance.clone(), + denominator.provenance.clone(), + ] + .concat(); + return match derivative { + ParsedDerivative::Total { + expression, variable, order, + } => SemanticExpr { + range, + provenance, + kind: SemanticExprKind::Derivative { + expression: Box::new(expression), + variable, + order, + }, }, + ParsedDerivative::Partial { + expression, + variables, + order, + } => { + let mut arguments = vec![expression]; + arguments.extend(variables.into_iter().map(|variable| SemanticExpr { + kind: SemanticExprKind::Symbol(variable), + range: denominator.range.clone(), + provenance: denominator.provenance.clone(), + })); + arguments.push(SemanticExpr { + kind: SemanticExprKind::Number(order.to_string()), + range: numerator.range.clone(), + provenance: numerator.provenance.clone(), + }); + SemanticExpr { + range, + provenance, + kind: SemanticExprKind::Apply { + operator: "partial-derivative".into(), + arguments, + }, + } + } }; } combined( @@ -1173,6 +1227,77 @@ impl Parser { ) } + fn parse_integral(&mut self) -> SemanticExpr { + let command = self.next(); + let mut lower = None; + let mut upper = None; + loop { + if self.consume_operator('_') { + lower = Some(self.parse_group_or_atom()); + } else if self.consume_operator('^') { + upper = Some(self.parse_group_or_atom()); + } else { + break; + } + } + let start = self.cursor; + let differential = (start..self.tokens.len().saturating_sub(1)) + .rev() + .find(|index| { + token_name(&self.tokens[*index].kind) == Some("d") + && token_name(&self.tokens[*index + 1].kind).is_some() + && self.tokens.get(*index + 2).is_none_or(|token| { + !starts_atom(&token.kind) + || matches!(token.kind, TokenKind::Command(ref name) if is_relation_command(name)) + }) + }); + let Some(differential) = differential else { + return SemanticExpr { + kind: SemanticExprKind::Apply { + operator: "integral".into(), + arguments: Vec::new(), + }, + range: command.range, + provenance: command.provenance, + }; + }; + if differential == start { + return SemanticExpr { + kind: SemanticExprKind::Apply { + operator: "integral".into(), + arguments: Vec::new(), + }, + range: command.range, + provenance: command.provenance, + }; + } + let integrand = Parser::new(self.tokens[start..differential].to_vec()).parse_relation(); + let variable_token = self.tokens[differential + 1].clone(); + let variable = SemanticExpr { + kind: SemanticExprKind::Symbol( + token_name(&variable_token.kind) + .unwrap_or_default() + .to_owned(), + ), + range: variable_token.range.clone(), + provenance: variable_token.provenance.clone(), + }; + self.cursor = differential + 2; + let mut arguments = vec![integrand, variable]; + if let (Some(lower), Some(upper)) = (lower, upper) { + arguments.push(lower); + arguments.push(upper); + } + SemanticExpr { + range: merge_range(&command.range, &variable_token.range), + provenance: command.provenance, + kind: SemanticExprKind::Apply { + operator: "integral".into(), + arguments, + }, + } + } + fn parse_atom(&mut self) -> SemanticExpr { let token = self.next(); match token.kind { @@ -1419,25 +1544,123 @@ fn starts_atom(token: &TokenKind) -> bool { } } +fn token_name(token: &TokenKind) -> Option<&str> { + match token { + TokenKind::Identifier(name) | TokenKind::Command(name) => Some(name), + _ => None, + } +} + +fn is_relation_command(name: &str) -> bool { + matches!( + name, + "ge" | "geq" + | "in" + | "le" + | "leq" + | "notin" + | "subset" + | "subseteq" + | "supset" + | "supseteq" + ) +} + +enum ParsedDerivative { + Total { + expression: SemanticExpr, + variable: String, + order: u8, + }, + Partial { + expression: SemanticExpr, + variables: Vec, + order: u8, + }, +} + fn derivative_parts( numerator: &SemanticExpr, denominator: &SemanticExpr, -) -> Option<(SemanticExpr, String, u8)> { +) -> Option { let SemanticExprKind::Product(numerator_factors) = &numerator.kind else { return None; }; let SemanticExprKind::Product(denominator_factors) = &denominator.kind else { return None; }; - let (Some("d"), Some(expression), Some("d"), Some(variable)) = ( - numerator_factors.first().and_then(symbol_name), - numerator_factors.get(1), - denominator_factors.first().and_then(symbol_name), - denominator_factors.get(1).and_then(symbol_name), - ) else { + let (operator, numerator_order) = differential_order(numerator_factors.first()?)?; + let expression = numerator_factors.get(1)?.clone(); + if operator == "d" { + let (denominator_operator, _) = differential_order(denominator_factors.first()?)?; + if denominator_operator != "d" || denominator_factors.len() != 2 { + return None; + } + let (variable, variable_order) = variable_order(&denominator_factors[1])?; + if numerator_order != variable_order { + return None; + } + return Some(ParsedDerivative::Total { + expression, + variable, + order: numerator_order, + }); + } + if operator != "partial" { + return None; + } + let mut variables = Vec::new(); + let mut denominator_order = 0_u8; + let mut cursor = 0; + while cursor + 1 < denominator_factors.len() { + let (denominator_operator, operator_order) = + differential_order(&denominator_factors[cursor])?; + if denominator_operator != "partial" || operator_order != 1 { + return None; + } + let (variable, order) = variable_order(&denominator_factors[cursor + 1])?; + denominator_order = denominator_order.checked_add(order)?; + variables.extend(std::iter::repeat_n(variable, order as usize)); + cursor += 2; + } + (cursor == denominator_factors.len() && numerator_order == denominator_order).then_some( + ParsedDerivative::Partial { + expression, + variables, + order: numerator_order, + }, + ) +} + +fn differential_order(expression: &SemanticExpr) -> Option<(&str, u8)> { + match &expression.kind { + SemanticExprKind::Symbol(name) if matches!(name.as_str(), "d" | "partial") => { + Some((name, 1)) + } + SemanticExprKind::Power(base, exponent) => { + let name = symbol_name(base)?; + matches!(name, "d" | "partial") + .then(|| number_order(exponent).map(|order| (name, order)))? + } + _ => None, + } +} + +fn variable_order(expression: &SemanticExpr) -> Option<(String, u8)> { + match &expression.kind { + SemanticExprKind::Symbol(name) => Some((name.clone(), 1)), + SemanticExprKind::Power(base, exponent) => { + Some((symbol_name(base)?.into(), number_order(exponent)?)) + } + _ => None, + } +} + +fn number_order(expression: &SemanticExpr) -> Option { + let SemanticExprKind::Number(value) = &expression.kind else { return None; }; - Some((expression.clone(), variable.into(), 1)) + value.parse::().ok().filter(|order| *order > 0) } fn symbol_name(expression: &SemanticExpr) -> Option<&str> { @@ -1552,6 +1775,28 @@ mod tests { )); } + #[test] + fn lowers_calculus_operators_with_explicit_variables_orders_and_bounds() { + assert_eq!( + render_canonical(&lower_template("\\int_0^1 g(t) \\, d t")), + "apply(integral,apply(g,symbol(t)),symbol(t),number(0),number(1))" + ); + assert_eq!( + render_canonical(&lower_template("\\frac{d^2 f}{d x^2}")), + "derivative(symbol(f),x,2)" + ); + assert_eq!( + render_canonical(&lower_template( + "\\frac{\\partial^2 f}{\\partial x \\partial y}" + )), + "apply(partial-derivative,symbol(f),symbol(x),symbol(y),number(2))" + ); + assert_eq!( + render_canonical(&lower_template("\\nabla f(x)")), + "apply(nabla,apply(f,symbol(x)))" + ); + } + #[test] fn snapshot_lowering_preserves_delimiters_and_ignores_spacing_commands() { let document: ProjectDocument = serde_json::from_value(serde_json::json!({ diff --git a/crates/semath-core/src/engine.rs b/crates/semath-core/src/engine.rs index c3e0a36..d0808be 100644 --- a/crates/semath-core/src/engine.rs +++ b/crates/semath-core/src/engine.rs @@ -144,6 +144,10 @@ impl AnalyzedDocument { } }) .collect(); + semantic_occurrences.extend(structural_command_occurrences( + &document, + &semantic_occurrences, + )); let cross_modal_bindings = extract_cross_modal_bindings(&document); for binding in &cross_modal_bindings { semantic_occurrences.push(SemanticOccurrenceSeed { @@ -196,6 +200,53 @@ impl AnalyzedDocument { } } +fn structural_command_occurrences( + document: &ProjectDocument, + existing: &[SemanticOccurrenceSeed], +) -> Vec { + document + .nodes + .iter() + .filter(|node| { + node.kind == crate::NotationNodeKind::Command + && node.state == crate::SyntaxState::Complete + }) + .filter_map(|node| { + let selection_range = node + .ranges + .command + .as_ref() + .or(node.ranges.name.as_ref())? + .clone(); + if selection_range.start_offset == selection_range.end_offset + || existing + .iter() + .any(|seed| seed.selection_range == selection_range) + { + return None; + } + let structural_path = notation_path(document, &selection_range); + let surface = source_text(document, &selection_range); + let candidate_options = structural_candidate_options( + document, + &structural_path, + &node.ranges.full, + &surface, + ); + (!candidate_options.is_empty()).then(|| SemanticOccurrenceSeed { + kind: OccurrenceKind::Notation, + surface: surface.clone(), + selection_range, + range: node.ranges.full.clone(), + structural_path, + source_text: source_text(document, &node.ranges.full), + notation: vec![NotationComponent::NamedSurface { value: surface }], + candidate_options, + }) + }) + .collect() +} + #[derive(Default)] struct ProjectState { documents: HashMap, @@ -801,7 +852,21 @@ fn notation_components( } crate::NotationNodeKind::Script => match node.name.as_deref() { Some("superscript") => components.push(NotationComponent::Superscript), - Some("subscript") => components.push(NotationComponent::Subscript), + Some("subscript") => { + let base = node + .children + .first() + .map(|child| bounded_notation_text(document, *child, 0)) + .unwrap_or_default(); + let index = node + .children + .get(1) + .map(|child| bounded_notation_text(document, *child, 0)) + .unwrap_or_default(); + if !base.is_empty() && !index.is_empty() { + components.push(NotationComponent::Subscript { base, index }); + } + } _ => {} }, crate::NotationNodeKind::NamedOperator => { @@ -823,6 +888,25 @@ fn notation_components( components } +fn bounded_notation_text(document: &ProjectDocument, node_id: u32, depth: u8) -> String { + if depth == 8 { + return String::new(); + } + let Some(node) = document.nodes.get(node_id as usize) else { + return String::new(); + }; + if let Some(text) = &node.text { + return text.clone(); + } + if node.children.is_empty() { + return node.name.clone().unwrap_or_default(); + } + node.children + .iter() + .map(|child| bounded_notation_text(document, *child, depth + 1)) + .collect() +} + fn source_text(document: &ProjectDocument, range: &SourceRange) -> String { let index = crate::SourceIndex::new(&document.content); let start = index.byte_for_utf16(range.start_offset); diff --git a/crates/semath-core/src/law.rs b/crates/semath-core/src/law.rs index 625ad81..c5cc768 100644 --- a/crates/semath-core/src/law.rs +++ b/crates/semath-core/src/law.rs @@ -598,6 +598,14 @@ fn expression_shape(expression: &SemanticExpr, shapes: &ShapeObservations) -> Sh _ => ShapeInference::Unknown, } } + SemanticExprKind::Apply { + operator, + arguments, + } if matches!(operator.as_str(), "integral" | "partial-derivative") => arguments + .first() + .map_or(ShapeInference::Unknown, |argument| { + expression_shape(argument, shapes) + }), SemanticExprKind::Relation { left, right, .. } => combine_equal_shapes([ expression_shape(left, shapes), expression_shape(right, shapes), diff --git a/crates/semath-core/src/protocol.rs b/crates/semath-core/src/protocol.rs index cd3af99..2cbcce4 100644 --- a/crates/semath-core/src/protocol.rs +++ b/crates/semath-core/src/protocol.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use crate::semantic_index::{EntityId, NotationComponent, SourceOccurrenceId}; -pub const PROTOCOL_VERSION: u32 = 5; +pub const PROTOCOL_VERSION: u32 = 6; pub const WASMTEX_SYNTAX_SCHEMA_VERSION: u32 = 4; #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] diff --git a/crates/semath-core/src/semantic_index.rs b/crates/semath-core/src/semantic_index.rs index ad1a418..968e2e0 100644 --- a/crates/semath-core/src/semantic_index.rs +++ b/crates/semath-core/src/semantic_index.rs @@ -44,7 +44,7 @@ pub enum NotationComponent { NamedSurface { value: String }, Modifier { name: String }, Style { name: String }, - Subscript, + Subscript { base: String, index: String }, Superscript, Argument { role: String }, Delimiter { value: String }, diff --git a/docs/architecture.md b/docs/architecture.md index 7d3f5a7..cab8cf3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -84,10 +84,13 @@ namespaced concepts; they do not add sentence recognizers. Source spelling and layout remain in the structural representation. The canonical IR represents a small compositional vocabulary: symbols, numbers, directional relations, sums, products, fractions, powers, multi-argument -applications, composition, derivatives, dot and cross products, and explicit -set union, intersection, and membership operators. Operator meaning is not -encoded as a textbook law name. Every node keeps source ranges and macro -provenance. +applications, composition, total and partial derivatives with explicit order +and variables, integrals with differentials and bounds when structurally +available, nabla applications, dot and cross products, and explicit set union, +intersection, and membership operators. Indexed occurrences retain base/index +components while their complete surface remains the entity key. Operator +meaning is not encoded as a textbook law name. Every node keeps source ranges +and macro provenance. Normalization is layered: @@ -156,7 +159,7 @@ units and coordinate frames. ## Public boundary -Protocol 5 exposes selection, `semanticView`, definition, references, rename, +Protocol 6 exposes selection, `semanticView`, definition, references, rename, and diagnostics. `semanticView` is meaning-first: summary, roles, conditions, explicit assumptions, evidence, declarations, conflicts, and refusal are public; raw parser trees are not. Native, WASM, Worker, LSP, and CorTeX consume diff --git a/docs/pack-maturity.md b/docs/pack-maturity.md index 0d8c81e..92953aa 100644 --- a/docs/pack-maturity.md +++ b/docs/pack-maturity.md @@ -42,13 +42,13 @@ field completeness. | Macro semantics | A macro command name could be mistaken for prose meaning | Only wasmtex-approved transparent surfaces contribute prose quantity meaning; opaque calls refuse | | Engineering composition | Shared notation could activate an unrelated field | Added typed role/quantity composition and a 16-case cross-field refusal suite | | Pack vocabulary | Natural concept paraphrases required runtime vocabulary edits | Schema 5 added reviewed concept aliases consumed by the generic classifier | -| Constraints | Side conditions were free-form strings without machine-checkable subjects or evidence | Schema 6 and protocol 5 use closed constraint kinds, validated law roles, bound symbols, source evidence, and explicit resolution status | +| Constraints | Side conditions were free-form strings without machine-checkable subjects or evidence | Schema 6 uses closed constraint kinds, validated law roles, bound symbols, source evidence, and explicit resolution status | +| Generic calculus IR | Integrals, partial derivatives, nabla applications, and indexed families lost operator structure | The shared structural path now keeps explicit differential variables, derivative order, integral bounds, and base/index components without command-specific pack logic | ## Remaining measured gaps | Category | Current limitation | Affected evidence | | --- | --- | --- | -| Generic IR | Indexed families, partial derivatives, gradients, integrals, and operator result typing do not yet share a complete constraint path | Calculus, optimization, linear algebra, and control systems | | Coverage | Probe packs demonstrate coherent vertical slices, not broad field recognition | Electromagnetism, thermodynamics/heat transfer, fluid mechanics, calculus, discrete mathematics, and optimization/ML | The remaining limitations are inputs to later roadmap issues. They require diff --git a/lib/wasm/SHA256SUMS b/lib/wasm/SHA256SUMS index 12c6f69..8eedee4 100644 --- a/lib/wasm/SHA256SUMS +++ b/lib/wasm/SHA256SUMS @@ -1,4 +1,4 @@ 48ebf2d7ca8844a6c43d40f6c162c5f55ec11db48f59016a7bb8fe71c3a50aee semath_wasm.js 876e88de0cb682992cbac9908e0281972bdd3d63fcc6d1faa5ad1b37832da44c semath_wasm.d.ts -ef965c26c5893a66384a01aa71f0942b4e020edf1fb5b0035c773af80df99524 semath_wasm_bg.wasm +b74b83b630e41afd9f0c015a3a9998826ad1b4835994aa4c6329781b5d4bb450 semath_wasm_bg.wasm 0e25611dc3609896c18fd79eb0eb03ddd383a7bc34df29ccfc5abfd5df4dbe72 semath_wasm_bg.wasm.d.ts diff --git a/lib/wasm/semath_wasm_bg.wasm b/lib/wasm/semath_wasm_bg.wasm index 635da11..fed4da4 100644 Binary files a/lib/wasm/semath_wasm_bg.wasm and b/lib/wasm/semath_wasm_bg.wasm differ diff --git a/packages/lsp/src/index.test.ts b/packages/lsp/src/index.test.ts index 9fbcb35..a458606 100644 --- a/packages/lsp/src/index.test.ts +++ b/packages/lsp/src/index.test.ts @@ -178,6 +178,44 @@ describe("SemathLspServer", () => { server.dispose(); }); + test("addresses structural calculus operators and preserves indexed-family parts", async () => { + const { messages, server } = await setup(); + const uri = "file:///calculus.tex"; + const content = "Use $\\int_0^1 g(t)\\,dt$ and the indexed family $x_i$."; + await server.handle({ + method: "textDocument/didOpen", + params: { + textDocument: { languageId: "latex", text: content, uri, version: 1 }, + }, + }); + await server.handle({ + id: 64, + method: "semath/semanticView", + params: { + position: positionAt(content, content.indexOf("\\int") + "\\int".length), + textDocument: { uri }, + }, + }); + await server.handle({ + id: 65, + method: "semath/semanticView", + params: { + position: positionAt(content, content.indexOf("x_i")), + textDocument: { uri }, + }, + }); + + expect(response(messages, 64).view.context.candidates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ family: "binder", interpretation: "binder" }), + ]), + ); + expect(response(messages, 65).view.symbol.notation).toEqual( + expect.arrayContaining([{ kind: "subscript", base: "x", index: "i" }]), + ); + server.dispose(); + }); + test("maps three-way English declarations through hover and definition", async () => { const { messages, server } = await setup(); const uri = "file:///declarations.md"; diff --git a/packages/protocol/src/index.test.ts b/packages/protocol/src/index.test.ts index 8622087..7862490 100644 --- a/packages/protocol/src/index.test.ts +++ b/packages/protocol/src/index.test.ts @@ -14,7 +14,7 @@ describe("protocol", () => { projectId: "project", protocolVersion: SEMATH_PROTOCOL_VERSION, }; - expect(snapshot.protocolVersion).toBe(5); + expect(snapshot.protocolVersion).toBe(6); }); test("allows omitted empty role collections from the wire format", () => { diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 65daf2f..318fad4 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -4,7 +4,7 @@ import type { LatexMacroEvent, } from "wasmtex/syntax"; -export const SEMATH_PROTOCOL_VERSION = 5 as const; +export const SEMATH_PROTOCOL_VERSION = 6 as const; export const WASMTEX_SYNTAX_SCHEMA_VERSION = 4 as const; export type DocumentLanguage = "bibtex" | "latex" | "markdown"; @@ -114,7 +114,7 @@ export type NotationComponent = | { kind: "named-surface"; value: string } | { kind: "modifier"; name: string } | { kind: "style"; name: string } - | { kind: "subscript" } + | { kind: "subscript"; base: string; index: string } | { kind: "superscript" } | { kind: "argument"; role: string } | { kind: "delimiter"; value: string };