Added the Mathematics tools as a prototype (#1162)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-11-05 10:39:40 +01:00
1 parent ce846e540e
commit 9c98bdae86
49 files changed
+4373 -11

No files matched your search

+2
View File
@@ -100,6 +100,8 @@ Code which decides something on behalf of a request — whether the classic RAG
**Area guide:** `app/MindWork AI Studio/Tools/ToolCallingSystem/AGENTS.md`, with how to add, change, or
remove a tool and the rules every tool implementation follows.
**Mathematics.** Code which computes with AngouriMath runs inside `MathComputationService.ComputeAsync` and reads expressions with `MathExpressionReader`, never by calling `MathS` or `Entity` on a thread of its own: the service gives it the stack deep expressions need, the time budget, and the settings the tools rely on, such as explicit parsing. Check exact values and expansions with `MathGuards` first, since AngouriMath ignores the cancellation there. Evaluate a value numerically with `NumericEvaluator.EvaluateAt`, not with `EvalNumerical`: by default, AngouriMath takes tiny values, such as those of physical constants, for 0. Never name AngouriMath in a text the model reads, such as a refusal or an issue; write "the tool" instead. See "Mathematics" in `documentation/Tools.md`.
## Model Capabilities
**Documentation:** `documentation/Models.md`
+2 -1
View File
@@ -86,7 +86,8 @@ public sealed class VerifyCommand
/// <remarks>
/// Debug builds and the tests are not trimmed, so only a published build shows that the trimmer
/// removed code which System.Text.Json reaches by reflection. Then AI Studio can neither load nor
/// save, e.g., its chats, while everything looks fine during development.
/// save, e.g., its chats, while everything looks fine during development. The same holds for
/// AngouriMath, which the Mathematics tools compute with.
/// </remarks>
/// <returns>The exit code of the publishing, when that failed; otherwise the one of the smoke test.</returns>
private static async Task<int> RunTrimSmokeTestAsync()
@@ -11545,6 +11545,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1513592659"] = "Copies the allow
-- Waiting for the configuration plugin...
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1533382393"] = "Waiting for the configuration plugin..."
-- MailKit connects AI Studio to the mail servers of your mailboxes over IMAP. This is how AI Studio keeps a local index of your mails, which the AI can search, read, and count.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1538080625"] = "MailKit connects AI Studio to the mail servers of your mailboxes over IMAP. This is how AI Studio keeps a local index of your mails, which the AI can search, read, and count."
-- Encryption secret: is not configured
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1560776885"] = "Encryption secret: is not configured"
@@ -11677,6 +11680,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2371107659"] = "installation pro
-- Installed Pandoc version: Pandoc is not installed or not available.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2374031539"] = "Installed Pandoc version: Pandoc is not installed or not available."
-- The Slide Assistant uses the Open XML SDK to read your PowerPoint templates and to write the presentations it plans with you as PowerPoint files.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2377050296"] = "The Slide Assistant uses the Open XML SDK to read your PowerPoint templates and to write the presentations it plans with you as PowerPoint files."
-- current installation location does not support automatic updates
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2401198677"] = "current installation location does not support automatic updates"
@@ -11758,6 +11764,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T285730904"] = "managed; updates
-- The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2868174483"] = "The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software."
-- The Mathematics tools use AngouriMath to calculate exactly and to work with formulas: it keeps fractions exact, simplifies, differentiates, and integrates expressions, and solves equations, all locally on your computer.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2906898949"] = "The Mathematics tools use AngouriMath to calculate exactly and to work with formulas: it keeps fractions exact, simplifies, differentiates, and integrates expressions, and solves equations, all locally on your computer."
-- AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are not yet available.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2924964415"] = "AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are not yet available."
@@ -11944,6 +11953,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4079152443"] = "This library is
-- Ropus provides the Opus encoder and decoder used by the media pipeline.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4113556626"] = "Ropus provides the Opus encoder and decoder used by the media pipeline."
-- MimeKit reads the mails in your mailboxes, including their senders, recipients, and attachments, so that AI Studio can add them to the local index of your mailboxes.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4139338110"] = "MimeKit reads the mails in your mailboxes, including their senders, recipients, and attachments, so that AI Studio can add them to the local index of your mailboxes."
-- Community & Code
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4158546761"] = "Community & Code"
@@ -12841,6 +12853,9 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T4678
-- Assistant Builder: Generate and install assistant plugins
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T610184927"] = "Assistant Builder: Generate and install assistant plugins"
-- Mathematics: Let the AI calculate exactly and work with formulas, right on your computer
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T842000463"] = "Mathematics: Let the AI calculate exactly and work with formulas, right on your computer"
-- Use no data sources, when sending an assistant result to a chat
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::SENDTOCHATDATASOURCEBEHAVIOREXTENSIONS::T1223925477"] = "Use no data sources, when sending an assistant result to a chat"
@@ -16498,6 +16513,24 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS:
-- Lets the AI search your mailboxes, list mails by sender, date, or flags, and quote what they say.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::MAILBOXES::SEARCHMAILSTOOL::T553285901"] = "Lets the AI search your mailboxes, list mails by sender, date, or flags, and quote what they say."
-- Lets the AI calculate exactly instead of guessing digits, e.g., fractions, roots, or integrals, and check an equation. Everything is computed on your computer.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::MATHEMATICS::CALCULATETOOL::T1801415227"] = "Lets the AI calculate exactly instead of guessing digits, e.g., fractions, roots, or integrals, and check an equation. Everything is computed on your computer."
-- Calculate
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::MATHEMATICS::CALCULATETOOL::T3960340175"] = "Calculate"
-- Lets the AI work with formulas: simplify and factor them, differentiate and integrate them, take limits, and solve equations. Every result is checked numerically where possible, right on your computer.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::MATHEMATICS::COMPUTESYMBOLICALLYTOOL::T1552482768"] = "Lets the AI work with formulas: simplify and factor them, differentiate and integrate them, take limits, and solve equations. Every result is checked numerically where possible, right on your computer."
-- Compute Symbolically
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::MATHEMATICS::COMPUTESYMBOLICALLYTOOL::T3797998120"] = "Compute Symbolically"
-- Mathematics
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::MATHEMATICS::MATHEMATICSTOOLCOLLECTION::T3403411261"] = "Mathematics"
-- Lets the AI calculate exactly and work with formulas, such as solving equations or checking an integral, right on your computer.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::MATHEMATICS::MATHEMATICSTOOLCOLLECTION::T3783381480"] = "Lets the AI calculate exactly and work with formulas, such as solving equations or checking an integral, right on your computer."
-- This chat holds internal content, such as e-mails, wiki pages, or internal web pages, so it may only read pages of the wiki and of the allowed private hosts configured in AI Studio. The requested address is not one of them. A new chat can read other web pages again.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::READWEBPAGETOOL::T1012489816"] = "This chat holds internal content, such as e-mails, wiki pages, or internal web pages, so it may only read pages of the wiki and of the allowed private hosts configured in AI Studio. The requested address is not one of them. A new chat can read other web pages again."
@@ -74,6 +74,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="AngouriMath" Version="2.6.0" />
<PackageReference Include="CodeBeam.MudBlazor.Extensions" Version="9.2.0" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
@@ -404,6 +404,10 @@
<ThirdPartyComponent Name="Tokenizer" Developer="Anthony Moi, Nicolas Patry, Pierric Cistac, Arthur Zucker & Open Source Community" LicenseName="Apache-2.0" LicenseUrl="https://github.com/huggingface/tokenizers/blob/main/LICENSE" RepositoryUrl="https://github.com/huggingface/tokenizers" UseCase="@T("The Tokenizer library serves as the base framework for integrating the DeepSeek tokenizer.")" />
<ThirdPartyComponent Name="HtmlAgilityPack" Developer="ZZZ Projects & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/zzzprojects/html-agility-pack/blob/master/LICENSE" RepositoryUrl="https://github.com/zzzprojects/html-agility-pack" UseCase="@T("We use the HtmlAgilityPack to extract content from the web. This is necessary, e.g., when you provide a URL as input for an assistant.")"/>
<ThirdPartyComponent Name="ReverseMarkdown" Developer="Babu Annamalai & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/mysticmind/reversemarkdown-net/blob/master/LICENSE" RepositoryUrl="https://github.com/mysticmind/reversemarkdown-net" UseCase="@T("This library is used to convert HTML to Markdown. This is necessary, e.g., when you provide a URL as input for an assistant.")"/>
<ThirdPartyComponent Name="AngouriMath" Developer='WhiteBlackGoose, Rafael Vuijk, Hadrian Tang, Alexander Potapov, darkfader, Aleksei Akhundov, KuhakuPixel, Goldey2, Joshua "Yoshi" Askharoun, Marmota & Open Source Community' LicenseName="MIT" LicenseUrl="https://github.com/ASC-Community/AngouriMath/blob/master/LICENSE.md" RepositoryUrl="https://github.com/asc-community/AngouriMath" UseCase="@T("The Mathematics tools use AngouriMath to calculate exactly and to work with formulas: it keeps fractions exact, simplifies, differentiates, and integrates expressions, and solves equations, all locally on your computer.")"/>
<ThirdPartyComponent Name="MailKit" Developer="Jeffrey Stedfast, Jonathan Dick, Miguel de Icaza, rklec, Steffen Kieß & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/jstedfast/MailKit/blob/master/LICENSE" RepositoryUrl="https://github.com/jstedfast/MailKit" UseCase="@T("MailKit connects AI Studio to the mail servers of your mailboxes over IMAP. This is how AI Studio keeps a local index of your mails, which the AI can search, read, and count.")"/>
<ThirdPartyComponent Name="MimeKit" Developer="Jeffrey Stedfast, Jason Nelson, Federico Di Gregorio, Joe Shook, Jonathan Dick, Miguel de Icaza, Peter Dettman & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/jstedfast/MimeKit/blob/master/LICENSE" RepositoryUrl="https://github.com/jstedfast/MimeKit" UseCase="@T("MimeKit reads the mails in your mailboxes, including their senders, recipients, and attachments, so that AI Studio can add them to the local index of your mailboxes.")"/>
<ThirdPartyComponent Name="Open XML SDK" Developer="Microsoft, Taylor Southwick, Tom Jebo, Michael Bowen, Eric White, Thomas Barnekow, Alfred Hellstern, lindexi, Simon Cropp & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/dotnet/Open-XML-SDK/blob/main/LICENSE" RepositoryUrl="https://github.com/dotnet/Open-XML-SDK" UseCase="@T("The Slide Assistant uses the Open XML SDK to read your PowerPoint templates and to write the presentations it plans with you as PowerPoint files.")"/>
<ThirdPartyComponent Name="wikEd diff" Developer="Cacycle & Open Source Community" LicenseName="None (public domain)" LicenseUrl="https://en.wikipedia.org/wiki/User:Cacycle/diff#License" RepositoryUrl="https://en.wikipedia.org/wiki/User:Cacycle/diff" UseCase="@T("This library is used to display the differences between two texts. This is necessary, e.g., for the grammar and spelling assistant.")"/>
<ThirdPartyComponent Name="CodeJar" Developer="Anton Medvedev" LicenseName="MIT" LicenseUrl="https://github.com/antonmedv/codejar/blob/master/LICENSE" RepositoryUrl="https://github.com/antonmedv/codejar/" UseCase="@T("CodeJar is a lightweight embeddable code editor for the browser.")"/>
<ThirdPartyComponent Name="Simple Icons" Developer="Simple Icons Contributors & Open Source Community" LicenseName="CC0-1.0" LicenseUrl="https://github.com/simple-icons/simple-icons/blob/16.21.0/LICENSE.md" RepositoryUrl="https://github.com/simple-icons/simple-icons" UseCase="@T("Several of the provider logos in AI Studio use the icon paths and brand colors published by the Simple Icons project, which releases them into the public domain under CC0. The trademarks themselves are not part of that release and remain the property of their respective owners.")"/>
@@ -1091,6 +1091,7 @@ CONFIG["SETTINGS"] = {}
-- whole collection or sets its confidence, and of several levels set for a collection and its
-- tools, the highest applies.
-- Collection IDs include: mailboxes (search_mails, read_mail, and count_mails; while the mailbox
-- preview is enabled), mathematics (calculate and compute_symbolically; while the mathematics
-- preview is enabled)
-- Disable individual tools or tool collections by their stable ID. The default is an empty set.
@@ -1106,10 +1107,12 @@ CONFIG["SETTINGS"] = {}
-- Configure the minimum provider confidence level required for individual tools or tool
-- collections.
-- Tool IDs include: web_search, read_web_page, search_confluence, semantic_search
-- Collection IDs include: mailboxes
-- Collection IDs include: mailboxes, mathematics
-- Allowed values are: NONE, UNTRUSTED, VERY_LOW, LOW, MODERATE, MEDIUM, HIGH
-- Defaults: web_search = VERY_LOW, read_web_page = VERY_LOW, search_confluence = HIGH,
-- semantic_search = NONE, mailboxes = VERY_LOW
-- semantic_search = NONE, mailboxes = VERY_LOW, mathematics = NONE
-- mathematics computes locally and sees nothing but what the model writes into its arguments,
-- so it needs no minimum of its own.
-- search_confluence always searches with a HIGH-confidence provider only, whatever value is
-- set here.
-- semantic_search offers a provider only the data sources whose own confidence level it meets,
@@ -1120,7 +1123,8 @@ CONFIG["SETTINGS"] = {}
-- ["read_web_page"] = "VERY_LOW",
-- ["search_confluence"] = "HIGH",
-- ["semantic_search"] = "NONE",
-- ["mailboxes"] = "VERY_LOW"
-- ["mailboxes"] = "VERY_LOW",
-- ["mathematics"] = "NONE"
-- }
-- Configure the settings of individual tools. Keys are "<tool ID>.<field name>", values are
@@ -1564,7 +1568,7 @@ CONFIG["CHAT_TEMPLATES"] = {}
-- -- organization switched off. A tool has to meet the confidence requirements of the
-- -- provider in use, so it may stay unavailable even though this template names it.
-- -- Tool IDs include: web_search, read_web_page, search_confluence
-- -- Collection IDs include: mailboxes
-- -- Collection IDs include: mailboxes, mathematics
-- -- Selecting search_confluence also selects read_web_page. semantic_search cannot be
-- -- selected here: it offers itself whenever the chat has data sources to search.
-- ["ToolIds"] = {
@@ -1734,7 +1738,7 @@ CONFIG["DOCUMENT_ANALYSIS_POLICIES"] = {}
-- -- A listed tool must still meet the confidence requirements of the provider in
-- -- use, so a tool may stay unavailable even though this policy permits it.
-- -- Tool IDs include: web_search, read_web_page, search_confluence
-- -- Collection IDs include: mailboxes
-- -- Collection IDs include: mailboxes, mathematics
-- -- Allowing search_confluence also allows read_web_page.
-- ["AllowedToolIds"] = { "web_search" },
--
@@ -11547,6 +11547,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1513592659"] = "Kopiert das zul
-- Waiting for the configuration plugin...
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1533382393"] = "Warten auf das Konfigurations-Plugin …"
-- MailKit connects AI Studio to the mail servers of your mailboxes over IMAP. This is how AI Studio keeps a local index of your mails, which the AI can search, read, and count.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1538080625"] = "MailKit verbindet AI Studio über IMAP mit den Mailservern Ihrer Postfächer. So führt AI Studio einen lokalen Index Ihrer E-Mails, in dem die KI suchen, lesen und zählen kann."
-- Encryption secret: is not configured
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1560776885"] = "Geheimnis für die Verschlüsselung: ist nicht konfiguriert"
@@ -11679,6 +11682,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2371107659"] = "Installation vom
-- Installed Pandoc version: Pandoc is not installed or not available.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2374031539"] = "Installierte Pandoc-Version: Pandoc ist nicht installiert oder nicht verfügbar."
-- The Slide Assistant uses the Open XML SDK to read your PowerPoint templates and to write the presentations it plans with you as PowerPoint files.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2377050296"] = "Der Folien-Assistent verwendet das Open XML SDK, um Ihre PowerPoint-Vorlagen zu lesen und die Präsentationen, die er gemeinsam mit Ihnen plant, als PowerPoint-Dateien zu speichern."
-- current installation location does not support automatic updates
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2401198677"] = "Der aktuelle Installationsort unterstützt keine automatischen Updates"
@@ -11760,6 +11766,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T285730904"] = "verwaltet; Update
-- The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2868174483"] = "Das .NET-Backend kann nicht als Desktop-App gestartet werden. Deshalb verwende ich ein zweites Backend in Rust, das ich „Runtime“ nenne. Mit Rust als Runtime kann Tauri genutzt werden, um eine typische Desktop-App zu realisieren. Dank Rust kann diese App für Windows-, macOS- und Linux-Desktops angeboten werden. Rust ist eine großartige Sprache für die Entwicklung sicherer und leistungsstarker Software."
-- The Mathematics tools use AngouriMath to calculate exactly and to work with formulas: it keeps fractions exact, simplifies, differentiates, and integrates expressions, and solves equations, all locally on your computer.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2906898949"] = "Die Mathematik-Werkzeuge verwenden AngouriMath, um exakt zu rechnen und mit Formeln zu arbeiten: Die Bibliothek hält Brüche exakt, vereinfacht, differenziert und integriert Ausdrücke und löst Gleichungen, alles lokal auf Ihrem Rechner."
-- AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are not yet available.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2924964415"] = "AI Studio wird mit Unternehmenskonfigurationen und Konfigurationsservern betrieben. Die Konfigurations-Plugins sind noch nicht verfügbar."
@@ -11946,6 +11955,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4079152443"] = "Diese Bibliothek
-- Ropus provides the Opus encoder and decoder used by the media pipeline.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4113556626"] = "Ropus stellt den Opus-Encoder und -Decoder bereit, die von der Medienpipeline verwendet werden."
-- MimeKit reads the mails in your mailboxes, including their senders, recipients, and attachments, so that AI Studio can add them to the local index of your mailboxes.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4139338110"] = "MimeKit liest die E-Mails in Ihren Postfächern einschließlich ihrer Absender, Empfänger und Anhänge aus, damit AI Studio sie dem lokalen Index Ihrer Postfächer hinzufügen kann."
-- Community & Code
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4158546761"] = "Community & Code"
@@ -12843,6 +12855,9 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T4678
-- Assistant Builder: Generate and install assistant plugins
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T610184927"] = "Assistenten-Builder: Assistenten-Plugins generieren und installieren"
-- Mathematics: Let the AI calculate exactly and work with formulas, right on your computer
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T842000463"] = "Mathematik: Die KI exakt rechnen und mit Formeln arbeiten lassen, direkt auf Ihrem Rechner"
-- Use no data sources, when sending an assistant result to a chat
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::SENDTOCHATDATASOURCEBEHAVIOREXTENSIONS::T1223925477"] = "Keine Datenquellen vorauswählen, wenn ein Ergebnis von einem Assistenten an einen neuen Chat gesendet wird"
@@ -16500,6 +16515,24 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS:
-- Lets the AI search your mailboxes, list mails by sender, date, or flags, and quote what they say.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::MAILBOXES::SEARCHMAILSTOOL::T553285901"] = "Ermöglicht der KI, Ihre Postfächer zu durchsuchen, E-Mails nach Absender, Datum oder Status wie ungelesen oder gekennzeichnet aufzulisten und daraus zu zitieren."
-- Lets the AI calculate exactly instead of guessing digits, e.g., fractions, roots, or integrals, and check an equation. Everything is computed on your computer.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::MATHEMATICS::CALCULATETOOL::T1801415227"] = "Ermöglicht der KI, etwa Brüche, Wurzeln oder Integrale exakt zu berechnen, statt Ziffern zu raten, und Gleichungen zu prüfen. Alle Berechnungen laufen auf Ihrem Rechner."
-- Calculate
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::MATHEMATICS::CALCULATETOOL::T3960340175"] = "Berechnen"
-- Lets the AI work with formulas: simplify and factor them, differentiate and integrate them, take limits, and solve equations. Every result is checked numerically where possible, right on your computer.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::MATHEMATICS::COMPUTESYMBOLICALLYTOOL::T1552482768"] = "Ermöglicht der KI, mit Formeln zu arbeiten: sie zu vereinfachen und zu faktorisieren, abzuleiten und zu integrieren, Grenzwerte zu berechnen und Gleichungen zu lösen. Jedes Ergebnis wird, wo möglich, numerisch geprüft, direkt auf Ihrem Rechner."
-- Compute Symbolically
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::MATHEMATICS::COMPUTESYMBOLICALLYTOOL::T3797998120"] = "Symbolisch berechnen"
-- Mathematics
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::MATHEMATICS::MATHEMATICSTOOLCOLLECTION::T3403411261"] = "Mathematik"
-- Lets the AI calculate exactly and work with formulas, such as solving equations or checking an integral, right on your computer.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::MATHEMATICS::MATHEMATICSTOOLCOLLECTION::T3783381480"] = "Ermöglicht der KI, exakt zu rechnen und mit Formeln zu arbeiten, etwa Gleichungen zu lösen oder ein Integral zu prüfen, direkt auf Ihrem Rechner."
-- This chat holds internal content, such as e-mails, wiki pages, or internal web pages, so it may only read pages of the wiki and of the allowed private hosts configured in AI Studio. The requested address is not one of them. A new chat can read other web pages again.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::READWEBPAGETOOL::T1012489816"] = "Dieser Chat enthält interne Inhalte, etwa E-Mails, Wiki-Seiten oder interne Webseiten, und darf deshalb nur Seiten des Wikis und der zulässigen privaten Hosts lesen, die in AI Studio konfiguriert sind. Die angeforderte Adresse gehört nicht dazu. Ein neuer Chat kann wieder andere Webseiten lesen."
@@ -11547,6 +11547,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1513592659"] = "Copies the allow
-- Waiting for the configuration plugin...
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1533382393"] = "Waiting for the configuration plugin..."
-- MailKit connects AI Studio to the mail servers of your mailboxes over IMAP. This is how AI Studio keeps a local index of your mails, which the AI can search, read, and count.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1538080625"] = "MailKit connects AI Studio to the mail servers of your mailboxes over IMAP. This is how AI Studio keeps a local index of your mails, which the AI can search, read, and count."
-- Encryption secret: is not configured
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1560776885"] = "Encryption secret: is not configured"
@@ -11679,6 +11682,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2371107659"] = "installation pro
-- Installed Pandoc version: Pandoc is not installed or not available.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2374031539"] = "Installed Pandoc version: Pandoc is not installed or not available."
-- The Slide Assistant uses the Open XML SDK to read your PowerPoint templates and to write the presentations it plans with you as PowerPoint files.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2377050296"] = "The Slide Assistant uses the Open XML SDK to read your PowerPoint templates and to write the presentations it plans with you as PowerPoint files."
-- current installation location does not support automatic updates
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2401198677"] = "current installation location does not support automatic updates"
@@ -11760,6 +11766,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T285730904"] = "managed; updates
-- The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2868174483"] = "The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software."
-- The Mathematics tools use AngouriMath to calculate exactly and to work with formulas: it keeps fractions exact, simplifies, differentiates, and integrates expressions, and solves equations, all locally on your computer.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2906898949"] = "The Mathematics tools use AngouriMath to calculate exactly and to work with formulas: it keeps fractions exact, simplifies, differentiates, and integrates expressions, and solves equations, all locally on your computer."
-- AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are not yet available.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2924964415"] = "AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are not yet available."
@@ -11946,6 +11955,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4079152443"] = "This library is
-- Ropus provides the Opus encoder and decoder used by the media pipeline.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4113556626"] = "Ropus provides the Opus encoder and decoder used by the media pipeline."
-- MimeKit reads the mails in your mailboxes, including their senders, recipients, and attachments, so that AI Studio can add them to the local index of your mailboxes.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4139338110"] = "MimeKit reads the mails in your mailboxes, including their senders, recipients, and attachments, so that AI Studio can add them to the local index of your mailboxes."
-- Community & Code
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4158546761"] = "Community & Code"
@@ -12843,6 +12855,9 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T4678
-- Assistant Builder: Generate and install assistant plugins
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T610184927"] = "Assistant Builder: Generate and install assistant plugins"
-- Mathematics: Let the AI calculate exactly and work with formulas, right on your computer
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T842000463"] = "Mathematics: Let the AI calculate exactly and work with formulas, right on your computer"
-- Use no data sources, when sending an assistant result to a chat
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::SENDTOCHATDATASOURCEBEHAVIOREXTENSIONS::T1223925477"] = "Use no data sources, when sending an assistant result to a chat"
@@ -16500,6 +16515,24 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS:
-- Lets the AI search your mailboxes, list mails by sender, date, or flags, and quote what they say.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::MAILBOXES::SEARCHMAILSTOOL::T553285901"] = "Lets the AI search your mailboxes, list mails by sender, date, or flags, and quote what they say."
-- Lets the AI calculate exactly instead of guessing digits, e.g., fractions, roots, or integrals, and check an equation. Everything is computed on your computer.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::MATHEMATICS::CALCULATETOOL::T1801415227"] = "Lets the AI calculate exactly instead of guessing digits, e.g., fractions, roots, or integrals, and check an equation. Everything is computed on your computer."
-- Calculate
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::MATHEMATICS::CALCULATETOOL::T3960340175"] = "Calculate"
-- Lets the AI work with formulas: simplify and factor them, differentiate and integrate them, take limits, and solve equations. Every result is checked numerically where possible, right on your computer.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::MATHEMATICS::COMPUTESYMBOLICALLYTOOL::T1552482768"] = "Lets the AI work with formulas: simplify and factor them, differentiate and integrate them, take limits, and solve equations. Every result is checked numerically where possible, right on your computer."
-- Compute Symbolically
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::MATHEMATICS::COMPUTESYMBOLICALLYTOOL::T3797998120"] = "Compute Symbolically"
-- Mathematics
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::MATHEMATICS::MATHEMATICSTOOLCOLLECTION::T3403411261"] = "Mathematics"
-- Lets the AI calculate exactly and work with formulas, such as solving equations or checking an integral, right on your computer.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::MATHEMATICS::MATHEMATICSTOOLCOLLECTION::T3783381480"] = "Lets the AI calculate exactly and work with formulas, such as solving equations or checking an integral, right on your computer."
-- This chat holds internal content, such as e-mails, wiki pages, or internal web pages, so it may only read pages of the wiki and of the allowed private hosts configured in AI Studio. The requested address is not one of them. A new chat can read other web pages again.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::READWEBPAGETOOL::T1012489816"] = "This chat holds internal content, such as e-mails, wiki pages, or internal web pages, so it may only read pages of the wiki and of the allowed private hosts configured in AI Studio. The requested address is not one of them. A new chat can read other web pages again."
+6
View File
@@ -4,6 +4,7 @@ using AIStudio.Assistants.VisualBriefing;
using AIStudio.Settings;
using AIStudio.Tools.ToolCallingSystem;
using AIStudio.Tools.Databases;
using AIStudio.Tools.Mathematics;
using AIStudio.Tools.AIJobs;
using AIStudio.Tools.AssistantLaunch;
using AIStudio.Tools.AssistantSessions;
@@ -16,6 +17,7 @@ using AIStudio.Tools.Services;
using AIStudio.Tools.ToolCallingSystem.Harness;
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations;
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mailboxes;
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mathematics;
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.SemanticSearch;
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch;
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch.SearXNG;
@@ -206,6 +208,10 @@ internal sealed class Program
builder.Services.AddSingleton<IToolImplementation, ReadMailTool>();
builder.Services.AddSingleton<IToolImplementation, CountMailsTool>();
builder.Services.AddSingleton<IToolCollection, MailboxToolCollection>();
builder.Services.AddSingleton<MathComputationService>();
builder.Services.AddSingleton<IToolImplementation, CalculateTool>();
builder.Services.AddSingleton<IToolImplementation, ComputeSymbolicallyTool>();
builder.Services.AddSingleton<IToolCollection, MathematicsToolCollection>();
builder.Services.AddSingleton<IToolDefinitionSource, CodeToolDefinitionSource>();
builder.Services.AddSingleton<ToolRegistry>();
builder.Services.AddSingleton<ToolExecutor>();
@@ -18,4 +18,5 @@ public enum PreviewFeatures
PRE_META_ASSISTANT_V1,
PRE_VISUAL_BRIEFING_ASSISTANT_2026,
PRE_MAILBOXES_2026,
PRE_MATHEMATICS_2026,
}
@@ -21,6 +21,7 @@ public static class PreviewFeaturesExtensions
PreviewFeatures.PRE_META_ASSISTANT_V1 => TB("Assistant Builder: Generate and install assistant plugins"),
PreviewFeatures.PRE_VISUAL_BRIEFING_ASSISTANT_2026 => TB("Visual Briefing Assistant: Turn source material into an interactive briefing"),
PreviewFeatures.PRE_MAILBOXES_2026 => TB("Mailboxes: Let the AI search and read your e-mails, kept in a local index on your computer"),
PreviewFeatures.PRE_MATHEMATICS_2026 => TB("Mathematics: Let the AI calculate exactly and work with formulas, right on your computer"),
_ => TB("Unknown preview feature")
};
@@ -26,6 +26,7 @@ public static class PreviewVisibilityExtensions
if (visibility >= PreviewVisibility.PROTOTYPE)
{
features.Add(PreviewFeatures.PRE_MAILBOXES_2026);
features.Add(PreviewFeatures.PRE_MATHEMATICS_2026);
}
if (visibility >= PreviewVisibility.EXPERIMENTAL)
@@ -0,0 +1,260 @@
namespace AIStudio.Tools.Mathematics;
/// <summary>
/// Integrates a function numerically by double exponential quadrature, also over infinite intervals.
/// </summary>
/// <remarks>
/// A substitution moves the points of a trapezoidal sum doubly exponentially close to the bounds of
/// the interval, or out to infinity: tanh-sinh on [a, b], exp-sinh on [a, oo) and (-oo, b], and
/// sinh-sinh on the whole line. Integrands which are analytic inside the interval converge to about
/// fourteen digits within a few hundred points, singularities at the bounds included, such as ln(x) or
/// 1/sqrt(x) at 0. The points never lie on a bound, where an integrand such as x^3/(e^x - 1) has no
/// value.<br/><br/>
/// Each level halves the step, and the difference between two levels estimates the error. The terms
/// left out at the ends add to it whenever the sum had to stop before they became negligible: at the
/// largest parameter, at a bound which a double cannot tell apart from its neighbors, or where the
/// integrand has no value. That is what keeps an integral like sin(x)/x over [0, oo), which the
/// substitution cannot settle, from converging by chance. A singularity at a bound other than 0 is
/// reached only to about seven digits, since a double near such a bound carries few digits of its
/// distance to it; the estimated error says so.<br/><br/>
/// The integrand is a double function, compiled by AngouriMath, so the result carries at most the
/// digits of a double.
/// </remarks>
public static class DoubleExponentialQuadrature
{
/// <summary>
/// The finest step is 2^-MAX_LEVEL.
/// </summary>
private const int MAX_LEVEL = 10;
/// <summary>
/// The coarsest step which may end the integration: a coarser sum can match the next one by chance.
/// </summary>
private const int MIN_CONVERGED_LEVEL = 3;
/// <summary>
/// Two levels which differ by less than this, relative to the integral, end the refinement.
/// </summary>
private const double RELATIVE_TOLERANCE = 1e-13;
/// <summary>
/// The largest estimated relative error of a converged result: six digits at least.
/// </summary>
private const double ACCEPTABLE_RELATIVE_ERROR = 1e-6;
/// <summary>
/// A term this much smaller than the sum so far no longer counts.
/// </summary>
private const double NEGLIGIBLE = 1e-18;
/// <summary>
/// How many negligible terms in a row end one side of a sum.
/// </summary>
private const int NEGLIGIBLE_IN_A_ROW = 3;
/// <summary>
/// The largest parameter on a finite interval: there, the points lie within 1e-37 of the bounds.
/// </summary>
private const double FINITE_MAX_PARAMETER = 4.0;
/// <summary>
/// The largest parameter towards infinity: there, the points lie beyond 1e50.
/// </summary>
private const double INFINITE_MAX_PARAMETER = 5.0;
/// <summary>
/// How much more the terms left out at an end may add up to than estimated. The last terms before
/// a bound are also the least accurate ones: 1 - x carries few digits for an x close to 1, which a
/// singularity such as 1/sqrt(1 - x) magnifies.
/// </summary>
private const double TAIL_SAFETY_FACTOR = 10;
private const int EVALUATIONS_PER_CANCELLATION_CHECK = 256;
private const double HALF_PI = Math.PI / 2;
/// <summary>
/// Integrates a function over an interval.
/// </summary>
/// <param name="integrand">The function, which returns NaN or an infinity where it has no value.</param>
/// <param name="lower">The lower bound, which may be negative infinity.</param>
/// <param name="upper">The upper bound, which may be positive infinity; below the lower bound, the integral changes its sign.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The result, with its estimated error and how the integration ended.</returns>
public static QuadratureResult Integrate(Func<double, double> integrand, double lower, double upper, CancellationToken token = default)
{
if (double.IsNaN(lower) || double.IsNaN(upper))
throw new ArgumentException("A bound of the integral is not a number.");
if (lower.Equals(upper))
return new(0, 0, QuadratureStatus.CONVERGED, null, 0);
if (lower > upper)
{
var reversed = Integrate(integrand, upper, lower, token);
return reversed with { Value = -reversed.Value };
}
var interval = new Interval(lower, upper);
var evaluations = 0;
var previous = double.NaN;
LevelSum sum = default;
for (var level = 0; level <= MAX_LEVEL; level++)
{
sum = Sum(integrand, interval, Math.ScaleB(1.0, -level), ref evaluations, token);
if (sum.NoValueAt is { } point)
return new(double.NaN, double.PositiveInfinity, QuadratureStatus.NO_VALUE, point, evaluations);
var difference = Math.Abs(sum.Integral - previous);
var scale = Math.Max(Math.Abs(sum.Integral), sum.AbsoluteIntegral);
var settled = level >= MIN_CONVERGED_LEVEL && difference <= RELATIVE_TOLERANCE * scale;
if (settled || level == MAX_LEVEL)
return Result(sum, difference + sum.TailError, scale, evaluations);
previous = sum.Integral;
}
return Result(sum, double.PositiveInfinity, 0, evaluations);
}
private static QuadratureResult Result(LevelSum sum, double error, double scale, int evaluations)
{
if (!double.IsFinite(sum.Integral) || double.IsNaN(error))
return new(sum.Integral, double.PositiveInfinity, QuadratureStatus.NOT_CONVERGED, null, evaluations);
var status = error <= ACCEPTABLE_RELATIVE_ERROR * scale ? QuadratureStatus.CONVERGED : QuadratureStatus.NOT_CONVERGED;
return new(sum.Integral, error, status, null, evaluations);
}
/// <summary>
/// The trapezoidal sum of one level, from the middle of the parameter range outwards on both sides.
/// </summary>
private static LevelSum Sum(Func<double, double> integrand, Interval interval, double step, ref int evaluations, CancellationToken token)
{
var center = interval.NodeAt(0);
var centerValue = integrand(center.X);
evaluations++;
if (!double.IsFinite(centerValue))
return new(0, 0, 0, center.X);
var sum = center.Weight * centerValue;
var absoluteSum = Math.Abs(sum);
var tail = 0.0;
foreach (var direction in (ReadOnlySpan<int>)[1, -1])
{
// Unknown before the first term of this side, so a missing value there is never negligible:
var lastTerm = double.PositiveInfinity;
var lastParameter = 0.0;
var negligibleInARow = 0;
for (var index = 1; ; index++)
{
var parameter = direction * index * step;
if (Math.Abs(parameter) > interval.MaxParameter)
{
tail += Tail(lastTerm, lastParameter, step);
break;
}
var node = interval.NodeAt(parameter);
if (node.AtBound)
{
tail += Tail(lastTerm, lastParameter, step);
break;
}
if (++evaluations % EVALUATIONS_PER_CANCELLATION_CHECK == 0)
token.ThrowIfCancellationRequested();
var value = integrand(node.X);
if (!double.IsFinite(value))
{
// Near a bound, an integrand such as x^3/(e^x - 1) loses its value to rounding, where its terms no longer count:
if (Math.Abs(lastTerm) <= NEGLIGIBLE * Math.Abs(sum))
{
tail += Tail(lastTerm, lastParameter, step);
break;
}
return new(0, 0, 0, node.X);
}
var term = node.Weight * value;
sum += term;
absoluteSum += Math.Abs(term);
lastTerm = term;
lastParameter = parameter;
if (Math.Abs(term) <= NEGLIGIBLE * Math.Abs(sum) && sum != 0)
{
if (++negligibleInARow >= NEGLIGIBLE_IN_A_ROW)
break;
}
else
negligibleInARow = 0;
}
}
return new(step * sum, step * tail, step * absoluteSum, null);
}
/// <summary>
/// What the terms beyond the last one of a side may add up to, before the sum is multiplied by the
/// step: as much as the last term, or the integral of terms which decay from it at the pace of the
/// substitution, whichever is more, times TAIL_SAFETY_FACTOR.
/// </summary>
private static double Tail(double lastTerm, double lastParameter, double step) => TAIL_SAFETY_FACTOR * Math.Abs(lastTerm) * Math.Max(1, 1 / (HALF_PI * Math.Cosh(lastParameter) * step));
/// <summary>
/// The sum of one level: the integral, the estimated error of what its sides left out, and the
/// integral of the magnitude, which scales the tolerance of an integral close to zero.
/// </summary>
private readonly record struct LevelSum(double Integral, double TailError, double AbsoluteIntegral, double? NoValueAt);
/// <summary>
/// A point of the sum and its weight; at a bound when a double cannot tell the point from the bound.
/// </summary>
private readonly record struct QuadratureNode(double X, double Weight, bool AtBound);
/// <summary>
/// The substitution which fits the bounds.
/// </summary>
private readonly record struct Interval(double Lower, double Upper)
{
public double MaxParameter => double.IsFinite(this.Lower) && double.IsFinite(this.Upper) ? FINITE_MAX_PARAMETER : INFINITE_MAX_PARAMETER;
public QuadratureNode NodeAt(double parameter)
{
var inner = HALF_PI * Math.Sinh(parameter);
var derivative = HALF_PI * Math.Cosh(parameter);
if (double.IsFinite(this.Lower) && double.IsFinite(this.Upper))
{
// tanh-sinh, with the distance to the nearer bound computed directly: 1 - tanh(u) = 2 / (e^(2u) + 1).
var halfWidth = (this.Upper - this.Lower) / 2;
var distance = halfWidth * 2 / (Math.Exp(2 * Math.Abs(inner)) + 1);
var x = parameter >= 0 ? this.Upper - distance : this.Lower + distance;
var secant = 2 / (Math.Exp(inner) + Math.Exp(-inner));
var atBound = distance == 0 || x.Equals(parameter >= 0 ? this.Upper : this.Lower);
return new(x, halfWidth * derivative * secant * secant, atBound);
}
if (double.IsFinite(this.Lower))
{
// exp-sinh towards positive infinity:
var offset = Math.Exp(inner);
var x = this.Lower + offset;
return new(x, derivative * offset, !double.IsFinite(x) || x.Equals(this.Lower));
}
if (double.IsFinite(this.Upper))
{
// exp-sinh towards negative infinity, mirrored:
var offset = Math.Exp(inner);
var x = this.Upper - offset;
return new(x, derivative * offset, !double.IsFinite(x) || x.Equals(this.Upper));
}
// sinh-sinh over the whole line:
var point = Math.Sinh(inner);
return new(point, derivative * Math.Cosh(inner), !double.IsFinite(point));
}
}
}
@@ -0,0 +1,12 @@
namespace AIStudio.Tools.Mathematics;
/// <summary>
/// A computation which the Mathematics tools refused or could not finish.
/// </summary>
/// <remarks>
/// The message goes to the model as the result of the tool, in English, like the other messages
/// about the arguments of a tool. It states what happened and, where the input was the cause, how
/// AngouriMath reads the input and how it is written instead.
/// </remarks>
/// <param name="message">What happened.</param>
public sealed class MathComputationException(string message) : Exception(message);
@@ -0,0 +1,134 @@
using System.Globalization;
using AIStudio.Chat;
using AngouriMath;
using AngouriMath.Core.Exceptions;
namespace AIStudio.Tools.Mathematics;
/// <summary>
/// Runs the computations of the Mathematics tools, one at a time and within a time budget.
/// </summary>
/// <remarks>
/// AngouriMath parses and simplifies recursively. About 500 nested parentheses overflow the stack of
/// a thread pool thread, which ends the whole process, so every computation runs on a thread of its
/// own with a stack of 64 MB. That holds the deepest nesting an expression within
/// MathLimits.MAX_EXPRESSION_LENGTH can have.<br/><br/>
/// Only one computation runs at a time, because AI Studio also runs on a Raspberry Pi. AngouriMath
/// stops at the cancellation token of its thread wherever it checks it, and that token ends with the
/// budget or with the request. Some computations never check it, such as an exact power with
/// millions of digits; MathGuards refuses those which can be told beforehand. One which still runs
/// past its budget keeps the slot until it ends: the call gives up and says so, and the next
/// computations wait for the slot instead of taking another core.<br/><br/>
/// The settings of AngouriMath belong to the flow which sets them. Each computation reads
/// expressions explicitly and solves without Newton's method, see AngouriMathBehaviorTests.
/// </remarks>
/// <param name="logger">The logger.</param>
public sealed class MathComputationService(ILogger<MathComputationService> logger)
{
private const int STACK_SIZE_BYTES = 64 * 1024 * 1024;
private const int MAX_MESSAGE_LENGTH = 400;
/// <summary>
/// How long a computation may take, and how long it waits for the slot before that.
/// </summary>
public static readonly TimeSpan DEFAULT_TIME_BUDGET = TimeSpan.FromSeconds(10);
/// <summary>
/// How long a call waits beyond the budget for a computation to stop at its cancellation token.
/// </summary>
private static readonly TimeSpan STOP_GRACE = TimeSpan.FromSeconds(1);
private readonly SemaphoreSlim slot = new(1, 1);
/// <summary>
/// How long a computation may take. Tests shorten it.
/// </summary>
internal TimeSpan TimeBudget { get; init; } = DEFAULT_TIME_BUDGET;
/// <summary>
/// Runs a computation on a thread of its own, once the slot is free.
/// </summary>
/// <remarks>
/// Parse the expressions inside the computation, too: the parser needs the stack of its thread.
/// An exception of AngouriMath comes back as a MathComputationException with its message.
/// </remarks>
/// <param name="computation">The computation, which gets the token of its budget.</param>
/// <param name="token">The cancellation token of the request.</param>
/// <typeparam name="T">The type of the result.</typeparam>
/// <returns>The result of the computation.</returns>
/// <exception cref="MathComputationException">The computation was refused, failed, or did not finish within its budget.</exception>
public async Task<T> ComputeAsync<T>(Func<CancellationToken, T> computation, CancellationToken token = default)
{
if (!await this.slot.WaitAsync(this.TimeBudget, token))
throw new MathComputationException($"Another computation is still running, so this one could not start within {this.BudgetInSeconds} seconds.");
// The thread disposes the budget and frees the slot once its computation ended. Should the thread not
// even start, the slot is freed below, and the budget is left to the garbage collector:
var budget = CancellationTokenSource.CreateLinkedTokenSource(token);
var completion = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
var thread = new Thread(() => this.Run(computation, budget, completion, token), STACK_SIZE_BYTES)
{
IsBackground = true,
Name = "Mathematics",
};
try
{
budget.CancelAfter(this.TimeBudget);
thread.Start();
}
catch
{
this.slot.Release();
throw;
}
try
{
return await completion.Task.WaitAsync(this.TimeBudget + STOP_GRACE, token);
}
catch (TimeoutException)
{
logger.LogWarning("A computation of the Mathematics tools ran past its time budget of {Seconds} seconds without stopping. It keeps the computing slot until it ends.", this.BudgetInSeconds);
throw new MathComputationException($"The computation did not finish within {this.BudgetInSeconds} seconds and could not be stopped. It keeps running until it ends, and other computations wait for it meanwhile.");
}
}
private string BudgetInSeconds => this.TimeBudget.TotalSeconds.ToString(CultureInfo.InvariantCulture);
private void Run<T>(Func<CancellationToken, T> computation, CancellationTokenSource budget, TaskCompletionSource<T> completion, CancellationToken token)
{
try
{
MathS.Multithreading.SetLocalCancellationToken(budget.Token);
using var explicitParsing = MathS.Settings.ExplicitParsingOnly.Set(true);
using var withoutNewton = MathS.Settings.AllowNewton.Set(false);
completion.TrySetResult(computation(budget.Token));
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
completion.TrySetCanceled(token);
}
catch (OperationCanceledException) when (budget.IsCancellationRequested)
{
completion.TrySetException(new MathComputationException($"The computation did not finish within {this.BudgetInSeconds} seconds."));
}
catch (AngouriMathBaseException exception)
{
logger.LogInformation("AngouriMath could not compute an expression: {Message}", exception.Message);
completion.TrySetException(new MathComputationException($"The tool could not compute this:{exception.Message.Shorten(MAX_MESSAGE_LENGTH)}"));
}
catch (Exception exception)
{
completion.TrySetException(exception);
}
finally
{
budget.Dispose();
this.slot.Release();
}
}
}
@@ -0,0 +1,165 @@
using System.Globalization;
using System.Text.RegularExpressions;
using AIStudio.Chat;
using AngouriMath;
using AngouriMath.Core.Exceptions;
using PeterO.Numbers;
namespace AIStudio.Tools.Mathematics;
/// <summary>
/// Reads an expression of the model into AngouriMath, strictly.
/// </summary>
/// <remarks>
/// AngouriMath reads some spellings as something else than they say, silently, see
/// AngouriMathBehaviorTests: without explicit parsing, x2 is x^2 and foo(3) is foo times 3, and
/// even with it, a != b is the factorial of a equated to b. This reader refuses such spellings
/// instead of letting a model compute what it did not mean, and states how AngouriMath writes the
/// thing instead. Its parse errors list over a thousand characters of expected tokens; the reader
/// keeps the column and the cause.<br/><br/>
/// Call it inside a computation of MathComputationService: the parser needs the stack of its thread.
/// </remarks>
public static partial class MathExpressionReader
{
private const int MAX_REASON_LENGTH = 200;
/// <summary>
/// Reads an expression, or refuses it.
/// </summary>
/// <param name="text">The expression as the model wrote it.</param>
/// <param name="argumentName">The argument the expression came in, named in every message.</param>
/// <returns>The expression as AngouriMath reads it.</returns>
/// <exception cref="MathComputationException">The expression is empty, too long, uses a spelling AngouriMath reads otherwise, or cannot be read.</exception>
public static Entity Read(string text, string argumentName)
{
if (string.IsNullOrWhiteSpace(text))
throw new MathComputationException($"Argument '{argumentName}' is empty.");
if (text.Length > MathLimits.MAX_EXPRESSION_LENGTH)
throw new MathComputationException(string.Create(CultureInfo.InvariantCulture, $"Argument '{argumentName}' has {text.Length:N0} characters, but at most {MathLimits.MAX_EXPRESSION_LENGTH:N0} are read."));
if (text.Contains("!=", StringComparison.Ordinal))
throw new MathComputationException($"Argument '{argumentName}' contains '!=', which the tool reads as a factorial followed by '='. Not-equal is written '<>'.");
if (text.Contains('|'))
throw new MathComputationException($"Argument '{argumentName}' contains '|', which the tool reads as divisibility. The absolute value is written abs(x), and 'or' joins two statements.");
var equalsSigns = CountEqualsSigns(text);
if (equalsSigns > 1)
throw new MathComputationException($"Argument '{argumentName}' contains {equalsSigns} equals signs, but an expression holds one equation at most. The tool would read a chain of them as several equations joined by 'and'.");
var expression = Parse(text, argumentName);
if (expression.Vars.Any(variable => variable.Name == "oo"))
throw new MathComputationException($"Argument '{argumentName}' uses 'oo', which the tool reads as a variable. Infinity is written +oo or -oo.");
return expression;
}
private static Entity Parse(string text, string argumentName)
{
try
{
using var explicitParsing = MathS.Settings.ExplicitParsingOnly.Set(true);
Entity parsed;
// Without the cache, an expression parsed elsewhere with other settings cannot come back here:
using (MathS.Settings.DowncastingEnabled.Set(false))
parsed = MathS.FromString(text, useCache: false);
return WithExactLiterals(parsed);
}
catch (MissingOperatorParseException exception)
{
throw new MathComputationException(DescribeMissingOperator(exception.Message, argumentName));
}
catch (FunctionArgumentCountException exception)
{
throw new MathComputationException($"Argument '{argumentName}' cannot be read: {exception.Message}.");
}
catch (ParseException exception)
{
throw new MathComputationException(DescribeParseError(exception.Message, argumentName));
}
}
/// <summary>
/// Turns every decimal literal into the fraction it writes, e.g., 1.5e-3 into 3/2000.
/// </summary>
/// <remarks>
/// AngouriMath downcasts while it parses: it reads 0.1 as 1/10, but 6.62607015e-34 as an inexact
/// decimal and 1e-60 as 0, see AngouriMathBehaviorTests. Simplifying then drops an inexact decimal
/// from a sum, so 1 + 1e-40 would be exactly 1. The reader therefore parses without downcasting,
/// which leaves every number a decimal, and turns each into its fraction or integer. A literal
/// with an exponent beyond MathLimits.MAX_EXACT_DIGITS stays a decimal.
/// </remarks>
private static Entity WithExactLiterals(Entity expression) => expression.Replace(node => node is Entity.Number.Real literal and not Entity.Number.Rational ? ExactValueOf(literal) : node);
private static Entity ExactValueOf(Entity.Number.Real literal)
{
var value = literal.EDecimal;
if (value.IsNaN() || value.IsInfinity() || value.Exponent.Abs().CompareTo(EInteger.FromInt32(MathLimits.MAX_EXACT_DIGITS)) > 0)
return literal;
var fraction = ERational.FromEDecimal(value);
return fraction.Denominator.Equals(EInteger.One) ? Entity.Number.Integer.Create(fraction.Numerator) : Entity.Number.Rational.Create(fraction.Numerator, fraction.Denominator);
}
private static string DescribeMissingOperator(string message, string argumentName)
{
var match = MissingOperatorTokens().Match(message);
if (!match.Success)
return $"Argument '{argumentName}' lacks an operator between two operands. Multiplication is written '*', as in 2*x.";
var left = match.Groups["left"].Value;
var right = match.Groups["right"].Value;
var column = int.Parse(match.Groups["column"].Value, CultureInfo.InvariantCulture) + 1;
if (right == "(" && IsName(left))
return $"Argument '{argumentName}' has no operator between '{left}' and '(' at column {column}. The tool knows no function named '{left}', so it would read the name as a variable times the parentheses. Multiplication is written '*'.";
return $"Argument '{argumentName}' has no operator between '{left}' and '{right}' at column {column}. Multiplication is written '*', as in 2*x, and a function takes its argument in parentheses, as in sin(x).";
}
private static string DescribeParseError(string message, string argumentName)
{
var match = ParseErrorPosition().Match(message);
if (!match.Success)
return $"Argument '{argumentName}' cannot be read: {message.Shorten(MAX_REASON_LENGTH)}";
var column = int.Parse(match.Groups["column"].Value, CultureInfo.InvariantCulture) + 1;
var reason = match.Groups["reason"].Value.Shorten(MAX_REASON_LENGTH);
return $"Argument '{argumentName}' cannot be read at column {column}: {reason}.";
}
private static bool IsName(string token) => token.Length > 0 && token.All(character => char.IsLetterOrDigit(character) || character == '_');
/// <summary>
/// Counts the equals signs which stand for an equation, leaving out those of &lt;= and &gt;=.
/// </summary>
private static int CountEqualsSigns(string text)
{
var count = 0;
for (var index = 0; index < text.Length; index++)
{
if (text[index] != '=')
continue;
if (index > 0 && text[index - 1] is '<' or '>')
continue;
count++;
}
return count;
}
// There should be an operator between [@0,0:0='2',<185>,1:0] and [@1,1:1='x',<189>,1:1], while parsing `2x`
[GeneratedRegex(@"='(?<left>.*?)',<\d+>,\d+:\d+\] and \[@\d+,\d+:\d+='(?<right>.*?)',<\d+>,\d+:(?<column>\d+)\]")]
private static partial Regex MissingOperatorTokens();
// line 1:4 extraneous input '*' expecting {'-', '+', ...}, while parsing `2 + * 3`
[GeneratedRegex(@"^line \d+:(?<column>\d+) (?<reason>.*?)(?: expecting .*)?(?:, while parsing `.*`)?$", RegexOptions.Singleline)]
private static partial Regex ParseErrorPosition();
}
@@ -0,0 +1,208 @@
using System.Globalization;
using System.Text;
using AngouriMath;
using PeterO.Numbers;
namespace AIStudio.Tools.Mathematics;
/// <summary>
/// Writes the results of the Mathematics tools for the model: as text, as LaTeX, and as decimals.
/// </summary>
/// <remarks>
/// Every number is written the same way whatever the language of the user, because it goes to a
/// model and comes back in the plain syntax of the tools.
/// </remarks>
public static class MathFormatting
{
/// <summary>
/// The smallest decimal exponent written without scientific notation.
/// </summary>
private const int MIN_PLAIN_EXPONENT = -6;
/// <summary>
/// The largest decimal exponent written without scientific notation.
/// </summary>
private const int MAX_PLAIN_EXPONENT = 20;
/// <summary>
/// An expression in the plain syntax which the tools also read.
/// </summary>
/// <param name="expression">The expression.</param>
/// <param name="what">What the expression is, named when it is too long.</param>
/// <returns>The expression as text.</returns>
/// <exception cref="MathComputationException">The text is longer than MathLimits.MAX_RESULT_LENGTH.</exception>
public static string ToText(Entity expression, string what) => EnsureLength(expression.Stringize(), what);
/// <summary>
/// An expression as LaTeX, which the chat renders.
/// </summary>
/// <param name="expression">The expression.</param>
/// <param name="what">What the expression is, named when it is too long.</param>
/// <returns>The expression as LaTeX.</returns>
/// <exception cref="MathComputationException">The LaTeX is longer than MathLimits.MAX_RESULT_LENGTH.</exception>
public static string ToLatex(Entity expression, string what) => EnsureLength(expression.Latexize(), what);
/// <summary>
/// Refuses a text longer than MathLimits.MAX_RESULT_LENGTH.
/// </summary>
/// <param name="text">The text of a result.</param>
/// <param name="what">What the text is, named when it is too long.</param>
/// <returns>The text.</returns>
/// <exception cref="MathComputationException">The text is too long.</exception>
public static string EnsureLength(string text, string what)
{
if (text.Length > MathLimits.MAX_RESULT_LENGTH)
throw new MathComputationException(string.Create(CultureInfo.InvariantCulture, $"{what} has {text.Length:N0} characters, but at most {MathLimits.MAX_RESULT_LENGTH:N0} are returned."));
return text;
}
/// <summary>
/// A decimal, rounded to the given number of significant digits.
/// </summary>
/// <remarks>
/// Trailing zeros are left out, so 0.5 stays 0.5 at twenty digits; how many digits the value
/// carries is reported next to it. Between 1e-6 and 1e21 the decimal is written out, beyond that
/// in scientific notation such as 6.02214076e+23.
/// </remarks>
/// <param name="value">The value.</param>
/// <param name="digits">How many significant digits to keep.</param>
/// <returns>The decimal.</returns>
public static string FormatDecimal(EDecimal value, int digits)
{
if (value.IsNaN())
return "NaN";
if (value.IsInfinity())
return value.IsNegative ? "-oo" : "+oo";
var rounded = value.RoundToPrecision(EContext.ForPrecisionAndRounding(digits, ERounding.HalfEven)).Reduce(null);
if (rounded.IsZero)
return "0";
var mantissa = rounded.Mantissa.Abs().ToString();
var exponent = rounded.Exponent.ToInt64Checked() + mantissa.Length - 1;
if (exponent is >= MIN_PLAIN_EXPONENT and <= MAX_PLAIN_EXPONENT)
return rounded.ToPlainString();
var scientific = new StringBuilder();
if (rounded.IsNegative)
scientific.Append('-');
scientific.Append(mantissa[0]);
if (mantissa.Length > 1)
scientific.Append('.').Append(mantissa, 1, mantissa.Length - 1);
scientific.Append('e').Append(exponent < 0 ? '-' : '+').Append(Math.Abs(exponent).ToString(CultureInfo.InvariantCulture));
return scientific.ToString();
}
/// <summary>
/// A double, rounded to the given number of significant digits.
/// </summary>
/// <param name="value">The value.</param>
/// <param name="digits">How many significant digits to keep, 17 at most.</param>
/// <returns>The decimal.</returns>
public static string FormatDecimal(double value, int digits)
{
if (double.IsNaN(value))
return "NaN";
if (double.IsInfinity(value))
return value < 0 ? "-oo" : "+oo";
return FormatDecimal(EDecimal.FromDouble(value), Math.Min(digits, 17));
}
/// <summary>
/// A real or complex number, rounded to the given number of significant digits, as in 1.5 - 2i.
/// </summary>
/// <param name="value">The number.</param>
/// <param name="digits">How many significant digits to keep in each part.</param>
/// <returns>The decimal.</returns>
public static string FormatNumber(Entity.Number.Complex value, int digits)
{
var real = value.RealPart.EDecimal;
var imaginary = value.ImaginaryPart.EDecimal;
if (imaginary.IsZero)
return FormatDecimal(real, digits);
var imaginaryText = FormatDecimal(imaginary.Abs(), digits) + "i";
if (real.IsZero)
return imaginary.IsNegative ? "-" + imaginaryText : imaginaryText;
return $"{FormatDecimal(real, digits)} {(imaginary.IsNegative ? '-' : '+')} {imaginaryText}";
}
/// <summary>
/// How far apart two numbers lie, as the sum of the distances of their parts.
/// </summary>
/// <param name="first">One number.</param>
/// <param name="second">The other number.</param>
/// <returns>The distance, as a double.</returns>
public static double Distance(Entity.Number.Complex first, Entity.Number.Complex second)
{
var context = EContext.ForPrecision(20);
var real = first.RealPart.EDecimal.Subtract(second.RealPart.EDecimal, context).Abs();
var imaginary = first.ImaginaryPart.EDecimal.Subtract(second.ImaginaryPart.EDecimal, context).Abs();
return real.Add(imaginary, context).ToDouble();
}
/// <summary>
/// How many significant digits two real or complex values of the same quantity have in common,
/// measured against the larger of them, so a tiny imaginary part does not count for more than it is.
/// </summary>
/// <param name="first">One value.</param>
/// <param name="second">The other value.</param>
/// <param name="maximum">The most digits to report, for values which are equal.</param>
/// <returns>The number of digits, between 0 and maximum.</returns>
public static int CountMatchingDigits(Entity.Number.Complex first, Entity.Number.Complex second, int maximum)
{
if (first.ImaginaryPart.EDecimal.IsZero && second.ImaginaryPart.EDecimal.IsZero)
return CountMatchingDigits(first.RealPart.EDecimal, second.RealPart.EDecimal, maximum);
var context = EContext.ForPrecision(20);
var firstSize = first.RealPart.EDecimal.Abs().Add(first.ImaginaryPart.EDecimal.Abs(), context);
var secondSize = second.RealPart.EDecimal.Abs().Add(second.ImaginaryPart.EDecimal.Abs(), context);
var scale = EDecimal.Max(firstSize, secondSize).ToDouble();
var distance = Distance(first, second);
if (!double.IsFinite(distance) || !double.IsFinite(scale))
return 0;
if (distance <= 0)
return maximum;
return (int)Math.Clamp(Math.Floor(-Math.Log10(distance / scale)), 0, maximum);
}
/// <summary>
/// How many significant digits two values of the same quantity have in common.
/// </summary>
/// <remarks>
/// Counted from their relative difference, as is usual for correct digits: 0.99999 and 1.00001
/// agree to four digits although no digit is the same. Evaluated at two working precisions, the
/// digits they share are the ones the computation carries.
/// </remarks>
/// <param name="first">One value.</param>
/// <param name="second">The other value.</param>
/// <param name="maximum">The most digits to report, for values which are equal.</param>
/// <returns>The number of digits, between 0 and maximum.</returns>
public static int CountMatchingDigits(EDecimal first, EDecimal second, int maximum)
{
if (first.IsNaN() || second.IsNaN() || first.IsInfinity() || second.IsInfinity())
return 0;
if (first.CompareTo(second) == 0)
return maximum;
var context = EContext.ForPrecision(20);
var scale = first.Abs().CompareTo(second.Abs()) >= 0 ? first.Abs() : second.Abs();
var relative = first.Subtract(second, context).Abs().Divide(scale, context).ToDouble();
if (relative <= 0)
return maximum;
return (int)Math.Clamp(Math.Floor(-Math.Log10(relative)), 0, maximum);
}
}
@@ -0,0 +1,160 @@
using System.Globalization;
using AIStudio.Chat;
using AngouriMath;
using PeterO.Numbers;
namespace AIStudio.Tools.Mathematics;
/// <summary>
/// Refuses the computations which AngouriMath cannot stop once they started.
/// </summary>
/// <remarks>
/// AngouriMath computes an exact power or factorial in full, and an expansion term by term, without
/// checking its cancellation token meanwhile: 9^9^9, (10^9)!, and the expansion of (x + y)^500 run
/// on long past any budget, see AngouriMathBehaviorTests. The guards estimate the size of such a
/// result from the expression alone, before anything is computed, and refuse it when it would be
/// larger than any result the tools return anyway.<br/><br/>
/// The estimate works from the inside out, so the inner power of 9^9^9^9 is known before the outer
/// one is judged. Only numbers of known value count: a power of a variable is never computed as a
/// number. Call the guards inside a computation of MathComputationService, since they evaluate the
/// exponents and the arguments of factorials, which have passed the guards before.
/// </remarks>
public static class MathGuards
{
private const int MAX_SHOWN_LENGTH = 120;
/// <summary>
/// Refuses an expression whose exact evaluation would produce a number of more than
/// MathLimits.MAX_EXACT_DIGITS digits.
/// </summary>
/// <param name="expression">The expression to be evaluated or simplified.</param>
/// <exception cref="MathComputationException">A part of the expression would grow too large.</exception>
public static void CheckExactSize(Entity expression) => EstimateDigits(expression);
/// <summary>
/// Refuses an expression whose expansion would take a power of a sum beyond
/// MathLimits.MAX_EXPANSION_EXPONENT.
/// </summary>
/// <param name="expression">The expression to be expanded.</param>
/// <exception cref="MathComputationException">A power of a sum has too large an exponent.</exception>
public static void CheckExpansion(Entity expression)
{
foreach (var node in expression.Nodes)
{
if (node is not Entity.Powf { Base: Entity.Sumf or Entity.Minusf } power)
continue;
var exponent = ValueOf(power.Exponent);
if (exponent is null || Math.Abs(exponent.Value) <= MathLimits.MAX_EXPANSION_EXPONENT)
continue;
throw new MathComputationException(string.Create(CultureInfo.InvariantCulture, $"'{Show(power)}' raises a sum to the power of {exponent.Value:0.###}. Where the tool may expand, a sum is raised to exponents up to {MathLimits.MAX_EXPANSION_EXPONENT} only, since it cannot stop a larger expansion midway."));
}
}
/// <summary>
/// Estimates how many digits the exact value of a node needs, and refuses it beyond the limit.
/// </summary>
/// <remarks>
/// An upper bound for sums, products, and quotients: the digits of numerator and denominator
/// add up. A number which is not exact, such as a decimal of the current precision, needs none.
/// </remarks>
private static double EstimateDigits(Entity node)
{
var digits = node switch
{
Entity.Number.Rational rational => DigitsOf(rational.ERational.Numerator) + DigitsOf(rational.ERational.Denominator),
Entity.Powf power => EstimatePower(power),
Entity.Factorialf factorial => EstimateFactorial(factorial),
_ => node.DirectChildren.Sum(EstimateDigits),
};
if (digits > MathLimits.MAX_EXACT_DIGITS)
throw new MathComputationException(string.Create(CultureInfo.InvariantCulture, $"'{Show(node)}' would have about {digits:N0} digits as an exact number. The tool computes such a number in full and cannot stop midway, so exact numbers are limited to {MathLimits.MAX_EXACT_DIGITS:N0} digits."));
return digits;
}
private static double EstimatePower(Entity.Powf power)
{
var baseDigits = EstimateDigits(power.Base);
var exponentDigits = EstimateDigits(power.Exponent);
if (MathVariables.HasAny(power.Base))
return baseDigits + exponentDigits;
var exponent = ValueOf(power.Exponent);
if (exponent is null)
return baseDigits + exponentDigits;
var magnitude = Math.Abs(exponent.Value);
if (power.Base.Evaled is Entity.Number.Rational rational)
{
var size = Log10(rational.ERational.Numerator) + Log10(rational.ERational.Denominator);
var digits = magnitude * size;
if (digits > MathLimits.MAX_EXACT_DIGITS && rational.ERational.Sign > 0)
throw new MathComputationException(string.Create(CultureInfo.InvariantCulture, $"'{Show(power)}' would have about {digits:N0} digits as an exact number; its decimal logarithm is about {exponent.Value * (Log10(rational.ERational.Numerator) - Log10(rational.ERational.Denominator)):0.###}. The tool computes such a number in full and cannot stop midway, so exact numbers are limited to {MathLimits.MAX_EXACT_DIGITS:N0} digits."));
return digits;
}
// A base which is exact without being rational, such as sqrt(2), can still become one, as 2^500000:
return magnitude * Math.Max(baseDigits, 0);
}
private static double EstimateFactorial(Entity.Factorialf factorial)
{
var argumentDigits = EstimateDigits(factorial.Argument);
var argument = ValueOf(factorial.Argument);
if (argument is null)
return argumentDigits;
// Stirling: log10(n!) is about n * log10(n / e) + log10(2 * pi * n) / 2.
var n = Math.Abs(argument.Value);
if (n < 2)
return argumentDigits + 1;
return n * Math.Log10(n / Math.E) + Math.Log10(2 * Math.PI * n) / 2 + 1;
}
/// <summary>
/// The value of a part without variables, as a double, or null when it has none.
/// </summary>
private static double? ValueOf(Entity part)
{
if (MathVariables.HasAny(part))
return null;
var value = part.Evaled switch
{
Entity.Number.Real real => real.EDecimal.ToDouble(),
Entity.Number.Complex complex => Math.Abs(complex.RealPart.EDecimal.ToDouble()) + Math.Abs(complex.ImaginaryPart.EDecimal.ToDouble()),
_ => double.NaN,
};
return double.IsNaN(value) ? null : value;
}
private static double DigitsOf(EInteger value) => value.IsZero ? 1 : value.Abs().GetDigitCountAsInt64();
/// <summary>
/// The decimal logarithm of an integer's magnitude, also beyond the range of a double.
/// </summary>
private static double Log10(EInteger value)
{
var magnitude = value.Abs();
if (magnitude.IsZero)
return 0;
var digits = magnitude.GetDigitCountAsInt64();
if (digits <= 15)
return Math.Log10(magnitude.ToInt64Checked());
var leading = double.Parse(magnitude.ToString()[..15], CultureInfo.InvariantCulture);
return Math.Log10(leading) + (digits - 15);
}
private static string Show(Entity node) => node.ToString().Shorten(MAX_SHOWN_LENGTH);
}
@@ -0,0 +1,49 @@
namespace AIStudio.Tools.Mathematics;
/// <summary>
/// The limits of the Mathematics tools, in one place.
/// </summary>
/// <remarks>
/// AI Studio also runs on a Raspberry Pi, and AngouriMath cannot stop every computation midway. The
/// limits keep a single call from taking the device: they bound what is read, what is computed
/// exactly, and what goes back to the model. "Mathematics" in documentation/Tools.md lists them.
/// </remarks>
public static class MathLimits
{
/// <summary>
/// The longest expression read, in characters.
/// </summary>
/// <remarks>
/// It also bounds how deeply an expression can nest: 1,000 levels of parentheses at most, which
/// the stack of a computation holds, see MathComputationService.
/// </remarks>
public const int MAX_EXPRESSION_LENGTH = 2_000;
/// <summary>
/// The longest result returned, in characters, for each form of it.
/// </summary>
public const int MAX_RESULT_LENGTH = 20_000;
/// <summary>
/// The most digits an exact number may have while it is computed.
/// </summary>
/// <remarks>
/// AngouriMath computes an exact power or factorial in full and checks no cancellation meanwhile,
/// so MathGuards refuses one which would grow beyond this, before it starts.
/// </remarks>
public const int MAX_EXACT_DIGITS = 20_000;
/// <summary>
/// The largest exponent of a sum which may be expanded.
/// </summary>
/// <remarks>
/// (x + y)^200 expands in a few milliseconds into about 13,000 characters, while (x + y)^500 runs
/// on without checking for cancellation.
/// </remarks>
public const int MAX_EXPANSION_EXPONENT = 200;
/// <summary>
/// The most significant digits a decimal result may have.
/// </summary>
public const int MAX_DIGITS = 100;
}
@@ -0,0 +1,32 @@
using AngouriMath;
namespace AIStudio.Tools.Mathematics;
/// <summary>
/// The variables of an expression, as the Mathematics tools count them.
/// </summary>
/// <remarks>
/// AngouriMath counts the constants pi and e among the free variables, so ln(0) has the free
/// variable e, the base of its logarithm. Asked for the variables of an expression, the tools mean
/// those which have no value, see AngouriMathBehaviorTests.
/// </remarks>
public static class MathVariables
{
/// <summary>
/// The free variables of an expression without the constants, in the order of their names.
/// </summary>
/// <param name="expression">The expression.</param>
/// <returns>Each variable once.</returns>
public static IReadOnlyList<Entity.Variable> FreeIn(Entity expression) => expression.FreeVariables
.Where(variable => variable is not Entity.Constant)
.Distinct()
.OrderBy(variable => variable.Name, StringComparer.Ordinal)
.ToList();
/// <summary>
/// Whether an expression has a free variable other than the constants.
/// </summary>
/// <param name="expression">The expression.</param>
/// <returns>True when a variable without a value occurs freely.</returns>
public static bool HasAny(Entity expression) => expression.FreeVariables.Any(variable => variable is not Entity.Constant);
}
@@ -0,0 +1,212 @@
using AngouriMath;
using PeterO.Numbers;
namespace AIStudio.Tools.Mathematics;
/// <summary>
/// Checks numerically whether two expressions are equal, at fixed sample points.
/// </summary>
/// <remarks>
/// A symbolic answer is only as good as the library which gave it, and AngouriMath changes answers
/// between releases. The tools therefore check what they can: a simplification against its input, an
/// antiderivative by its derivative, a solution by inserting it.<br/><br/>
/// The sample points are exact rationals between 0.6 and 2.3, so a polynomial is evaluated exactly,
/// however much its terms cancel, and everything else at 50 digits. They are fixed, so the same
/// question gets the same answer every time; the providers cache a conversation from its beginning.
/// A point where either side has no value, such as a pole, is skipped. Agreement at the points
/// supports the equality; it does not prove it.<br/><br/>
/// A derivative is compared with the central difference of its function, over a step of 1e-20 at 60
/// digits. The integer parameter of periodic solutions, which AngouriMath names n_1, takes the values
/// -1, 0, and 1 instead, since such a solution holds at integers only.<br/><br/>
/// Call it inside a computation of MathComputationService.
/// </remarks>
public static class NumericCheck
{
private const int SAMPLE_POINTS = 6;
private const int MIN_VALID_POINTS = 3;
private const int PRECISION = 50;
/// <summary>
/// The values the variables take, in hundredths: irregular, so that no point lies on a pattern
/// such as a multiple of pi, and eleven of them, a prime, so that the variables of a point differ.
/// </summary>
private static readonly int[] SAMPLE_HUNDREDTHS = [61, 73, 89, 107, 119, 131, 147, 163, 179, 197, 223];
private static readonly EDecimal TOLERANCE = EDecimal.FromString("1e-30");
/// <summary>
/// The precision of a derivative check: the central difference divides by its tiny step.
/// </summary>
private const int DERIVATIVE_PRECISION = 60;
/// <summary>
/// The step of the central difference: rounding at 60 digits costs about 1e-40 of it, and its own
/// error, about the square of the step, 1e-40 as well.
/// </summary>
private static readonly Entity.Number.Rational DERIVATIVE_STEP = Entity.Number.Rational.Create(EInteger.One, EInteger.FromInt32(10).Pow(20));
private static readonly Entity.Number.Rational TWO_DERIVATIVE_STEPS = Entity.Number.Rational.Create(EInteger.FromInt32(2), EInteger.FromInt32(10).Pow(20));
private static readonly EDecimal DERIVATIVE_TOLERANCE = EDecimal.FromString("1e-20");
/// <summary>
/// Checks whether two expressions agree at every sample point where both have a value.
/// </summary>
/// <remarks>
/// A condition of AngouriMath, as in x + 1 provided not x - 1 = 0, is left aside on both sides:
/// the expressions are compared where they have a value.
/// </remarks>
/// <param name="left">One expression.</param>
/// <param name="right">The other expression.</param>
/// <returns>What the check found.</returns>
public static NumericCheckOutcome AreEqual(Entity left, Entity right)
{
left = WithoutCondition(left);
right = WithoutCondition(right);
var variables = MathVariables.FreeIn(left).Concat(MathVariables.FreeIn(right)).Distinct().OrderBy(variable => variable.Name, StringComparer.Ordinal).ToArray();
using var precision = MathS.Settings.DecimalPrecisionContext.Set(EContext.ForPrecision(PRECISION));
var validPoints = 0;
for (var point = 0; point < SAMPLE_POINTS; point++)
{
var assignment = SampleAssignment(point, variables);
var leftValue = ValueAt(left, assignment);
var rightValue = ValueAt(right, assignment);
if (leftValue is null || rightValue is null)
continue;
validPoints++;
if (!AreClose(leftValue, rightValue, TOLERANCE))
return NumericCheckOutcome.FAILED;
}
return validPoints >= MIN_VALID_POINTS ? NumericCheckOutcome.PASSED : NumericCheckOutcome.NOT_POSSIBLE;
}
/// <summary>
/// Checks whether one expression is the derivative of another, by a central difference at each sample point.
/// </summary>
/// <param name="derivative">The claimed derivative.</param>
/// <param name="function">The function.</param>
/// <param name="variable">The variable of the derivative.</param>
/// <returns>What the check found.</returns>
public static NumericCheckOutcome IsDerivativeOf(Entity derivative, Entity function, Entity.Variable variable)
{
derivative = WithoutCondition(derivative);
function = WithoutCondition(function);
var variables = MathVariables.FreeIn(derivative).Concat(MathVariables.FreeIn(function)).Append(variable).Distinct().OrderBy(free => free.Name, StringComparer.Ordinal).ToArray();
using var precision = MathS.Settings.DecimalPrecisionContext.Set(EContext.ForPrecision(DERIVATIVE_PRECISION));
var validPoints = 0;
for (var point = 0; point < SAMPLE_POINTS; point++)
{
var assignment = SampleAssignment(point, variables);
var at = assignment[variable];
var claimed = ValueAt(derivative, assignment);
var above = ValueAt(function, With(assignment, variable, (at + DERIVATIVE_STEP).InnerSimplified));
var below = ValueAt(function, With(assignment, variable, (at - DERIVATIVE_STEP).InnerSimplified));
if (claimed is null || above is null || below is null)
continue;
var difference = NumericEvaluator.EvaluateAt(((Entity)above - below) / TWO_DERIVATIVE_STEPS, DERIVATIVE_PRECISION);
validPoints++;
if (!AreClose(claimed, difference, DERIVATIVE_TOLERANCE))
return NumericCheckOutcome.FAILED;
}
return validPoints >= MIN_VALID_POINTS ? NumericCheckOutcome.PASSED : NumericCheckOutcome.NOT_POSSIBLE;
}
/// <summary>
/// Whether AngouriMath named a variable as the integer parameter of periodic solutions, as in pi / 6 + 2 * pi * n_1.
/// </summary>
/// <param name="variable">The variable.</param>
/// <returns>True for n_1, n_2, and so on.</returns>
public static bool IsIntegerParameter(Entity.Variable variable) => variable.Name.StartsWith("n_", StringComparison.Ordinal) && variable.Name.Length > 2 && variable.Name[2..].All(char.IsAsciiDigit);
/// <summary>
/// The values of the variables at one sample point.
/// </summary>
/// <param name="point">The number of the point, from zero.</param>
/// <param name="variables">The variables, in a fixed order.</param>
/// <returns>Each variable with its exact value.</returns>
public static IReadOnlyDictionary<Entity.Variable, Entity> SampleAssignment(int point, IReadOnlyList<Entity.Variable> variables)
{
var assignment = new Dictionary<Entity.Variable, Entity>(variables.Count);
for (var index = 0; index < variables.Count; index++)
{
// An integer parameter takes -1, 0, and 1, since a periodic solution holds only at integers:
if (IsIntegerParameter(variables[index]))
{
assignment[variables[index]] = Entity.Number.Integer.Create((point + index) % 3 - 1);
continue;
}
var hundredths = SAMPLE_HUNDREDTHS[(point * 3 + index * 5) % SAMPLE_HUNDREDTHS.Length];
assignment[variables[index]] = Entity.Number.Rational.Create(hundredths, 100);
}
return assignment;
}
private static Dictionary<Entity.Variable, Entity> With(IReadOnlyDictionary<Entity.Variable, Entity> assignment, Entity.Variable variable, Entity value) => new(assignment) { [variable] = value };
/// <summary>
/// The value of an expression with its variables replaced, or null when it has none there.
/// </summary>
/// <param name="expression">The expression.</param>
/// <param name="assignment">The value of each variable.</param>
/// <returns>The value, or null for no value, an infinite one, or one AngouriMath cannot evaluate.</returns>
public static Entity.Number.Complex? ValueAt(Entity expression, IReadOnlyDictionary<Entity.Variable, Entity> assignment)
{
try
{
var substituted = expression;
foreach (var (variable, value) in assignment)
substituted = substituted.Substitute(variable, value);
if (!substituted.EvaluableNumerical)
return null;
//
// A polynomial at a sample point stays exact. Anything else is evaluated without the downcasting which turns
// tiny values into 0, and so is an exact 0: simplifying takes a tiny value such as 6.62607015e-34 * sin(0.61)
// for 0 at this precision, see AngouriMathBehaviorTests:
//
var exact = NumericEvaluator.ExactValueOf(substituted);
var number = exact is not null && !IsZero(exact) ? exact : NumericEvaluator.EvaluateAt(substituted, MathS.Settings.DecimalPrecisionContext.Value.Precision.ToInt32Checked());
return IsFinite(number) ? number : null;
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
return null;
}
}
private static Entity WithoutCondition(Entity expression) => expression is Entity.Providedf provided ? provided.Expression : expression;
private static bool IsFinite(Entity.Number.Complex number) => IsFinite(number.RealPart.EDecimal) && IsFinite(number.ImaginaryPart.EDecimal);
private static bool IsZero(Entity.Number.Complex number) => number.RealPart.EDecimal.IsZero && number.ImaginaryPart.EDecimal.IsZero;
private static bool IsFinite(EDecimal value) => !value.IsNaN() && !value.IsInfinity();
private static bool AreClose(Entity.Number.Complex left, Entity.Number.Complex right, EDecimal tolerance)
{
var context = EContext.ForPrecision(PRECISION);
var difference = left.RealPart.EDecimal.Subtract(right.RealPart.EDecimal, context).Abs().Add(left.ImaginaryPart.EDecimal.Subtract(right.ImaginaryPart.EDecimal, context).Abs(), context);
var leftSize = left.RealPart.EDecimal.Abs().Add(left.ImaginaryPart.EDecimal.Abs(), context);
var rightSize = right.RealPart.EDecimal.Abs().Add(right.ImaginaryPart.EDecimal.Abs(), context);
//
// Against 0, the tolerance is absolute: an expression whose terms cancel, such as sin(2*x) - 2*sin(x)*cos(x),
// comes only as close as its rounding noise. Between two other values, it is relative, so that tiny values,
// such as those of a formula with the Planck constant, are still told apart:
//
var scale = leftSize.IsZero || rightSize.IsZero ? EDecimal.One : EDecimal.Max(leftSize, rightSize);
return difference.CompareTo(tolerance.Multiply(scale, context)) <= 0;
}
}
@@ -0,0 +1,22 @@
namespace AIStudio.Tools.Mathematics;
/// <summary>
/// What a numeric check found.
/// </summary>
public enum NumericCheckOutcome
{
/// <summary>
/// Too few sample points had a value on both sides, or one side cannot be evaluated as a number.
/// </summary>
NOT_POSSIBLE = 0,
/// <summary>
/// Both sides agree at every sample point where both have a value.
/// </summary>
PASSED,
/// <summary>
/// The sides differ at a sample point.
/// </summary>
FAILED,
}
@@ -0,0 +1,327 @@
using System.Globalization;
using AIStudio.Chat;
using AngouriMath;
using PeterO.Numbers;
namespace AIStudio.Tools.Mathematics;
/// <summary>
/// Evaluates an expression without variables to a number, exactly where it can, and states how many
/// of its digits are certain.
/// </summary>
/// <remarks>
/// A rational value stays exact, so 547 / 57576 comes back as that fraction. Every other value is
/// evaluated twice, at ten and at twenty digits beyond those asked for, and only the digits both agree
/// on are reported: AngouriMath rounds at every step, and a compound expression such as pi^4/15 loses
/// its last digits at the precision asked for, see AngouriMathBehaviorTests.<br/><br/>
/// A definite integral is integrated numerically, never by AngouriMath: its antiderivative may jump
/// between the bounds, and Evaled turns the constants inside an integral it cannot take into decimals.
/// Its value then carries the digits of a double at most. The two evaluations shift every integral
/// by its estimated error, down in one and up in the other, so the digits they agree on are those the
/// integrals vouch for, too.<br/><br/>
/// A tiny value, such as one computed with the Planck constant, gets as many more digits as it has
/// leading zeros, see EvaluateAt.<br/><br/>
/// Call it inside a computation of MathComputationService.
/// </remarks>
public static class NumericEvaluator
{
private const int GUARD_DIGITS = 10;
private const int MAX_SHOWN_LENGTH = 120;
/// <summary>
/// The most digits an evaluation computes with. A value which needs more, because it is smaller
/// than about 1e-900, loses its digits.
/// </summary>
private const int MAX_PRECISION = 1_000;
/// <summary>
/// The precision of a bound of an integral, which ends up as a double.
/// </summary>
private const int BOUND_PRECISION = 30;
/// <summary>
/// Evaluates an expression to a number.
/// </summary>
/// <param name="expression">The expression, read by MathExpressionReader.</param>
/// <param name="digits">How many significant digits are asked for.</param>
/// <param name="token">The cancellation token of the computation.</param>
/// <returns>The value with its certain digits.</returns>
/// <exception cref="MathComputationException">The expression has no numeric value, or cannot be integrated.</exception>
public static NumericValue Evaluate(Entity expression, int digits, CancellationToken token)
{
RefuseWhatIsNoNumber(expression);
MathGuards.CheckExactSize(expression);
var integrals = new List<NumericIntegral>();
var issues = new List<string>();
var withPlaceholders = expression.Replace(node => node is Entity.Integralf integral ? Integrate(integral, integrals, issues, token) : node);
var exact = integrals.Count == 0 ? ExactValueOf(expression) : null;
if (exact is not null && IsZero(exact) && MayBeTiny(expression, digits))
exact = null;
if (exact is not null)
return new(exact, DecimalOf(exact, digits + GUARD_DIGITS), digits, false, null, issues);
var substituted = Substituted(withPlaceholders, integrals, 0);
if (!substituted.EvaluableNumerical)
throw new MathComputationException($"'{Show(expression)}' has no value the tool can compute as a single number, as for a sum without a closed form, an infinite sum, or a matrix.");
var central = EvaluateAt(substituted, digits + GUARD_DIGITS);
if (IsNaN(central))
throw new MathComputationException($"'{Show(expression)}' has no value: it evaluates to NaN, as for a division by zero, a pole, or the factorial of a negative integer.");
if (IsInfinite(central))
{
issues.Add("The value is infinite.");
return new(null, central, 0, integrals.Count > 0, null, issues);
}
//
// Where AngouriMath rounds to decimal places, a value far below 1 keeps only some of its digits, and a
// tiny one none at all. The precision therefore grows by the leading zeros of the value, and a value of
// zero is evaluated again with twice the digits, until MAX_PRECISION. More digits cannot change an
// integral's value of zero, which comes from the quadrature:
//
var shift = 0;
var maxShift = MAX_PRECISION - digits - 2 * GUARD_DIGITS;
while (true)
{
int wantedShift;
if (!IsZero(central))
wantedShift = LeadingZerosOf(central);
else if (integrals.Count == 0)
wantedShift = 2 * shift + digits + GUARD_DIGITS;
else
break;
wantedShift = Math.Min(wantedShift, maxShift);
if (wantedShift <= shift + GUARD_DIGITS)
break;
token.ThrowIfCancellationRequested();
shift = wantedShift;
central = EvaluateAt(substituted, digits + GUARD_DIGITS + shift);
}
var low = integrals.Count == 0 ? central : EvaluateAt(Substituted(withPlaceholders, integrals, -1), digits + GUARD_DIGITS + shift);
var high = EvaluateAt(Substituted(withPlaceholders, integrals, 1), digits + 2 * GUARD_DIGITS + shift);
var certainDigits = MathFormatting.CountMatchingDigits(low, high, digits);
double? estimatedError = integrals.Count == 0 ? null : MathFormatting.Distance(low, high) / 2;
if (IsZero(central) && integrals.Count == 0)
{
certainDigits = 0;
issues.Add(string.Create(CultureInfo.InvariantCulture, $"The value evaluates to 0 with {digits + GUARD_DIGITS + shift} digits, but the tool cannot show that it is exactly 0: a value too small for that many digits comes out as 0, too."));
}
else if (certainDigits == 0)
issues.Add(string.Create(CultureInfo.InvariantCulture, $"No digit of the value is certain: it lies within about {MathFormatting.FormatDecimal(MathFormatting.Distance(low, high), 2)} of the decimal."));
else if (certainDigits < digits)
issues.Add(string.Create(CultureInfo.InvariantCulture, $"Only {certainDigits} of the {digits} significant digits asked for are certain, so the decimal has {certainDigits}."));
return new(null, central, certainDigits, integrals.Count > 0, estimatedError, issues);
}
private static void RefuseWhatIsNoNumber(Entity expression)
{
if (expression is Entity.Statement)
throw new MathComputationException($"'{Show(expression)}' is a statement, such as a comparison, and has no numeric value. An equation with one '=' is checked by comparing its sides.");
if (expression is Entity.Set)
throw new MathComputationException($"'{Show(expression)}' is a set, and has no numeric value.");
var variables = MathVariables.FreeIn(expression).Select(variable => variable.Name).ToList();
if (variables.Count > 0)
throw new MathComputationException($"'{Show(expression)}' contains the variable{(variables.Count == 1 ? string.Empty : "s")} {string.Join(", ", variables)}, so it has no numeric value. compute_symbolically works with variables.");
}
/// <summary>
/// The exact value of an expression when it is rational, or complex with rational parts.
/// </summary>
/// <param name="expression">The expression.</param>
/// <returns>The exact value, or null for an expression without one.</returns>
public static Entity.Number.Complex? ExactValueOf(Entity expression) => expression.InnerSimplified switch
{
Entity.Number.Rational rational => rational,
Entity.Number.Complex { RealPart: Entity.Number.Rational, ImaginaryPart: Entity.Number.Rational } complex => complex,
_ => null,
};
/// <summary>
/// Integrates a definite integral numerically, and puts a variable of its own in its place.
/// </summary>
private static Entity Integrate(Entity.Integralf integral, List<NumericIntegral> integrals, List<string> issues, CancellationToken token)
{
if (integral.Range is not { } range)
throw new MathComputationException($"'{Show(integral)}' has no bounds, so it has no numeric value. compute_symbolically integrates without bounds.");
var (from, to) = range;
if (integral.Var is not Entity.Variable variable)
throw new MathComputationException($"'{Show(integral)}' does not integrate over a variable.");
if (MathVariables.FreeIn(integral.Expression).Any(free => free != variable) || MathVariables.HasAny(from) || MathVariables.HasAny(to))
throw new MathComputationException($"'{Show(integral)}' depends on the variable of an outer integral. Nested integrals are not integrated numerically.");
var lower = BoundOf(from, integral);
var upper = BoundOf(to, integral);
Func<double, double> integrand;
try
{
integrand = integral.Expression.Compile<double, double>(variable);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
throw new MathComputationException($"The integrand of '{Show(integral)}' cannot be evaluated numerically: {exception.Message.Shorten(MAX_SHOWN_LENGTH)}");
}
var result = DoubleExponentialQuadrature.Integrate(integrand, lower, upper, token);
switch (result.Status)
{
case QuadratureStatus.NO_VALUE:
throw new MathComputationException(string.Create(CultureInfo.InvariantCulture, $"The integrand of '{Show(integral)}' has no finite value at {variable.Name} = {MathFormatting.FormatDecimal(result.FailurePoint ?? double.NaN, 6)}, inside the interval of integration."));
case QuadratureStatus.NOT_CONVERGED:
throw new MathComputationException($"The numerical integration of '{Show(integral)}' did not settle. The integral may diverge, converge only conditionally such as sin(x)/x over [0, +oo), or have a singularity inside the interval, which a split at the singularity moves to a bound.");
}
// The expression has no free variables at this point, so no name can clash with one of them:
var placeholder = MathS.Var($"Q_{integrals.Count + 1}");
integrals.Add(new(placeholder, result.Value, Math.Max(result.EstimatedError, Math.Abs(result.Value) * Math.ScaleB(1.0, -52))));
issues.Add(string.Create(CultureInfo.InvariantCulture, $"'{Show(integral)}' was integrated numerically, with an estimated error of {MathFormatting.FormatDecimal(result.EstimatedError, 2)}."));
return placeholder;
}
private static double BoundOf(Entity bound, Entity.Integralf integral)
{
var value = EvaluateAt(bound, BOUND_PRECISION);
if (!value.ImaginaryPart.EDecimal.IsZero)
throw new MathComputationException($"A bound of '{Show(integral)}' is not a real number.");
var number = value.RealPart.EDecimal;
if (number.IsNaN())
throw new MathComputationException($"A bound of '{Show(integral)}' has no value.");
if (number.IsInfinity())
return number.IsNegative ? double.NegativeInfinity : double.PositiveInfinity;
return number.ToDouble();
}
/// <summary>
/// The expression with every integral replaced by its value, shifted by its estimated error in the given direction.
/// </summary>
private static Entity Substituted(Entity expression, IReadOnlyList<NumericIntegral> integrals, int direction)
{
// Downcasting would turn the value of a tiny integral into 0, see EvaluateAt:
using var withoutDowncasting = MathS.Settings.DowncastingEnabled.Set(false);
foreach (var integral in integrals)
expression = expression.Substitute(integral.Placeholder, Entity.Number.Real.Create(EDecimal.FromDouble(integral.Value + direction * integral.Error)));
return expression;
}
/// <summary>
/// Evaluates an expression without variables to a number, with at least the given number of significant digits.
/// </summary>
/// <remarks>
/// By default, AngouriMath downcasts: it turns a number which looks like an integer at the precision into that
/// integer, and so a tiny value such as 1.380649e-23 * 300 into 0, see AngouriMathBehaviorTests. This evaluation
/// does without. Then AngouriMath rounds the quotient of two integers to as many decimal places as the precision
/// has digits, so 21/10^50 vanishes at 30 digits. The precision therefore grows by the leading zeros of the
/// smallest fraction in the expression, up to MAX_PRECISION.
/// </remarks>
/// <param name="expression">The expression, without variables.</param>
/// <param name="precision">How many significant digits to compute with, at least.</param>
/// <returns>The value, which may be NaN or infinite.</returns>
public static Entity.Number.Complex EvaluateAt(Entity expression, int precision)
{
precision = Math.Min(precision + LeadingZerosOfFractions(expression), MAX_PRECISION);
using var context = MathS.Settings.DecimalPrecisionContext.Set(EContext.ForPrecision(precision));
using var withoutDowncasting = MathS.Settings.DowncastingEnabled.Set(false);
return expression.EvalNumerical();
}
/// <summary>
/// Whether an exact 0 may stand for a tiny value instead.
/// </summary>
/// <remarks>
/// AngouriMath simplifies a value below about 1e-1000 to 0, e.g., pi/10^1500, see AngouriMathBehaviorTests.
/// A fraction that small, such as 1e-2000, leaves the 0 in doubt. Otherwise, a true 0, as for sin(pi),
/// evaluates to rounding noise which changes with the precision, but a tiny value, as for exp(-3000), keeps
/// its digits. A quotient such as pi/10^1500 evaluates to 0 as well, since AngouriMath rounds it to decimal
/// places; its 0 stands.
/// </remarks>
private static bool MayBeTiny(Entity expression, int digits)
{
if (LeadingZerosOfFractions(expression) > MAX_PRECISION - digits - 2 * GUARD_DIGITS)
return true;
var value = EvaluateAt(expression, digits + GUARD_DIGITS);
var check = EvaluateAt(expression, digits + 2 * GUARD_DIGITS);
return !IsZero(value) && MathFormatting.CountMatchingDigits(value, check, GUARD_DIGITS) >= GUARD_DIGITS / 2;
}
/// <summary>
/// How many zeros the smallest fraction of an expression has after its decimal point, e.g., 49 for 21/10^50.
/// </summary>
/// <remarks>
/// The fractions are those the expression writes, such as 1e-200, and those its simplification computes, such as
/// 21/10^50 from 3/10^50 * 7.
/// </remarks>
private static int LeadingZerosOfFractions(Entity expression) => expression.Nodes
.Concat(expression.InnerSimplified.Nodes)
.OfType<Entity.Number.Complex>()
.SelectMany(number => new[] { number.RealPart, number.ImaginaryPart })
.OfType<Entity.Number.Rational>()
.Select(fraction => fraction.ERational.Denominator.ToString().Length - fraction.ERational.Numerator.Abs().ToString().Length)
.Append(0)
.Max();
/// <summary>
/// How many zeros a number has after its decimal point, e.g., 18 for 3.97e-19, and 0 for a number of at least 0.1.
/// </summary>
private static int LeadingZerosOf(Entity.Number.Complex number)
{
var real = number.RealPart.EDecimal;
var imaginary = number.ImaginaryPart.EDecimal;
var largest = real.Abs().CompareTo(imaginary.Abs()) >= 0 ? real : imaginary;
if (largest.IsZero || largest.IsNaN() || largest.IsInfinity())
return 0;
var exponentOfFirstDigit = largest.Exponent.ToInt64Checked() + largest.UnsignedMantissa.ToString().Length - 1;
return (int)Math.Clamp(-exponentOfFirstDigit - 1, 0, MAX_PRECISION);
}
private static bool IsZero(Entity.Number.Complex number) => number.RealPart.EDecimal.IsZero && number.ImaginaryPart.EDecimal.IsZero;
/// <summary>
/// An exact value as a decimal with the given number of significant digits.
/// </summary>
/// <remarks>
/// AngouriMath converts a fraction to a fixed number of decimal places, so 1/10^200 would become 0,
/// see AngouriMathBehaviorTests.
/// </remarks>
private static Entity.Number.Complex DecimalOf(Entity.Number.Complex exact, int precision)
{
var context = EContext.ForPrecision(precision);
using var withoutDowncasting = MathS.Settings.DowncastingEnabled.Set(false);
return Entity.Number.Complex.Create(Entity.Number.Real.Create(DecimalOf(exact.RealPart, context)), Entity.Number.Real.Create(DecimalOf(exact.ImaginaryPart, context)));
}
private static EDecimal DecimalOf(Entity.Number.Real part, EContext context) => part is Entity.Number.Rational fraction ? fraction.ERational.ToEDecimal(context) : part.EDecimal;
private static bool IsNaN(Entity.Number.Complex number) => number.RealPart.EDecimal.IsNaN() || number.ImaginaryPart.EDecimal.IsNaN();
private static bool IsInfinite(Entity.Number.Complex number) => number.RealPart.EDecimal.IsInfinity() || number.ImaginaryPart.EDecimal.IsInfinity();
private static string Show(Entity expression) => expression.ToString().Shorten(MAX_SHOWN_LENGTH);
/// <summary>
/// A definite integral, integrated numerically, and the variable which stands for it.
/// </summary>
private sealed record NumericIntegral(Entity.Variable Placeholder, double Value, double Error);
}
@@ -0,0 +1,14 @@
using AngouriMath;
namespace AIStudio.Tools.Mathematics;
/// <summary>
/// The value of an expression without variables, with as many digits as the computation vouches for.
/// </summary>
/// <param name="Exact">The exact value when it is rational, or complex with rational parts; otherwise null.</param>
/// <param name="Value">The value at the working precision, before it is rounded to its digits.</param>
/// <param name="Digits">How many significant digits of the value are certain; zero when none is.</param>
/// <param name="Approximate">Whether a part of the value was integrated numerically.</param>
/// <param name="EstimatedError">The estimated absolute error of an approximate value; null for any other.</param>
/// <param name="Issues">What the model has to know about the value.</param>
public sealed record NumericValue(Entity.Number? Exact, Entity.Number.Complex Value, int Digits, bool Approximate, double? EstimatedError, IReadOnlyList<string> Issues);
@@ -0,0 +1,11 @@
namespace AIStudio.Tools.Mathematics;
/// <summary>
/// The result of a numerical integration.
/// </summary>
/// <param name="Value">The estimate of the integral; only an estimate of it when Status is CONVERGED.</param>
/// <param name="EstimatedError">The estimated absolute error of the value.</param>
/// <param name="Status">How the integration ended.</param>
/// <param name="FailurePoint">Where the integrand had no finite value, when Status is NO_VALUE.</param>
/// <param name="Evaluations">How often the integrand was evaluated.</param>
public sealed record QuadratureResult(double Value, double EstimatedError, QuadratureStatus Status, double? FailurePoint, int Evaluations);
@@ -0,0 +1,23 @@
namespace AIStudio.Tools.Mathematics;
/// <summary>
/// How a numerical integration ended.
/// </summary>
public enum QuadratureStatus
{
/// <summary>
/// The estimate settled within the tolerance; its estimated error says how far.
/// </summary>
CONVERGED = 0,
/// <summary>
/// The estimate did not settle: the integral may diverge or oscillate, or the integrand varies too
/// much for the finest step. The value is the last estimate and must not be taken for the integral.
/// </summary>
NOT_CONVERGED,
/// <summary>
/// The integrand has no finite value at a point inside the interval, such as a pole.
/// </summary>
NO_VALUE,
}
@@ -0,0 +1,155 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Mathematics;
using AIStudio.Tools.PluginSystem;
using AngouriMath;
namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mathematics;
/// <summary>
/// Evaluates an expression numerically, exactly where it can, and checks an equation by comparing its sides.
/// </summary>
/// <remarks>
/// Models guess digits, slip with fractions, and assert identities they never checked. This tool
/// computes locally with AngouriMath: a rational value stays exact, every other decimal carries only
/// the digits the computation vouches for, and a definite integral is integrated numerically, also
/// over an infinite interval, see NumericEvaluator.<br/><br/>
/// Its result holds nothing but the expression of the model, as AngouriMath read it, and values
/// computed from it, so it opens no way for content from outside and needs no prompt injection
/// filter. Nothing leaves AI Studio either. It belongs to the Mathematics collection, see
/// MathematicsToolCollection.
/// </remarks>
public sealed class CalculateTool(SettingsManager settingsManager, MathComputationService computationService) : IToolImplementation
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(CalculateTool).Namespace, nameof(CalculateTool));
private const string EXPRESSION_ARGUMENT = "expression";
private const string DIGITS_ARGUMENT = "digits";
private const int DEFAULT_DIGITS = 20;
/// <summary>
/// The syntax both Mathematics tools read, described for the model.
/// </summary>
internal const string SYNTAX = "Plain syntax, not LaTeX: + - * / ^ and ! for the factorial, with every multiplication written as *, e.g., 2*pi*x. Functions take their arguments in parentheses: sqrt, exp, ln, log(x) to base 10, log(b, x) to base b, sin, cos, tan, arcsin, arccos, arctan, sinh, cosh, tanh, abs, floor, ceil, gamma, binomial(n, k), gcd(a, b), lcm(a, b), erf. Constants are pi, e, and i; infinity is +oo or -oo. A definite integral is integral(f, x, a, b), a limit limit(f, x, a), and a sum sum(f, k, a, b) over the integers k from a to b. A sum is computed only in closed form, such as for powers of k; any other sum has to be written out term by term. Not-equal is written <>, and | is not an operator.";
public string ImplementationKey => ToolSelectionRules.CALCULATE_TOOL_ID;
public ToolDefinition GetDefinition() => new()
{
Id = ToolSelectionRules.CALCULATE_TOOL_ID,
ImplementationKey = ToolSelectionRules.CALCULATE_TOOL_ID,
// No minimum confidence of its own: the Mathematics collection states it, see MathematicsToolCollection.
SystemPromptInstructions = """
Use `calculate` for every number beyond simple mental arithmetic, instead of working out digits yourself, and to check a numerical claim before you make it.
- It reads plain syntax, not LaTeX. Translate LaTeX first, write every multiplication with `*`, and give each function its arguments in parentheses, e.g., `2*pi*sqrt(x)` for `2\pi\sqrt{x}`.
- `expression` in the result shows how your input was read. Compare it with what you meant.
- Give the decimal with the digits returned, never more. `exact` is the exact value of a rational result.
- When `approximate` is true, a definite integral was integrated numerically: say so, together with its `estimated_error`.
- For an equation, `numerically_equal` means that both sides agree in all `matching_digits`. That supports the equation, but does not prove it; say so. Only when both sides are exact, `exactly_equal` settles it.
- When the tool refuses an expression, its message says how the tool reads it and how it is written instead.
""",
Function = new()
{
Name = ToolSelectionRules.CALCULATE_TOOL_ID,
DescriptionForLLM = "Evaluate a mathematical expression locally, exactly where possible: fractions stay exact, and a decimal carries only the digits the computation vouches for. Definite integrals, also over infinite intervals, are integrated numerically. An expression with one '=' is an equation, whose two sides are evaluated and compared.",
Parameters = ToolParameterSchemaBuilder.Create()
.RequiredString(EXPRESSION_ARGUMENT, $"The expression, without variables except those bound by an integral or a limit. {SYNTAX} Example: integral(x^3/(e^x - 1), x, 0, +oo) = pi^4/15")
.OptionalInteger(DIGITS_ARGUMENT, $"Optional: how many significant digits the decimal should have, from 1 to {MathLimits.MAX_DIGITS}. Fewer come back when the computation cannot vouch for more, e.g., about 14 after a numerical integration. Leave it out to get {DEFAULT_DIGITS}.")
.Build(),
},
};
public string Icon => Icons.Material.Filled.Calculate;
public bool IsAvailable => PreviewFeatures.PRE_MATHEMATICS_2026.IsEnabled(settingsManager);
// Everything is computed locally:
public ToolOutboundData OutboundData => ToolOutboundData.NONE;
public IReadOnlySet<string> SensitiveTraceArgumentNames => new HashSet<string>(StringComparer.Ordinal);
public string GetDisplayName() => TB("Calculate");
public string GetDescription() => TB("Lets the AI calculate exactly instead of guessing digits, e.g., fractions, roots, or integrals, and check an equation. Everything is computed on your computer.");
public async Task<ToolExecutionResult> ExecuteAsync(JsonElement arguments, ToolExecutionContext context, CancellationToken token = default)
{
var text = ToolArgumentReader.ReadRequiredString(arguments, EXPRESSION_ARGUMENT);
var digits = ReadDigits(arguments);
var result = await computationService.ComputeAsync(computationToken => Calculate(text, digits, computationToken), token);
return new ToolExecutionResult { JsonContent = result };
}
private static int ReadDigits(JsonElement arguments)
{
var digits = ToolArgumentReader.ReadOptionalPositiveInt(arguments, DIGITS_ARGUMENT, $"to get {DEFAULT_DIGITS} significant digits");
if (digits is null)
return DEFAULT_DIGITS;
if (digits > MathLimits.MAX_DIGITS)
throw new ArgumentException($"Argument '{DIGITS_ARGUMENT}' must be at most {MathLimits.MAX_DIGITS}, but was {digits}. Leave it out to get {DEFAULT_DIGITS} significant digits.");
return digits.Value;
}
/// <summary>
/// Evaluates the expression, or both sides of an equation, and writes the result for the model.
/// </summary>
internal static JsonObject Calculate(string text, int digits, CancellationToken token)
{
var expression = MathExpressionReader.Read(text, EXPRESSION_ARGUMENT);
var result = new JsonObject
{
["expression"] = MathFormatting.ToText(expression, "The expression as read"),
["latex"] = MathFormatting.ToLatex(expression, "The LaTeX of the expression"),
};
if (expression is not Entity.Equalsf equation)
{
var value = NumericEvaluator.Evaluate(expression, digits, token);
Describe(value, result);
return result;
}
var left = NumericEvaluator.Evaluate(equation.Left, digits, token);
var right = NumericEvaluator.Evaluate(equation.Right, digits, token);
var certainDigits = Math.Min(left.Digits, right.Digits);
var matchingDigits = Math.Min(MathFormatting.CountMatchingDigits(left.Value, right.Value, digits), certainDigits);
result["left"] = Describe(left, new JsonObject { ["expression"] = MathFormatting.ToText(equation.Left, "The left side") });
result["right"] = Describe(right, new JsonObject { ["expression"] = MathFormatting.ToText(equation.Right, "The right side") });
result["numerically_equal"] = certainDigits > 0 && matchingDigits >= certainDigits;
result["matching_digits"] = matchingDigits;
// Two exact values settle the question, also where they differ only beyond the digits compared:
if (left.Exact is not null && right.Exact is not null)
result["exactly_equal"] = left.Exact.Equals(right.Exact);
return result;
}
private static JsonObject Describe(NumericValue value, JsonObject json)
{
if (value.Exact is not null)
{
json["exact"] = MathFormatting.ToText(value.Exact, "The exact value");
json["exact_latex"] = MathFormatting.ToLatex(value.Exact, "The LaTeX of the exact value");
}
json["decimal"] = MathFormatting.FormatNumber(value.Value, Math.Max(value.Digits, 1));
json["digits"] = value.Digits;
json["approximate"] = value.Approximate;
if (value.EstimatedError is { } estimatedError)
json["estimated_error"] = MathFormatting.FormatDecimal(estimatedError, 2);
if (value.Issues.Count > 0)
json["issues"] = new JsonArray([..value.Issues.Select(issue => JsonValue.Create(issue))]);
return json;
}
}
@@ -0,0 +1,506 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using AIStudio.Chat;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Mathematics;
using AIStudio.Tools.PluginSystem;
using AngouriMath;
using AngouriMath.Core;
namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mathematics;
/// <summary>
/// Computes with formulas: simplifies, expands, factors, differentiates, integrates, takes limits, and solves.
/// </summary>
/// <remarks>
/// AngouriMath does the algebra locally, and the tool checks each answer numerically wherever it can,
/// see NumericCheck: a symbolic answer is only as good as the library which gave it, and AngouriMath
/// changes answers between releases. Where AngouriMath finds no closed form, the result says so
/// instead of passing the unsolved problem off as an answer.<br/><br/>
/// The arguments are checked strictly against the operation: an argument which the operation does
/// not use is refused rather than ignored, since the model meant something by it.<br/><br/>
/// Like Calculate, its result holds nothing but the expression of the model and what was computed
/// from it, so it needs no prompt injection filter, and nothing leaves AI Studio. It belongs to the
/// Mathematics collection, see MathematicsToolCollection.
/// </remarks>
public sealed class ComputeSymbolicallyTool(SettingsManager settingsManager, MathComputationService computationService) : IToolImplementation
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ComputeSymbolicallyTool).Namespace, nameof(ComputeSymbolicallyTool));
private const string OPERATION_ARGUMENT = "operation";
private const string EXPRESSION_ARGUMENT = "expression";
private const string VARIABLE_ARGUMENT = "variable";
private const string ORDER_ARGUMENT = "order";
private const string LOWER_BOUND_ARGUMENT = "lower_bound";
private const string UPPER_BOUND_ARGUMENT = "upper_bound";
private const string APPROACH_ARGUMENT = "approach";
private const string DIRECTION_ARGUMENT = "direction";
private const int MAX_ORDER = 10;
private const int SOLUTION_DIGITS = 15;
private const int MAX_ECHO_LENGTH = 40;
/// <summary>
/// The relative difference within which a definite integral agrees with its numerical integration,
/// at least: a double carries no more.
/// </summary>
private const double MIN_QUADRATURE_TOLERANCE = 1e-12;
/// <summary>
/// The precision of a value which ends up as a double, such as a bound of an integral.
/// </summary>
private const int DOUBLE_PRECISION = 30;
private static readonly Dictionary<string, SymbolicOperation> OPERATIONS = new(StringComparer.Ordinal)
{
["simplify"] = SymbolicOperation.SIMPLIFY,
["expand"] = SymbolicOperation.EXPAND,
["factor"] = SymbolicOperation.FACTOR,
["differentiate"] = SymbolicOperation.DIFFERENTIATE,
["integrate"] = SymbolicOperation.INTEGRATE,
["limit"] = SymbolicOperation.LIMIT,
["solve"] = SymbolicOperation.SOLVE,
};
private static readonly Dictionary<string, ApproachFrom> DIRECTIONS = new(StringComparer.Ordinal)
{
["both"] = ApproachFrom.BothSides,
["left"] = ApproachFrom.Left,
["right"] = ApproachFrom.Right,
};
private static readonly string[] OPERATION_NAMES = [..OPERATIONS.Keys];
private static readonly string[] DIRECTION_NAMES = [..DIRECTIONS.Keys];
public string ImplementationKey => ToolSelectionRules.COMPUTE_SYMBOLICALLY_TOOL_ID;
public ToolDefinition GetDefinition() => new()
{
Id = ToolSelectionRules.COMPUTE_SYMBOLICALLY_TOOL_ID,
ImplementationKey = ToolSelectionRules.COMPUTE_SYMBOLICALLY_TOOL_ID,
// No minimum confidence of its own: the Mathematics collection states it, see MathematicsToolCollection.
SystemPromptInstructions = """
Use `compute_symbolically` to simplify, expand, or factor an expression, to differentiate or integrate it, to take a limit, or to solve an equation or an inequality, instead of doing the algebra yourself.
- It reads the same plain syntax as `calculate`, not LaTeX.
- `result` is the answer in plain syntax, and `result_latex` the same for your reply. A `condition` says where the result holds, e.g., `not x - 1 = 0`; mention it.
- When `complete` is false, the tool found no closed form, and `result` still holds the integral, the limit, or the equation. Never present it as an answer; `calculate` gives a definite integral numerically.
- `verification` says whether the result was checked numerically: `passed`, `failed`, or `not_possible`. Never present a result with `failed` as correct, and say when the check was `not_possible`.
- Solutions may be complex; `real` tells for each one with a value. n_1 stands for any integer, r_1 for any real number, and C for any constant.
""",
Function = new()
{
Name = ToolSelectionRules.COMPUTE_SYMBOLICALLY_TOOL_ID,
DescriptionForLLM = "Compute with formulas locally: simplify, expand, or factor an expression, differentiate it, integrate it with or without bounds, take a limit, or solve an equation or inequality. Each result is checked numerically where possible, and says when no closed form was found.",
Parameters = ToolParameterSchemaBuilder.Create()
.RequiredEnum(OPERATION_ARGUMENT, "What to do with the expression.", OPERATION_NAMES)
.RequiredString(EXPRESSION_ARGUMENT, $"The expression, or for solve the equation or inequality. {CalculateTool.SYNTAX} Example: x^2 - 5*x + 6 = 0")
.OptionalString(VARIABLE_ARGUMENT, "For differentiate, integrate, limit, and solve: the variable to differentiate by, integrate over, approach in, or solve for, e.g., x. Leave it out when the expression has only one variable, and always for simplify, expand, and factor.")
.OptionalInteger(ORDER_ARGUMENT, $"Only for differentiate: how often to differentiate, from 1 to {MAX_ORDER}. Leave it out for the first derivative.")
.OptionalString(LOWER_BOUND_ARGUMENT, "Only for integrate: the lower bound of a definite integral, such as 0 or -oo. Give both bounds or none; leave both out for an antiderivative.")
.OptionalString(UPPER_BOUND_ARGUMENT, "Only for integrate: the upper bound of a definite integral, such as pi or +oo.")
.OptionalString(APPROACH_ARGUMENT, "Only for limit, and required there: the point the variable approaches, such as 0 or +oo.")
.OptionalEnum(DIRECTION_ARGUMENT, "Only for limit: approach from both sides, from the left, or from the right. Leave it out for both sides.", DIRECTION_NAMES)
.Build(),
},
};
public string Icon => Icons.Material.Filled.Functions;
public bool IsAvailable => PreviewFeatures.PRE_MATHEMATICS_2026.IsEnabled(settingsManager);
// Everything is computed locally:
public ToolOutboundData OutboundData => ToolOutboundData.NONE;
public IReadOnlySet<string> SensitiveTraceArgumentNames => new HashSet<string>(StringComparer.Ordinal);
public string GetDisplayName() => TB("Compute Symbolically");
public string GetDescription() => TB("Lets the AI work with formulas: simplify and factor them, differentiate and integrate them, take limits, and solve equations. Every result is checked numerically where possible, right on your computer.");
public async Task<ToolExecutionResult> ExecuteAsync(JsonElement arguments, ToolExecutionContext context, CancellationToken token = default)
{
var request = ReadRequest(arguments);
var result = await computationService.ComputeAsync(_ => Compute(request), token);
return new ToolExecutionResult { JsonContent = result };
}
/// <summary>
/// Reads the arguments, and refuses each one which the operation does not use.
/// </summary>
internal static SymbolicRequest ReadRequest(JsonElement arguments)
{
var operationName = ToolArgumentReader.ReadRequiredString(arguments, OPERATION_ARGUMENT);
if (!OPERATIONS.TryGetValue(operationName, out var operation))
throw new ArgumentException($"Argument '{OPERATION_ARGUMENT}' must be one of {string.Join(", ", OPERATION_NAMES)}, but was '{operationName.Shorten(MAX_ECHO_LENGTH)}'.");
var expression = ToolArgumentReader.ReadRequiredString(arguments, EXPRESSION_ARGUMENT);
var variable = ToolArgumentReader.ReadOptionalString(arguments, VARIABLE_ARGUMENT, "to take the only variable of the expression");
var order = ToolArgumentReader.ReadOptionalPositiveInt(arguments, ORDER_ARGUMENT, "for the first derivative");
var lowerBound = ToolArgumentReader.ReadOptionalString(arguments, LOWER_BOUND_ARGUMENT, "together with the upper bound for an antiderivative");
var upperBound = ToolArgumentReader.ReadOptionalString(arguments, UPPER_BOUND_ARGUMENT, "together with the lower bound for an antiderivative");
var approach = ToolArgumentReader.ReadOptionalString(arguments, APPROACH_ARGUMENT, "for any operation but limit");
var direction = ToolArgumentReader.ReadOptionalChoice(arguments, DIRECTION_ARGUMENT, DIRECTION_NAMES, "to approach from both sides");
if (variable is not null && operation is SymbolicOperation.SIMPLIFY or SymbolicOperation.EXPAND or SymbolicOperation.FACTOR)
throw NotUsed(VARIABLE_ARGUMENT, operationName);
if (variable is "")
throw new ArgumentException($"Argument '{VARIABLE_ARGUMENT}' must name a variable, such as x, but was empty. Leave it out to take the only variable of the expression.");
if (order is not null && operation is not SymbolicOperation.DIFFERENTIATE)
throw NotUsed(ORDER_ARGUMENT, operationName);
if (order > MAX_ORDER)
throw new ArgumentException($"Argument '{ORDER_ARGUMENT}' must be at most {MAX_ORDER}, but was {order}. Leave it out for the first derivative.");
if ((lowerBound is not null || upperBound is not null) && operation is not SymbolicOperation.INTEGRATE)
throw NotUsed(lowerBound is not null ? LOWER_BOUND_ARGUMENT : UPPER_BOUND_ARGUMENT, operationName);
if ((lowerBound is null) != (upperBound is null))
throw new ArgumentException($"Argument '{(lowerBound is null ? UPPER_BOUND_ARGUMENT : LOWER_BOUND_ARGUMENT)}' needs argument '{(lowerBound is null ? LOWER_BOUND_ARGUMENT : UPPER_BOUND_ARGUMENT)}' as well, since a definite integral has two bounds. Leave both out for an antiderivative.");
if (approach is not null && operation is not SymbolicOperation.LIMIT)
throw NotUsed(APPROACH_ARGUMENT, operationName);
if (approach is null && operation is SymbolicOperation.LIMIT)
throw new ArgumentException($"Argument '{APPROACH_ARGUMENT}' is required for the operation 'limit': it names the point the variable approaches, such as 0 or +oo.");
if (direction is not null && operation is not SymbolicOperation.LIMIT)
throw NotUsed(DIRECTION_ARGUMENT, operationName);
return new(operation, expression, variable, order ?? 1, lowerBound, upperBound, approach, direction is null ? ApproachFrom.BothSides : DIRECTIONS[direction]);
}
private static ArgumentException NotUsed(string argument, string operationName) => new($"Argument '{argument}' is not used by the operation '{operationName}'. Leave it out.");
/// <summary>
/// Carries out the operation and writes the result for the model.
/// </summary>
internal static JsonObject Compute(SymbolicRequest request)
{
var expression = MathExpressionReader.Read(request.Expression, EXPRESSION_ARGUMENT);
MathGuards.CheckExactSize(expression);
MathGuards.CheckExpansion(expression);
if (expression is Entity.Statement && request.Operation is not SymbolicOperation.SOLVE)
throw new MathComputationException($"Argument '{EXPRESSION_ARGUMENT}' is a statement, such as an equation or a comparison, which only the operation 'solve' takes.");
var json = new JsonObject
{
["operation"] = OPERATIONS.First(pair => pair.Value == request.Operation).Key,
["expression"] = MathFormatting.ToText(expression, "The expression as read"),
["latex"] = MathFormatting.ToLatex(expression, "The LaTeX of the expression"),
};
var issues = new List<string>();
switch (request.Operation)
{
case SymbolicOperation.SIMPLIFY:
Transformed(json, issues, expression, expression.Simplify(), "The tool found nothing to simplify.");
break;
case SymbolicOperation.EXPAND:
Transformed(json, issues, expression, expression.Expand(), "The tool left the expression as it is, e.g., since its expansion would have more than 2,000 terms.");
break;
case SymbolicOperation.FACTOR:
Transformed(json, issues, expression, expression.Factorize(), "The tool found no factorization.");
break;
case SymbolicOperation.DIFFERENTIATE:
Differentiate(json, issues, expression, VariableOf(request, expression, "differentiate by"), request.Order);
break;
case SymbolicOperation.INTEGRATE:
Integrate(json, issues, expression, VariableOf(request, expression, "integrate over"), request);
break;
case SymbolicOperation.LIMIT:
Limit(json, issues, expression, VariableOf(request, expression, "take the limit in"), request);
break;
case SymbolicOperation.SOLVE:
Solve(json, issues, expression, VariableOf(request, expression, "solve for"));
break;
}
if (issues.Count > 0)
json["issues"] = new JsonArray([..issues.Select(issue => JsonValue.Create(issue))]);
return json;
}
/// <summary>
/// The variable of the operation: the one the model named, or else the only one of the expression.
/// </summary>
private static Entity.Variable VariableOf(SymbolicRequest request, Entity expression, string verb)
{
if (request.Variable is not null)
{
var named = MathExpressionReader.Read(request.Variable, VARIABLE_ARGUMENT);
if (named is not Entity.Variable variable || named is Entity.Constant)
throw new MathComputationException($"Argument '{VARIABLE_ARGUMENT}' must name a variable, such as x, but was '{request.Variable.Shorten(MAX_ECHO_LENGTH)}'.");
return variable;
}
var variables = MathVariables.FreeIn(expression);
return variables.Count switch
{
1 => variables[0],
0 => throw new MathComputationException($"Argument '{EXPRESSION_ARGUMENT}' has no variable to {verb}."),
_ => throw new MathComputationException($"Argument '{EXPRESSION_ARGUMENT}' has the variables {string.Join(", ", variables.Select(variable => variable.Name))}, so argument '{VARIABLE_ARGUMENT}' has to say which one to {verb}."),
};
}
private static void Transformed(JsonObject json, List<string> issues, Entity expression, Entity result, string unchanged)
{
if (result.Equals(expression))
issues.Add(unchanged);
Result(json, issues, expression, result);
json["complete"] = true;
json["verification"] = Verification(NumericCheck.AreEqual(expression, result));
}
private static void Differentiate(JsonObject json, List<string> issues, Entity expression, Entity.Variable variable, int order)
{
// One order at a time, so each step is checked against the one before:
var derivative = expression;
var outcome = NumericCheckOutcome.PASSED;
for (var step = 0; step < order; step++)
{
var next = derivative.Differentiate(variable).Simplify();
outcome = Combined(outcome, NumericCheck.IsDerivativeOf(next, derivative, variable));
derivative = next;
}
json["variable"] = variable.Name;
json["order"] = order;
Result(json, issues, expression, derivative);
json["complete"] = true;
json["verification"] = Verification(outcome);
}
private static void Integrate(JsonObject json, List<string> issues, Entity expression, Entity.Variable variable, SymbolicRequest request)
{
json["variable"] = variable.Name;
if (request.LowerBound is null || request.UpperBound is null)
{
var antiderivative = expression.Integrate(variable);
var complete = !Contains<Entity.Integralf>(antiderivative);
Result(json, issues, expression, antiderivative);
json["complete"] = complete;
json["verification"] = Verification(complete ? NumericCheck.IsDerivativeOf(expression, antiderivative, variable) : NumericCheckOutcome.NOT_POSSIBLE);
if (!complete)
issues.Add("The tool found no antiderivative in closed form.");
return;
}
var lower = MathExpressionReader.Read(request.LowerBound, LOWER_BOUND_ARGUMENT);
var upper = MathExpressionReader.Read(request.UpperBound, UPPER_BOUND_ARGUMENT);
MathGuards.CheckExactSize(lower);
MathGuards.CheckExactSize(upper);
json["lower_bound"] = MathFormatting.ToText(lower, "The lower bound");
json["upper_bound"] = MathFormatting.ToText(upper, "The upper bound");
var integral = MathS.Integral(expression, variable, lower, upper).InnerSimplified.Simplify();
var closed = !Contains<Entity.Integralf>(integral);
Result(json, issues, expression, integral);
json["complete"] = closed;
if (!closed)
issues.Add("The tool found no closed form for this integral.");
else if (IsNaN(integral))
issues.Add("The result is NaN: the integral has no value, e.g., since it diverges across a pole.");
json["verification"] = Verification(closed ? CheckByQuadrature(integral, expression, variable, lower, upper) : NumericCheckOutcome.NOT_POSSIBLE);
}
/// <summary>
/// Compares the value of a definite integral with its numerical integration, where both are numbers.
/// </summary>
private static NumericCheckOutcome CheckByQuadrature(Entity integral, Entity integrand, Entity.Variable variable, Entity lower, Entity upper)
{
if (MathVariables.FreeIn(integrand).Any(free => free != variable) || MathVariables.HasAny(lower) || MathVariables.HasAny(upper) || MathVariables.HasAny(integral))
return NumericCheckOutcome.NOT_POSSIBLE;
try
{
var value = NumericEvaluator.EvaluateAt(integral, DOUBLE_PRECISION);
if (!value.ImaginaryPart.EDecimal.IsZero)
return NumericCheckOutcome.NOT_POSSIBLE;
var exact = value.RealPart.EDecimal.ToDouble();
var numeric = DoubleExponentialQuadrature.Integrate(integrand.Compile<double, double>(variable), BoundOf(lower), BoundOf(upper));
if (numeric.Status is not QuadratureStatus.CONVERGED || !double.IsFinite(exact))
return NumericCheckOutcome.NOT_POSSIBLE;
var tolerance = Math.Max(10 * numeric.EstimatedError, MIN_QUADRATURE_TOLERANCE * Math.Max(1, Math.Abs(exact)));
return Math.Abs(exact - numeric.Value) <= tolerance ? NumericCheckOutcome.PASSED : NumericCheckOutcome.FAILED;
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
return NumericCheckOutcome.NOT_POSSIBLE;
}
}
private static double BoundOf(Entity bound)
{
var value = NumericEvaluator.EvaluateAt(bound, DOUBLE_PRECISION).RealPart.EDecimal;
if (value.IsInfinity())
return value.IsNegative ? double.NegativeInfinity : double.PositiveInfinity;
return value.ToDouble();
}
private static void Limit(JsonObject json, List<string> issues, Entity expression, Entity.Variable variable, SymbolicRequest request)
{
var approach = MathExpressionReader.Read(request.Approach!, APPROACH_ARGUMENT);
MathGuards.CheckExactSize(approach);
var limit = expression.Limit(variable, approach, request.Direction);
var complete = !Contains<Entity.Limitf>(limit);
json["variable"] = variable.Name;
json["approach"] = MathFormatting.ToText(approach, "The point approached");
json["direction"] = DIRECTIONS.First(pair => pair.Value == request.Direction).Key;
Result(json, issues, expression, limit);
json["complete"] = complete;
json["verification"] = Verification(NumericCheckOutcome.NOT_POSSIBLE);
if (!complete)
issues.Add("The tool could not determine this limit.");
else if (IsNaN(limit))
issues.Add("The result is NaN, which the tool gives where the limit does not exist, e.g., where the one-sided limits differ.");
}
private static void Solve(JsonObject json, List<string> issues, Entity expression, Entity.Variable variable)
{
var statement = expression;
if (expression is not Entity.Statement)
{
statement = new Entity.Equalsf(expression, 0);
issues.Add("The expression has no '=', so it was solved for expression = 0.");
}
var solutions = statement.Solve(variable);
json["variable"] = variable.Name;
json["result"] = MathFormatting.ToText(solutions, "The set of solutions");
json["result_latex"] = MathFormatting.ToLatex(solutions, "The LaTeX of the solutions");
ExplainParameters(issues, expression, solutions);
if (solutions is Entity.Set.ConditionalSet)
{
json["complete"] = false;
json["verification"] = Verification(NumericCheckOutcome.NOT_POSSIBLE);
issues.Add("The tool found no solution in closed form; the result restates the equation as a set.");
return;
}
json["complete"] = true;
if (solutions is not Entity.Set.FiniteSet finite)
{
json["verification"] = Verification(NumericCheckOutcome.NOT_POSSIBLE);
return;
}
var outcome = finite.Count == 0 || statement is not Entity.Equalsf ? NumericCheckOutcome.NOT_POSSIBLE : NumericCheckOutcome.PASSED;
var list = new JsonArray();
foreach (var solution in finite)
{
list.Add(Solution(solution));
if (statement is Entity.Equalsf equation)
outcome = Combined(outcome, NumericCheck.AreEqual(equation.Left.Substitute(variable, solution), equation.Right.Substitute(variable, solution)));
}
json["solutions"] = list;
json["verification"] = Verification(outcome);
if (finite.Count == 0)
issues.Add("There is no solution.");
}
private static JsonObject Solution(Entity solution)
{
var json = new JsonObject
{
["value"] = MathFormatting.ToText(solution, "A solution"),
["latex"] = MathFormatting.ToLatex(solution, "The LaTeX of a solution"),
};
if (MathVariables.HasAny(solution))
return json;
try
{
var value = NumericEvaluator.Evaluate(solution, SOLUTION_DIGITS, CancellationToken.None);
json["decimal"] = MathFormatting.FormatNumber(value.Value, Math.Max(value.Digits, 1));
json["real"] = value.Value.ImaginaryPart.EDecimal.IsZero;
}
catch (MathComputationException)
{
// A solution without a value, such as NaN, keeps only its exact form.
}
return json;
}
/// <summary>
/// Writes a result, with its condition apart when AngouriMath states one.
/// </summary>
private static void Result(JsonObject json, List<string> issues, Entity expression, Entity result)
{
if (result is Entity.Providedf provided)
{
json["result"] = MathFormatting.ToText(provided.Expression, "The result");
json["result_latex"] = MathFormatting.ToLatex(provided.Expression, "The LaTeX of the result");
json["condition"] = MathFormatting.ToText(provided.Predicate, "The condition of the result");
}
else
{
json["result"] = MathFormatting.ToText(result, "The result");
json["result_latex"] = MathFormatting.ToLatex(result, "The LaTeX of the result");
}
ExplainParameters(issues, expression, result);
}
/// <summary>
/// Says what the names mean which AngouriMath brought into the result, such as C for the constant of integration.
/// </summary>
private static void ExplainParameters(List<string> issues, Entity expression, Entity result)
{
var original = MathVariables.FreeIn(expression).Select(variable => variable.Name).ToHashSet(StringComparer.Ordinal);
foreach (var variable in MathVariables.FreeIn(result).Where(variable => !original.Contains(variable.Name)))
{
if (NumericCheck.IsIntegerParameter(variable))
issues.Add($"{variable.Name} stands for any integer.");
else if (variable.Name.StartsWith("r_", StringComparison.Ordinal))
issues.Add($"{variable.Name} stands for any real number.");
else if (variable.Name == "C" || variable.Name.StartsWith("C_", StringComparison.Ordinal))
issues.Add($"{variable.Name} stands for any constant.");
}
}
private static bool Contains<TNode>(Entity expression) where TNode : Entity => expression.Nodes.Any(node => node is TNode);
private static bool IsNaN(Entity expression) => expression is Entity.Number.Complex number && (number.RealPart.EDecimal.IsNaN() || number.ImaginaryPart.EDecimal.IsNaN());
private static NumericCheckOutcome Combined(NumericCheckOutcome first, NumericCheckOutcome second) => (first, second) switch
{
(NumericCheckOutcome.FAILED, _) or (_, NumericCheckOutcome.FAILED) => NumericCheckOutcome.FAILED,
(NumericCheckOutcome.NOT_POSSIBLE, _) or (_, NumericCheckOutcome.NOT_POSSIBLE) => NumericCheckOutcome.NOT_POSSIBLE,
_ => NumericCheckOutcome.PASSED,
};
private static string Verification(NumericCheckOutcome outcome) => outcome switch
{
NumericCheckOutcome.PASSED => "passed",
NumericCheckOutcome.FAILED => "failed",
_ => "not_possible",
};
}
@@ -0,0 +1,34 @@
using AIStudio.Provider;
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mathematics;
/// <summary>
/// The tools which compute locally: Calculate and Compute Symbolically.
/// </summary>
/// <remarks>
/// Both read the same syntax and share one computing slot, see MathComputationService, and a model
/// which works with formulas needs both: symbolic answers are checked with numbers, and numbers come
/// from formulas. They see nothing but what the model writes into their arguments, and nothing leaves
/// AI Studio, so the collection asks for no provider confidence. More tools of this kind, such as
/// approximations or fits, belong here; a tool which reads local data does not, since it needs a
/// confidence of its own.
/// </remarks>
public sealed class MathematicsToolCollection : IToolCollection
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(MathematicsToolCollection).Namespace, nameof(MathematicsToolCollection));
public ToolCollectionDefinition GetDefinition() => new()
{
Id = ToolSelectionRules.MATHEMATICS_COLLECTION_ID,
ToolIds = [ToolSelectionRules.CALCULATE_TOOL_ID, ToolSelectionRules.COMPUTE_SYMBOLICALLY_TOOL_ID],
MinimumProviderConfidence = ConfidenceLevel.NONE,
DescriptionForLLM = "Calculate exactly or to a stated number of digits, and work with formulas: simplify, factor, differentiate, integrate, take limits, and solve equations. Everything is computed locally, and each result is checked numerically where possible.",
};
public string Icon => Icons.Material.Filled.Calculate;
public string GetDisplayName() => TB("Mathematics");
public string GetDescription() => TB("Lets the AI calculate exactly and work with formulas, such as solving equations or checking an integral, right on your computer.");
}
@@ -0,0 +1,15 @@
namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mathematics;
/// <summary>
/// What compute_symbolically does with an expression.
/// </summary>
public enum SymbolicOperation
{
SIMPLIFY = 0,
EXPAND,
FACTOR,
DIFFERENTIATE,
INTEGRATE,
LIMIT,
SOLVE,
}
@@ -0,0 +1,16 @@
using AngouriMath.Core;
namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mathematics;
/// <summary>
/// The arguments of a call of compute_symbolically, checked against the operation, but not yet read as mathematics.
/// </summary>
/// <param name="Operation">What to do.</param>
/// <param name="Expression">The expression, as the model wrote it.</param>
/// <param name="Variable">The variable to differentiate, integrate, approach, or solve for, or null to take the only one.</param>
/// <param name="Order">How often to differentiate.</param>
/// <param name="LowerBound">The lower bound of a definite integral, or null for an indefinite one.</param>
/// <param name="UpperBound">The upper bound of a definite integral, or null for an indefinite one.</param>
/// <param name="Approach">The point a limit approaches, or null for any other operation.</param>
/// <param name="Direction">From where the limit approaches.</param>
public sealed record SymbolicRequest(SymbolicOperation Operation, string Expression, string? Variable, int Order, string? LowerBound, string? UpperBound, string? Approach, ApproachFrom Direction);
@@ -15,6 +15,9 @@ public static class ToolSelectionRules
public const string READ_MAIL_TOOL_ID = "read_mail";
public const string COUNT_MAILS_TOOL_ID = "count_mails";
public const string MAILBOXES_COLLECTION_ID = "mailboxes";
public const string CALCULATE_TOOL_ID = "calculate";
public const string COMPUTE_SYMBOLICALLY_TOOL_ID = "compute_symbolically";
public const string MATHEMATICS_COLLECTION_ID = "mathematics";
public static string GetMaxToolCallsFinalResponseInstruction() => $"The maximum of {MAX_TOOL_CALLS} tool calls has been reached. No more tools are available. Provide the best possible final answer to the user based on the tool results already available.";
+32
View File
@@ -2,6 +2,18 @@
"version": 1,
"dependencies": {
"net10.0": {
"AngouriMath": {
"type": "Direct",
"requested": "[2.6.0, )",
"resolved": "2.6.0",
"contentHash": "2PCeqX7NAOHQcTprqVJJoPjzO137mdEDxdy3/o1dJd8sn3CGjJkEH5rxbKRXmLo8bnsTpLymi8qKC9h5aU/7wA==",
"dependencies": {
"Antlr4.Runtime.Standard": "4.13.1",
"GenericTensor": "1.0.4",
"HonkSharp": "1.0.3",
"PeterO.Numbers": "1.8.0"
}
},
"CodeBeam.MudBlazor.Extensions": {
"type": "Direct",
"requested": "[9.2.0, )",
@@ -119,6 +131,11 @@
"SQLitePCLRaw.config.e_sqlite3": "3.0.5"
}
},
"Antlr4.Runtime.Standard": {
"type": "Transitive",
"resolved": "4.13.1",
"contentHash": "Da5+i4kFHUseJRZGcBG5fmZGpA/Ns180ibrQMxgZzjpQOnENVvSL5gi5HZ8Ncz8/AR2WsKbOg2lMBzjz0HUQcA=="
},
"BouncyCastle.Cryptography": {
"type": "Transitive",
"resolved": "2.7.0",
@@ -132,6 +149,16 @@
"System.IO.Packaging": "10.0.2"
}
},
"GenericTensor": {
"type": "Transitive",
"resolved": "1.0.4",
"contentHash": "sQsqRbJiwFH37OjriIKQx3VWKuRJ09s/SZdwqL0FreJbFSpbpY1UKeiP8RWsb88QcS2DgyicheyvdJ9YgLnUpQ=="
},
"HonkSharp": {
"type": "Transitive",
"resolved": "1.0.3",
"contentHash": "nV2VQhjXCuF7UC730/Pmb+CLfsdZad2J1JCNqlVCHjgBbn41LWODrJv5LDQPUKYIjZJC66pR9rSmSmyalKF7fA=="
},
"LuaCSharp.Annotations": {
"type": "Transitive",
"resolved": "0.5.8",
@@ -199,6 +226,11 @@
"System.Security.Cryptography.Pkcs": "10.0.13"
}
},
"PeterO.Numbers": {
"type": "Transitive",
"resolved": "1.8.0",
"contentHash": "N8ffDC+jwF1XcA+VfugZO1+wFClOvWjyXzXrstkpdjkgH2LgAfnEZIAFQbKgidHtTh5RAfwhCtH3MSfgZXxXwA=="
},
"SQLite": {
"type": "Transitive",
"resolved": "3.53.4",
@@ -3,6 +3,7 @@
- Added formulas in code blocks to the chat: when an AI model puts a single formula into a code block marked as LaTeX, the chat now shows the formula instead of its code. A code block with a LaTeX document or with text in it stays code.
- Added buttons to copy and save the formulas in the chat. When you point at a formula, you can copy its LaTeX code or save it as a LaTeX file, and the export menu of an answer offers its formulas as well.
- Added support for OneNote sections (`.one`): you can attach them to chats and assistants and use them in your local data sources. AI Studio reads every page, including the text OneNote recognized in pictures and handwriting.
- Added the Mathematics tools as a prototype: the AI can now calculate exactly and work with formulas, right on your computer. It keeps fractions exact, gives results with up to 100 digits, solves equations, differentiates and integrates, and can check whether an integral really has the value someone claims. AI Studio checks the results numerically wherever possible, and the AI tells you when a result is only approximate. To try it, open the app settings, allow preview features down to prototype, enable Mathematics, and then choose Mathematics among the tools of a chat.
- Improved the documentation for IT departments, which now lives in our [wiki](https://github.com/MindWorkAI/AI-Studio/wiki/Enterprise-IT). It is sorted by task, explains the setup on Windows, Linux, and macOS, and offers complete configurations for typical needs, such as using only local models. The link in the app settings leads there, and the wiki also holds the installation guide in English and German.
- Improved the German translation throughout: we proofread all texts and corrected wrong labels as well as spelling and grammar mistakes. The texts now always address you formally and use the same terms everywhere.
- Improved the security of the connection between the app window and AI Studio.
@@ -27,6 +28,7 @@
- Fixed the result of a tool in the chat showing umlauts, quotation marks, and other special characters as codes instead of the characters themselves.
- Fixed the Assistant Builder telling you in English why it could not use the answer of the AI model, even when you chose another language for AI Studio.
- Fixed requests to a provider failing when a configuration plugin gave it additional API parameters that contain the same parameter twice. AI Studio now sends the request without these parameters and names the repeated parameter in its log, as it does for other invalid parameters.
- Fixed the information page leaving out three libraries that AI Studio uses: MailKit and MimeKit, which read your mailboxes, and the Open XML SDK, with which the Slide Assistant reads and writes PowerPoint files.
- Upgraded several libraries to improve security.
- Upgraded Tauri to v2.12.5.
- Upgraded MudBlazor to v9.12.0.
@@ -0,0 +1,426 @@
using AngouriMath;
using AngouriMath.Core;
using AngouriMath.Core.Exceptions;
using PeterO.Numbers;
namespace AIStudio.Tests.Tools.Mathematics;
/// <summary>
/// Pins the behavior of AngouriMath which the Mathematics tools build on.
/// </summary>
/// <remarks>
/// AngouriMath puts correct answers ahead of backward compatibility, and says so: the release notes of
/// 2.6.0 list 516 changed answers, fourteen of them silent, where the call still succeeds and returns
/// something else. An update which changes anything below fails here first, not in a chat. Each test
/// names what AngouriMath does; where the tools have to guard against it, the test says so.
/// </remarks>
[TestFixture]
public sealed class AngouriMathBehaviorTests
{
private static readonly Entity.Variable X = MathS.Var("x");
private static Entity Parse(string text)
{
using var _ = MathS.Settings.ExplicitParsingOnly.Set(true);
return MathS.FromString(text);
}
private static string DecimalOf(string text, int workingPrecision, int digits)
{
using var _ = MathS.Settings.DecimalPrecisionContext.Set(EContext.ForPrecision(workingPrecision));
return Parse(text).EvalNumerical().RealPart.EDecimal.RoundToPrecision(EContext.ForPrecision(digits)).ToString();
}
[Test]
public void RationalsStayExact()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Parse("547 / 57576").Evaled, Is.EqualTo(Entity.Number.Rational.Create(547, 57576)));
Assert.That(Parse("0.1 + 0.2").Evaled, Is.EqualTo(Entity.Number.Rational.Create(3, 10)), "A decimal literal is read as the rational it writes.");
Assert.That(Parse("1.5e-3").Evaled, Is.EqualTo(Entity.Number.Rational.Create(3, 2000)));
Assert.That(Parse("2^200 / 3^100").Evaled, Is.TypeOf<Entity.Number.Rational>());
Assert.That(Parse("(-8)^(1/3)").Evaled, Is.EqualTo(Entity.Number.Integer.Create(-2)), "The cube root of a negative integer is the real one.");
}
}
[Test]
public void IrrationalValuesAreDecimalsOfTheCurrentPrecision()
{
using (Assert.EnterMultipleScope())
{
Assert.That(MathS.Settings.DecimalPrecisionContext.Value.Precision.ToInt32Checked(), Is.EqualTo(100), "The default precision is 100 significant digits.");
Assert.That(Parse("sqrt(2)").Evaled, Is.TypeOf<Entity.Number.Real>());
Assert.That(Parse("sqrt(2)").InnerSimplified.ToString(), Is.EqualTo("sqrt(2)"), "Simplifying keeps an irrational value exact.");
}
}
[Test]
public void ElementaryFunctionsAreRightToTheLastDigit()
{
using (Assert.EnterMultipleScope())
{
Assert.That(DecimalOf("sin(1)", 60, 60), Is.EqualTo("0.841470984807896506652502321630298999622563060798371065672752"));
Assert.That(DecimalOf("e", 60, 60), Is.EqualTo("2.71828182845904523536028747135266249775724709369995957496697"));
Assert.That(DecimalOf("pi", 60, 60), Is.EqualTo("3.14159265358979323846264338327950288419716939937510582097494"));
Assert.That(DecimalOf("ln(2)", 60, 60), Is.EqualTo("0.693147180559945309417232121458176568075500134360255254120680"));
Assert.That(DecimalOf("sqrt(2)", 60, 60), Is.EqualTo("1.41421356237309504880168872420969807856967187537694807317668"));
}
}
[Test]
public void ACompoundExpressionNeedsGuardDigits()
{
// pi^4/15 is 6.4939394022668291490960221792470074166485057115123614460978572926..., see Python's decimal:
using (Assert.EnterMultipleScope())
{
Assert.That(DecimalOf("pi^4/15", 60, 60), Is.Not.EqualTo("6.49393940226682914909602217924700741664850571151236144609786"), "At the precision asked for, the last digits are off: every step rounds.");
Assert.That(DecimalOf("pi^4/15", 70, 60), Is.EqualTo("6.49393940226682914909602217924700741664850571151236144609786"), "Ten guard digits make up for it.");
}
}
[Test]
public void DowncastingTurnsTinyValuesIntoZero()
{
// The Boltzmann constant at 300 K, the energy of a photon of 500 nm, and e^-100:
using (Assert.EnterMultipleScope())
{
Assert.That(MathS.Settings.DowncastingEnabled.Value, Is.True, "AngouriMath downcasts by default.");
Assert.That(DecimalOf("1.380649e-23 * 300", 30, 20), Is.EqualTo("0"), "Downcasting takes a tiny value for the integer 0, so NumericEvaluator.EvaluateAt does without it.");
Assert.That(DecimalOf("6.62607015e-34 * 299792458 / 500e-9", 40, 20), Is.EqualTo("0"));
Assert.That(DecimalOf("e^(-100)", 60, 20), Is.EqualTo("0"), "More digits only lower the bound below which a value vanishes.");
using var withoutDowncasting = MathS.Settings.DowncastingEnabled.Set(false);
Assert.That(DecimalOf("1.380649e-23 * 300", 30, 20), Is.EqualTo("4.14194700E-21"));
Assert.That(DecimalOf("6.62607015e-34 * 299792458 / 500e-9", 30, 20), Is.EqualTo("3.97289171430E-19"), "500e-9 is read as the fraction 1/2000000, and dividing by it rounds to 30 decimal places, see below.");
Assert.That(DecimalOf("6.62607015e-34 * 299792458 / 500e-9", 60, 20), Is.EqualTo("3.9728917142978574000E-19"));
Assert.That(DecimalOf("e^(-100)", 30, 20), Is.EqualTo("3.7200759760208359630E-44"));
}
}
[Test]
public void DowncastingWhileParsingLosesTinyLiterals()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Parse("1e-60"), Is.EqualTo(Entity.Number.Integer.Create(0)), "A literal below about 1e-50 is read as 0, so MathExpressionReader parses without downcasting.");
Assert.That(Parse("6.62607015e-34"), Is.TypeOf<Entity.Number.Real>(), "Unlike 0.1, a literal with a long denominator stays an inexact decimal.");
Assert.That(Parse("1 + 1e-40").InnerSimplified, Is.EqualTo(Entity.Number.Integer.Create(1)), "Simplifying drops an inexact decimal from a sum, so MathExpressionReader turns every literal into its fraction.");
Assert.That(Entity.Number.Rational.Create(EInteger.One, EInteger.FromInt32(10).Pow(200)).EDecimal.IsZero, Is.True, "A fraction converts to a fixed number of decimal places, so NumericEvaluator converts an exact value itself.");
Assert.That(Parse("pi/10^1500").InnerSimplified, Is.EqualTo(Entity.Number.Integer.Create(0)), "Simplifying takes a value below about 1e-1000 for 0, so NumericEvaluator checks an exact 0 numerically.");
Assert.That(Parse("pi/10^1000").InnerSimplified, Is.TypeOf<Entity.Divf>());
// Where it evaluates a function numerically, the bound lies near the precision instead:
using var lowerPrecision = MathS.Settings.DecimalPrecisionContext.Set(EContext.ForPrecision(50));
Assert.That(MathS.FromString("132521403/200000000000000000000000000000000000000000 * sin(61/100)", useCache: false).InnerSimplified, Is.EqualTo(Entity.Number.Integer.Create(0)), "So NumericCheck evaluates an exact 0 numerically.");
}
}
[Test]
public void WithoutDowncastingAQuotientOfIntegersIsRoundedToDecimalPlaces()
{
using var withoutDowncasting = MathS.Settings.DowncastingEnabled.Set(false);
using (Assert.EnterMultipleScope())
{
Assert.That(DecimalOf("3/10^50 * 7", 30, 20), Is.EqualTo("0E-30"), "At 30 digits, the quotient keeps 30 decimal places, so NumericEvaluator.EvaluateAt adds the leading zeros of the smallest fraction to the precision.");
Assert.That(DecimalOf("3/10^50 * 7", 80, 20), Is.EqualTo("2.1000000000000000000E-49"));
Assert.That(DecimalOf("2^(-200)", 30, 20), Is.EqualTo("6.2230152778611417071E-61"), "A power keeps its significant digits.");
}
}
[Test]
public void SettingsBelongToTheFlowWhichSetsThem()
{
var inner = Task.Run(() =>
{
using var _ = MathS.Settings.DecimalPrecisionContext.Set(EContext.ForPrecision(30));
return MathS.Settings.DecimalPrecisionContext.Value.Precision.ToInt32Checked();
}).Result;
using (Assert.EnterMultipleScope())
{
Assert.That(inner, Is.EqualTo(30));
Assert.That(MathS.Settings.DecimalPrecisionContext.Value.Precision.ToInt32Checked(), Is.EqualTo(100), "A setting made in another flow does not leak into this one.");
}
}
[Test]
public void ExplicitParsingRefusesImplicitMultiplication()
{
// Without explicit parsing, x2 is x^2, sin x is the variable sin times x, and foo(3) is foo times 3:
using (Assert.EnterMultipleScope())
{
Assert.That(MathS.FromString("x2").ToString(), Is.EqualTo("x ^ 2"));
Assert.That(MathS.FromString("sin x").ToString(), Is.EqualTo("sin * x"));
Assert.That(MathS.FromString("foo(3)").ToString(), Is.EqualTo("foo * 3"));
foreach (var text in new[] { "2x", "x y", "x2", "sin x", "foo(3)", "2 pi", "x(x+1)", "(x+1)(x-1)" })
Assert.Throws<MissingOperatorParseException>(() => Parse(text), text);
}
}
[Test]
public void ExplicitParsingReadsTheSyntaxTheToolsDocument()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Parse("2*(x+1)").ToString(), Is.EqualTo("2 * (x + 1)"));
Assert.That(Parse("-x^2").ToString(), Is.EqualTo("-x ^ 2"));
Assert.That(Parse("3!").Evaled, Is.EqualTo(Entity.Number.Integer.Create(6)));
Assert.That(Parse("log(100)").Evaled, Is.EqualTo(Entity.Number.Integer.Create(2)), "log without a base is the decimal logarithm.");
Assert.That(Parse("log(2, 8)").Evaled, Is.EqualTo(Entity.Number.Integer.Create(3)), "The base comes first.");
Assert.That(Parse("ln(e)").Evaled, Is.EqualTo(Entity.Number.Integer.Create(1)));
Assert.That(Parse("exp(x)").ToString(), Is.EqualTo("e ^ x"));
Assert.That(Parse("e^(i*pi)").Evaled, Is.EqualTo(Entity.Number.Integer.Create(-1)));
Assert.That(Parse("π"), Is.TypeOf<Entity.Constant>());
Assert.That(Parse("gamma(5)").Evaled, Is.EqualTo(Entity.Number.Integer.Create(24)));
Assert.That(Parse("binomial(5, 2)").Evaled, Is.EqualTo(Entity.Number.Integer.Create(10)));
Assert.That(Parse("abs(-2)").Evaled, Is.EqualTo(Entity.Number.Integer.Create(2)));
Assert.That(Parse("a <> b").ToString(), Is.EqualTo("not a = b"));
Assert.That(Parse("x_1 + x_2").ToString(), Is.EqualTo("x_1 + x_2"));
Assert.That(Parse("integral(x^3/(e^x-1), x, 0, +oo)"), Is.TypeOf<Entity.Integralf>());
Assert.That(Parse("limit(sin(x)/x, x, 0)"), Is.TypeOf<Entity.Limitf>());
Assert.That(Parse("+oo"), Is.TypeOf<Entity.Number.Real>());
}
}
[Test]
public void SomeSpellingsReadAsSomethingElse()
{
// The tools refuse these before parsing:
using (Assert.EnterMultipleScope())
{
Assert.That(Parse("a != b").ToString(), Is.EqualTo("a! = b"), "!= is the factorial of a, equated to b.");
Assert.That(Parse("oo"), Is.TypeOf<Entity.Variable>(), "Only +oo and -oo are infinite; oo is a variable.");
Assert.That(Parse("x | y"), Is.TypeOf<Entity.Dividesf>(), "| is divisibility, since 2.6.0.");
Assert.That(Parse("2 = 2 = 2").ToString(), Is.EqualTo("2 = 2 and 2 = 2"), "A chain of equations is a conjunction.");
}
}
[Test]
public void UnreadableInputThrowsAParseException()
{
using (Assert.EnterMultipleScope())
{
// Under explicit parsing, a character the lexer drops can leave two operands side by side, as in 2·3:
foreach (var text in new[] { "2 + * 3", "(2 + 3", "", "√2", "x²", "2·3", "1,5", "2**3", "|x|", "\\frac{1}{2}" })
Assert.That(() => Parse(text), Throws.InstanceOf<ParseException>(), text);
Assert.Throws<FunctionArgumentCountException>(() => Parse("sin(1, 2)"));
Assert.Throws<FunctionArgumentCountException>(() => Parse("integral(x^2, x, 2)"), "An integral has two or four arguments.");
}
}
[Test]
public void AParseExceptionNamesLineAndColumn()
{
var exception = Assert.Throws<UnhandledParseException>(() => Parse("2 + * 3"))!;
using (Assert.EnterMultipleScope())
{
Assert.That(exception.Message, Does.StartWith("line 1:4 extraneous input '*' expecting {"), "The message lists every token the parser expected, over a thousand characters.");
Assert.That(exception.Message, Does.EndWith(", while parsing `2 + * 3`"));
}
}
[Test]
public void FreeVariablesLeaveOutBoundOnes()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Parse("integral(x^2, x, 0, 1)").FreeVariables, Is.Empty);
Assert.That(Parse("integral(x*a, x, 0, 1)").FreeVariables, Is.EquivalentTo(new[] { MathS.Var("a") }));
Assert.That(Parse("pi + x").FreeVariables, Has.Some.InstanceOf<Entity.Constant>(), "The constants count among the free variables, see MathVariables.");
Assert.That(Parse("ln(0)").FreeVariables.Select(variable => variable.Name), Is.EqualTo(new[] { "e" }), "ln is the logarithm to the base e.");
Assert.That(Parse("x + 1").EvaluableNumerical, Is.False);
Assert.Throws<CannotEvalException>(() => Parse("x + 1").EvalNumerical());
}
}
[Test]
public void ValuesWithoutAMeaningAreNaN()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Parse("1/0").Evaled.ToString(), Is.EqualTo("NaN"));
Assert.That(Parse("0/0").Evaled.ToString(), Is.EqualTo("NaN"));
Assert.That(Parse("(-1)!").Evaled.ToString(), Is.EqualTo("NaN"));
Assert.That(Parse("tan(pi/2)").Evaled.ToString(), Is.EqualTo("NaN"));
Assert.That(Parse("ln(0)").Evaled.ToString(), Is.EqualTo("-oo"));
Assert.That(Parse("sqrt(-1)").Evaled.ToString(), Is.EqualTo("i"));
}
}
[Test]
public void LatexComesFromLatexize()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Parse("547 / 57576").Evaled.Latexize(), Is.EqualTo(@"\frac{547}{57576}"));
Assert.That(Parse("x^3/(e^x-1)").Latexize(), Is.EqualTo(@"\frac{{x}^{3}}{{\mathrm{e}}^{x}-1}"));
Assert.That(Parse("integral(x^3/(e^x-1), x, 0, +oo)").Latexize(), Is.EqualTo(@"\int_{0}^{\infty } \frac{{x}^{3}}{{\mathrm{e}}^{x}-1}\,\mathrm{d}x"));
Assert.That(Parse("(x-1)/(x-1)").Simplify().Latexize(), Is.EqualTo(@"1 \quad \text{for} \quad x-1 \neq 0"));
}
}
[Test]
public void CompiledFunctionsReturnNaNWhereTheyHaveNoValue()
{
var bose = Parse("x^3/(e^x-1)").Compile<double, double>(X);
var root = Parse("sqrt(x)").Compile<double, double>(X);
using (Assert.EnterMultipleScope())
{
Assert.That(bose(2.0), Is.EqualTo(8.0 / (Math.Exp(2.0) - 1.0)).Within(1e-15));
Assert.That(bose(0.0), Is.NaN, "0/0 at the lower bound of the integral; a quadrature must not evaluate there.");
Assert.That(root(-1.0), Is.NaN, "A compiled function stays real.");
Assert.That(Parse("erf(x)").Compile<double, double>(X)(1.0), Is.EqualTo(0.8427007929497149).Within(1e-15));
}
}
[Test]
public void SimplifyStatesWhereItsAnswerHolds()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Parse("sin(x)^2 + cos(x)^2").Simplify(), Is.EqualTo(Entity.Number.Integer.Create(1)));
Assert.That(Parse("(x^2-1)/(x-1)").Simplify(), Is.TypeOf<Entity.Providedf>());
Assert.That(Parse("(x^2-1)/(x-1)").Simplify().ToString(), Is.EqualTo("x + 1 provided not x - 1 = 0"));
Assert.That(Parse("sqrt(x^2)").Simplify().ToString(), Is.EqualTo("sqrt(x ^ 2)"), "Over the complex numbers, sqrt(x^2) is not x.");
}
}
[Test]
public void ExpandAndFactorizeAreInverse()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Parse("(x+1)^3").Expand().ToString(), Is.EqualTo("1 + 3 * x + 3 * x ^ 2 + x ^ 3"));
Assert.That(Parse("x^4 - 1").Factorize().ToString(), Is.EqualTo("(x + 1) * (x - 1) * (x ^ 2 + 1)"));
Assert.That(Parse("x^2 - 5*x + 6").Factorize().ToString(), Is.EqualTo("(x - 3) * (x - 2)"));
}
}
[Test]
public void ExpandLeavesAnExpressionWithTooManyTermsAsItIs()
{
using (Assert.EnterMultipleScope())
{
Assert.That(MathS.Settings.MaxExpansionTermCount.Value, Is.EqualTo(2000));
Assert.That(Parse("(x+y+z+w)^60").Expand().ToString(), Is.EqualTo("(x + y + z + w) ^ 60"), "39,711 terms are above the limit.");
Assert.That(Parse("(x+y)^100").Expand(), Is.TypeOf<Entity.Sumf>());
}
}
[Test]
public void DifferentiateNeedsASimplifyAfterwards()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Parse("x^5").Differentiate(X, 3).ToString(), Is.EqualTo("3 * x ^ 2 * 4 * 5"));
Assert.That(Parse("x^5").Differentiate(X, 3).Simplify().ToString(), Is.EqualTo("60 * x ^ 2"));
Assert.That(Parse("x^2*sin(x)").Differentiate(X).Simplify().ToString(), Is.EqualTo("x ^ 2 * cos(x) + 2 * x * sin(x)"));
}
}
[Test]
public void AnIndefiniteIntegralEndsInAConstantOrStaysAnIntegral()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Parse("x^2").Integrate(X).ToString(), Is.EqualTo("x ^ 3 / 3 + C"));
Assert.That(Parse("C*x").Integrate(X).ToString(), Is.EqualTo("C * x ^ 2 / 2 + C_1"), "The constant takes a name the expression does not use.");
Assert.That(Parse("e^(-x^2)").Integrate(X).ToString(), Is.EqualTo("sqrt(pi) / 2 * erf(x) + C"));
Assert.That(Parse("sin(sin(x))").Integrate(X), Is.TypeOf<Entity.Integralf>(), "Without a closed form, the integral comes back.");
}
}
[Test]
public void ADefiniteIntegralIsExactThroughItsNode()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Parse("integral(x^2, x, 0, 1)").InnerSimplified, Is.EqualTo(Entity.Number.Rational.Create(1, 3)));
Assert.That(Parse("integral(sin(x), x, 0, pi)").InnerSimplified, Is.EqualTo(Entity.Number.Integer.Create(2)));
Assert.That(Parse("integral(ln(x), x, 0, 1)").InnerSimplified, Is.EqualTo(Entity.Number.Integer.Create(-1)), "An integrable singularity at a bound.");
Assert.That(Parse("integral(1/(1+x^2), x, 0, +oo)").InnerSimplified.Simplify().ToString(), Is.EqualTo("pi / 2"));
Assert.That(Parse("integral(1/x, x, -1, 1)").InnerSimplified.ToString(), Is.EqualTo("NaN"), "It diverges across the pole.");
Assert.That(Parse("integral(x^3/(e^x-1), x, 0, +oo)").InnerSimplified, Is.TypeOf<Entity.Integralf>(), "No closed form is found for pi^4/15.");
Assert.That(Parse("integral(x^3/(e^x-1), x, 0, +oo)").Evaled.ToString(), Does.Contain("2.718281828459045"), "Evaled replaces the constants with decimals, even inside an integral it cannot take.");
}
}
[Test]
public void ALimitIsNaNWhereItDoesNotExist()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Parse("sin(x)/x").Limit(X, 0), Is.EqualTo(Entity.Number.Integer.Create(1)));
Assert.That(Parse("1/x").Limit(X, 0, ApproachFrom.Right).ToString(), Is.EqualTo("+oo"));
Assert.That(Parse("1/x").Limit(X, 0, ApproachFrom.Left).ToString(), Is.EqualTo("-oo"));
Assert.That(Parse("1/x").Limit(X, 0, ApproachFrom.BothSides).ToString(), Is.EqualTo("NaN"));
Assert.That(Parse("(1 + 1/x)^x").Limit(X, Parse("+oo")), Is.TypeOf<Entity.Constant>());
Assert.That(Parse("gamma(x)*sin(x)^x").Limit(X, 0), Is.TypeOf<Entity.Limitf>(), "A limit it cannot take comes back as a limit.");
}
}
[Test]
public void SolveWorksOverTheComplexNumbers()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Parse("x^2 - 5*x + 6 = 0").Solve(X).ToString(), Is.EqualTo("{ 2, 3 }"));
Assert.That(Parse("x^2 + 1 = 0").Solve(X).ToString(), Is.EqualTo("{ i, -i }"));
using (MathS.Settings.Codomain.Set(Domain.Real))
Assert.That(Parse("x^2 + 1 = 0").Solve(X).ToString(), Is.EqualTo("{ i, -i }"), "The codomain does not restrict the solutions.");
Assert.That(Parse("x^2 - 2 = 0").Solve(X).ToString(), Is.EqualTo("{ sqrt(2), -sqrt(2) }"), "The solutions stay exact.");
Assert.That(Parse("sin(x) = 1/2").Solve(X).ToString(), Is.EqualTo("{ pi / 6 + 2 * pi * n_1, pi - pi / 6 + 2 * pi * n_1 }"), "n_1 stands for any integer.");
Assert.That(Parse("x^2 < 4").Solve(X), Is.TypeOf<Entity.Set.Interval>());
Assert.That(Parse("x + 1 = x").Solve(X).ToString(), Is.EqualTo("{ }"));
Assert.Throws<SolveRequiresStatementException>(() => Parse("x^2 - 4").Solve(X));
}
}
[Test]
public void WithoutNewtonSolveAnswersOnlyInClosedForm()
{
var withNewton = (Entity.Set.FiniteSet)Parse("x^5 + 3*x + 1 = 0").Solve(X);
using (Assert.EnterMultipleScope())
{
Assert.That(withNewton.Count, Is.EqualTo(5));
Assert.That(withNewton.All(root => root is Entity.Number.Complex { IsExact: false }), "Newton's roots are doubles, printed with every binary digit.");
using (MathS.Settings.AllowNewton.Set(false))
Assert.That(Parse("x^5 + 3*x + 1 = 0").Solve(X), Is.TypeOf<Entity.Set.ConditionalSet>());
Assert.That(Parse("x^200 + x + 1 = 0").Solve(X), Is.TypeOf<Entity.Set.ConditionalSet>());
}
}
[Test]
public void SolveHonorsTheCancellationTokenOfItsThread()
{
var task = Task.Run(() =>
{
using var source = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
MathS.Multithreading.SetLocalCancellationToken(source.Token);
return Parse("x^20 + x + 1 = 0").Solve(X);
});
var exception = Assert.Throws<AggregateException>(() => task.Wait(TimeSpan.FromSeconds(10)))!;
Assert.That(exception.InnerException, Is.InstanceOf<OperationCanceledException>(), "Without the token, this takes about 1.6 seconds.");
}
[Test]
public void DeepNestingNeedsALargeStack()
{
// About 500 nested parentheses overflow the stack of a thread pool thread, which ends the process.
// A thread of its own with 64 MB reads the deepest nesting a 2,000-character expression can hold:
Entity? result = null;
var thread = new Thread(() => result = Parse(new string('(', 1000) + "1" + new string(')', 1000)).Evaled, 64 * 1024 * 1024);
thread.Start();
thread.Join();
Assert.That(result, Is.EqualTo(Entity.Number.Integer.Create(1)));
}
}
@@ -0,0 +1,287 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using AIStudio.Tools.Mathematics;
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mathematics;
using Microsoft.Extensions.Logging.Abstractions;
using PeterO.Numbers;
namespace AIStudio.Tests.Tools.Mathematics;
[TestFixture]
public sealed class CalculateToolTests
{
private static JsonObject Calculate(string expression, int digits = 20) => CalculateTool.Calculate(expression, digits, CancellationToken.None);
private static string Refusal(string expression) => Assert.Throws<MathComputationException>(() => Calculate(expression))!.Message;
private static string Text(JsonObject result, string property) => result[property]!.GetValue<string>();
private static double RelativeError(JsonObject result, string expected)
{
var reference = EDecimal.FromString(expected);
return EDecimal.FromString(Text(result, "decimal")).Subtract(reference).Abs().Divide(reference.Abs(), EContext.ForPrecision(20)).ToDouble();
}
private static JsonElement Arguments(string json)
{
using var document = JsonDocument.Parse(json);
return document.RootElement.Clone();
}
[Test]
public void KeepsAFractionExact()
{
var result = Calculate("547 / 57576");
using (Assert.EnterMultipleScope())
{
Assert.That(result["expression"]!.GetValue<string>(), Is.EqualTo("547 / 57576"), "The expression reads as written; its value is the fraction.");
Assert.That(result["exact"]!.GetValue<string>(), Is.EqualTo("547/57576"));
Assert.That(result["exact_latex"]!.GetValue<string>(), Is.EqualTo(@"\frac{547}{57576}"));
Assert.That(result["decimal"]!.GetValue<string>(), Is.EqualTo("0.0095004863137418368765"));
Assert.That(result["digits"]!.GetValue<int>(), Is.EqualTo(20));
Assert.That(result["approximate"]!.GetValue<bool>(), Is.False);
Assert.That(result.ContainsKey("estimated_error"), Is.False);
Assert.That(result.ContainsKey("issues"), Is.False);
}
}
[Test]
public void GivesAnIrrationalValueWithAllDigitsAskedFor()
{
var root = Calculate("sqrt(2)", 30);
var power = Calculate("pi^4/15", 60);
using (Assert.EnterMultipleScope())
{
Assert.That(root["decimal"]!.GetValue<string>(), Is.EqualTo("1.41421356237309504880168872421"));
Assert.That(root.ContainsKey("exact"), Is.False);
Assert.That(power["decimal"]!.GetValue<string>(), Is.EqualTo("6.49393940226682914909602217924700741664850571151236144609786"), "Without guard digits, the last digits would be off.");
Assert.That(power["digits"]!.GetValue<int>(), Is.EqualTo(60));
}
}
[Test]
public void DoesNotTakeANumberCloseToAnIntegerForIt()
{
// e^(pi*sqrt(163)) is 262537412640768743.99999999999925...
var result = Calculate("e^(pi*sqrt(163))", 30);
using (Assert.EnterMultipleScope())
{
Assert.That(result.ContainsKey("exact"), Is.False);
Assert.That(result["decimal"]!.GetValue<string>(), Is.EqualTo("262537412640768743.999999999999"));
}
}
[Test]
public void GivesComplexAndInfiniteValues()
{
var root = Calculate("sqrt(-4)");
var logarithm = Calculate("ln(0)");
using (Assert.EnterMultipleScope())
{
Assert.That(root["decimal"]!.GetValue<string>(), Is.EqualTo("2i"));
Assert.That(root.ContainsKey("exact"), Is.True);
Assert.That(logarithm["decimal"]!.GetValue<string>(), Is.EqualTo("-oo"));
Assert.That(logarithm["digits"]!.GetValue<int>(), Is.Zero);
Assert.That(logarithm["issues"]![0]!.GetValue<string>(), Is.EqualTo("The value is infinite."));
}
}
[Test]
public void ReadsEveryDecimalLiteralAsItsFraction()
{
// AngouriMath alone reads 6.62607015e-34 as an inexact decimal and 1e-60 as 0, see AngouriMathBehaviorTests.DowncastingWhileParsingLosesTinyLiterals:
var planck = Calculate("6.62607015e-34");
var tiny = Calculate("1e-60");
var equation = Calculate("1 + 1e-40 = 1");
using (Assert.EnterMultipleScope())
{
Assert.That(Text(planck, "exact"), Is.EqualTo("132521403/200000000000000000000000000000000000000000"));
Assert.That(Text(planck, "decimal"), Is.EqualTo("6.62607015e-34"));
Assert.That(Text(tiny, "exact"), Is.EqualTo("1/1" + new string('0', 60)));
Assert.That(equation["exactly_equal"]!.GetValue<bool>(), Is.False, "Simplifying would drop an inexact 1e-40 from the sum.");
Assert.That(equation["numerically_equal"]!.GetValue<bool>(), Is.True, "Both sides agree in the 20 digits compared.");
}
}
[Test]
public void KeepsTheDigitsOfTinyValues()
{
// Each of these came out as 0 while AngouriMath downcast, see AngouriMathBehaviorTests.DowncastingTurnsTinyValuesIntoZero:
var photon = Calculate("6.62607015e-34 * 299792458 / 500e-9");
var thermal = Calculate("1.380649e-23 * 300");
var exponential = Calculate("e^(-100)");
var tinier = Calculate("exp(-1000)");
var fraction = Calculate("21/10^50 * pi");
var sum = Calculate("21/10^50 + 1e-60");
var exactFraction = Calculate("1/10^200");
var integral = Calculate("integral(1e-30*x, x, 0, 1)");
var quotient = Calculate("pi/10^200");
var simplifiedToZero = Calculate("1e-2000 * pi");
using (Assert.EnterMultipleScope())
{
Assert.That(RelativeError(quotient, "3.14159265358979323846264e-200"), Is.LessThan(1e-19), "AngouriMath rounds a quotient to decimal places, so the precision grows until the value shows.");
Assert.That(simplifiedToZero.ContainsKey("exact"), Is.False, "AngouriMath simplifies the value to 0, see AngouriMathBehaviorTests.DowncastingWhileParsingLosesTinyLiterals.");
Assert.That(RelativeError(simplifiedToZero, "3.14159265358979323846264e-2000"), Is.LessThan(1e-19));
Assert.That(Text(photon, "decimal"), Is.EqualTo("3.9728917142978574e-19"));
Assert.That(Text(thermal, "decimal"), Is.EqualTo("4.141947e-21"));
Assert.That(RelativeError(exponential, "3.72007597602083596295969580386e-44"), Is.LessThan(1e-19));
Assert.That(RelativeError(tinier, "5.07595889754945676529180947957e-435"), Is.LessThan(1e-19));
Assert.That(RelativeError(fraction, "6.5973445725385658007715511049e-49"), Is.LessThan(1e-19), "The fraction is rounded to decimal places unless the precision grows by its leading zeros.");
Assert.That(Text(sum, "decimal"), Is.EqualTo("2.10000000001e-49"));
Assert.That(Text(exactFraction, "exact"), Is.EqualTo("1/1" + new string('0', 200)));
Assert.That(Text(exactFraction, "decimal"), Is.EqualTo("1e-200"));
Assert.That(RelativeError(integral, "5e-31"), Is.LessThan(1e-13));
foreach (var result in new[] { photon, thermal, exponential, tinier, fraction, sum, exactFraction })
Assert.That(result["digits"]!.GetValue<int>(), Is.EqualTo(20));
}
}
[Test]
public void SaysWhenAZeroMayBeATinyValue()
{
// Simplifying gives 0, but the fraction 1e-2000 needs more digits than NumericEvaluator.MAX_PRECISION for a numerical check:
var result = Calculate("1e-2000 * pi - 1e-2000 * pi");
using (Assert.EnterMultipleScope())
{
Assert.That(Text(result, "decimal"), Is.EqualTo("0"));
Assert.That(result["digits"]!.GetValue<int>(), Is.Zero);
Assert.That(result["issues"]!.AsArray().Select(issue => issue!.GetValue<string>()), Has.Some.StartsWith("The value evaluates to 0 with ").And.Some.Contains("cannot show that it is exactly 0"));
}
}
[Test]
public void IntegratesADefiniteIntegralNumerically()
{
var result = Calculate("integral(x^3/(e^x-1), x, 0, +oo)");
var digits = result["digits"]!.GetValue<int>();
var exact = Math.Pow(Math.PI, 4) / 15;
using (Assert.EnterMultipleScope())
{
Assert.That(digits, Is.InRange(12, 15), "A double carries no more, and the estimated error of the quadrature is conservative.");
Assert.That(double.Parse(result["decimal"]!.GetValue<string>(), System.Globalization.CultureInfo.InvariantCulture), Is.EqualTo(exact).Within(Math.Pow(10, 1 - digits) * exact), "Every digit reported is right.");
Assert.That(result["approximate"]!.GetValue<bool>(), Is.True);
Assert.That(result.ContainsKey("estimated_error"), Is.True);
Assert.That(result.ContainsKey("exact"), Is.False);
Assert.That(result["issues"]!.AsArray().Select(issue => issue!.GetValue<string>()), Has.Some.Contains("was integrated numerically"));
Assert.That(result["issues"]!.AsArray().Select(issue => issue!.GetValue<string>()), Has.Some.StartsWith("Only "), "Fewer digits than asked for are certain.");
}
}
[Test]
public void ComputesAroundAnIntegral()
{
var result = Calculate("1 + integral(e^(-x^2), x, -oo, +oo)");
Assert.That(double.Parse(result["decimal"]!.GetValue<string>(), System.Globalization.CultureInfo.InvariantCulture), Is.EqualTo(1 + Math.Sqrt(Math.PI)).Within(1e-13));
}
[Test]
public void ChecksTheExampleEquation()
{
var result = Calculate("integral(x^3/(e^x-1), x, 0, +oo) = pi^4/15");
using (Assert.EnterMultipleScope())
{
Assert.That(result["numerically_equal"]!.GetValue<bool>(), Is.True);
Assert.That(result["matching_digits"]!.GetValue<int>(), Is.InRange(12, 15));
Assert.That(result["left"]!["approximate"]!.GetValue<bool>(), Is.True);
Assert.That(result["right"]!["expression"]!.GetValue<string>(), Is.EqualTo("pi ^ 4 / 15"));
Assert.That(result.ContainsKey("exactly_equal"), Is.False, "Only two exact sides settle an equation.");
}
}
[Test]
public void SettlesAnEquationOfExactValues()
{
var equal = Calculate("2 + 2 = 4");
var close = Calculate("1/3 = 0.333");
var beyondTheDigits = Calculate("1/3 = 333333333333333333333333333333/1000000000000000000000000000000");
using (Assert.EnterMultipleScope())
{
Assert.That(equal["exactly_equal"]!.GetValue<bool>(), Is.True);
Assert.That(equal["numerically_equal"]!.GetValue<bool>(), Is.True);
Assert.That(close["exactly_equal"]!.GetValue<bool>(), Is.False);
Assert.That(close["numerically_equal"]!.GetValue<bool>(), Is.False);
Assert.That(close["matching_digits"]!.GetValue<int>(), Is.EqualTo(3));
Assert.That(beyondTheDigits["numerically_equal"]!.GetValue<bool>(), Is.True, "Twenty digits agree.");
Assert.That(beyondTheDigits["exactly_equal"]!.GetValue<bool>(), Is.False, "The exact values differ all the same.");
}
}
[Test]
public void RefusesWhatHasNoNumericValue()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Refusal("1/0"), Does.Contain("has no value: it evaluates to NaN"));
Assert.That(Refusal("x + 1"), Does.Contain("contains the variable x, so it has no numeric value. compute_symbolically works with variables."));
Assert.That(Refusal("a*b"), Does.Contain("contains the variables a, b,"));
Assert.That(Refusal("1 < 2"), Does.Contain("is a statement, such as a comparison"));
Assert.That(Refusal("(10^9)!"), Does.Contain("digits as an exact number"));
Assert.That(() => Calculate("integral(x^2, x)"), Throws.InstanceOf<MathComputationException>());
Assert.That(Refusal("sum(1/k, k, 1, 1000)"), Is.EqualTo("'sum(1 / k, k, 1, 1000)' has no value the tool can compute as a single number, as for a sum without a closed form, an infinite sum, or a matrix."), "AngouriMath sums only in closed form; its own message tells a developer to call EvaluableNumerical.");
Assert.That(Refusal("sum(1/k^2, k, 1, +oo)"), Does.Contain("has no value the tool can compute as a single number"));
Assert.That(Refusal("[1, 2] + [3, 4]"), Does.Contain("has no value the tool can compute as a single number"));
}
}
[Test]
public void ComputesDivisorsMultiplesAndSumsInClosedForm()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Text(Calculate("gcd(12, 18)"), "exact"), Is.EqualTo("6"));
Assert.That(Text(Calculate("lcm(4, 6, 10)"), "exact"), Is.EqualTo("60"), "Three arguments nest into two calls.");
Assert.That(Text(Calculate("gcd(2^100, 6^50)"), "exact"), Is.EqualTo("1125899906842624"));
Assert.That(Text(Calculate("sum(k^2, k, 1, 100)"), "exact"), Is.EqualTo("338350"));
Assert.That(Text(Calculate("sum(k, k, 1, 10^7)"), "exact"), Is.EqualTo("50000005000000"), "AngouriMath sums in closed form, never term by term.");
Assert.That(Text(Calculate("sum(k, k, 10, 1)"), "exact"), Is.EqualTo("0"), "An empty sum is 0.");
}
}
[Test]
public void RefusesAnIntegralItCannotIntegrate()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Refusal("integral(1/x, x, -1, 1)"), Does.StartWith("The integrand of 'integral(1 / x, x, -1, 1)' has no finite value at x = 0, inside the interval of integration."));
Assert.That(Refusal("integral(sin(x)/x, x, 0, +oo)"), Does.StartWith("The numerical integration of 'integral(sin(x) / x, x, 0, +oo)' did not settle."));
Assert.That(Refusal("integral(integral(x*y, y, 0, 1), x, 0, 1)"), Does.Contain("depends on the variable of an outer integral"));
}
}
[Test]
public async Task ExecutesThroughTheComputationService()
{
var tool = new CalculateTool(null!, new MathComputationService(NullLogger<MathComputationService>.Instance));
var result = await tool.ExecuteAsync(Arguments("""{"expression": "547 / 57576", "digits": 5}"""), null!);
Assert.That(result.JsonContent!["decimal"]!.GetValue<string>(), Is.EqualTo("0.0095005"));
}
[Test]
public void RefusesDigitsOutsideTheRange()
{
var tool = new CalculateTool(null!, new MathComputationService(NullLogger<MathComputationService>.Instance));
var tooMany = Arguments("""{"expression": "1/3", "digits": 101}""");
var none = Arguments("""{"expression": "1/3", "digits": 0}""");
using (Assert.EnterMultipleScope())
{
Assert.That(() => tool.ExecuteAsync(tooMany, null!), Throws.ArgumentException.With.Message.EqualTo("Argument 'digits' must be at most 100, but was 101. Leave it out to get 20 significant digits."));
Assert.That(() => tool.ExecuteAsync(none, null!), Throws.ArgumentException);
}
}
[Test]
public void TellsTheModelNothingAboutTheLibrary()
{
var definition = new CalculateTool(null!, new MathComputationService(NullLogger<MathComputationService>.Instance)).GetDefinition();
using (Assert.EnterMultipleScope())
{
Assert.That(definition.SystemPromptInstructions, Does.Not.Contain("AngouriMath"), "A model would take the name for something it may call or look up.");
Assert.That(definition.Function.DescriptionForLLM, Does.Not.Contain("AngouriMath"));
Assert.That(definition.Function.Parameters.GetRawText(), Does.Not.Contain("AngouriMath"));
}
}
}
@@ -0,0 +1,259 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using AIStudio.Tools.Mathematics;
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mathematics;
using AngouriMath.Core;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Tools.Mathematics;
/// <summary>
/// Checks compute_symbolically through the computation service, so it runs with the settings of the tools: explicit
/// parsing, and solving without Newton's method.
/// </summary>
[TestFixture]
public sealed class ComputeSymbolicallyToolTests
{
private static readonly MathComputationService SERVICE = new(NullLogger<MathComputationService>.Instance);
private static Task<JsonObject> Compute(SymbolicOperation operation, string expression, string? variable = null, int order = 1, string? lowerBound = null, string? upperBound = null, string? approach = null, ApproachFrom direction = ApproachFrom.BothSides) =>
SERVICE.ComputeAsync(_ => ComputeSymbolicallyTool.Compute(new SymbolicRequest(operation, expression, variable, order, lowerBound, upperBound, approach, direction)));
private static string Text(JsonObject json, string property) => json[property]!.GetValue<string>();
private static IEnumerable<string> Issues(JsonObject json) => json["issues"]?.AsArray().Select(issue => issue!.GetValue<string>()) ?? [];
private static JsonElement Arguments(string json)
{
using var document = JsonDocument.Parse(json);
return document.RootElement.Clone();
}
private static string RequestRefusal(string json) => Assert.Throws<ArgumentException>(() => ComputeSymbolicallyTool.ReadRequest(Arguments(json)))!.Message;
[Test]
public async Task SimplifiesAndStatesTheCondition()
{
var identity = await Compute(SymbolicOperation.SIMPLIFY, "sin(x)^2 + cos(x)^2");
var quotient = await Compute(SymbolicOperation.SIMPLIFY, "(x^2 - 1)/(x - 1)");
using (Assert.EnterMultipleScope())
{
Assert.That(Text(identity, "result"), Is.EqualTo("1"));
Assert.That(Text(identity, "verification"), Is.EqualTo("passed"));
Assert.That(identity["complete"]!.GetValue<bool>(), Is.True);
Assert.That(Text(quotient, "result"), Is.EqualTo("x + 1"));
Assert.That(Text(quotient, "condition"), Is.EqualTo("not x - 1 = 0"));
Assert.That(Text(quotient, "verification"), Is.EqualTo("passed"));
}
}
[Test]
public async Task ExpandsAndFactors()
{
var expanded = await Compute(SymbolicOperation.EXPAND, "(x + 1)^3");
var unchanged = await Compute(SymbolicOperation.EXPAND, "(x + y + z + w)^60");
var factored = await Compute(SymbolicOperation.FACTOR, "x^4 - 1");
using (Assert.EnterMultipleScope())
{
Assert.That(Text(expanded, "result"), Is.EqualTo("1 + 3 * x + 3 * x ^ 2 + x ^ 3"));
Assert.That(Text(expanded, "verification"), Is.EqualTo("passed"));
Assert.That(Issues(unchanged), Has.Some.StartsWith("The tool left the expression as it is"));
Assert.That(Text(factored, "result"), Is.EqualTo("(x + 1) * (x - 1) * (x ^ 2 + 1)"));
Assert.That(Text(factored, "verification"), Is.EqualTo("passed"));
}
}
[Test]
public async Task DifferentiatesAndChecksEachOrder()
{
var product = await Compute(SymbolicOperation.DIFFERENTIATE, "x^2*sin(x)");
var third = await Compute(SymbolicOperation.DIFFERENTIATE, "x^5", order: 3);
var parameter = await Compute(SymbolicOperation.DIFFERENTIATE, "a*x^2", "x");
using (Assert.EnterMultipleScope())
{
Assert.That(Text(product, "result"), Is.EqualTo("x ^ 2 * cos(x) + 2 * x * sin(x)"));
Assert.That(Text(product, "variable"), Is.EqualTo("x"));
Assert.That(Text(product, "verification"), Is.EqualTo("passed"));
Assert.That(Text(third, "result"), Is.EqualTo("60 * x ^ 2"));
Assert.That(third["order"]!.GetValue<int>(), Is.EqualTo(3));
Assert.That(Text(third, "verification"), Is.EqualTo("passed"));
Assert.That(Text(parameter, "verification"), Is.EqualTo("passed"));
}
}
[Test]
public async Task IntegratesWithoutBounds()
{
var power = await Compute(SymbolicOperation.INTEGRATE, "x^2");
var withoutClosedForm = await Compute(SymbolicOperation.INTEGRATE, "sin(sin(x))");
using (Assert.EnterMultipleScope())
{
Assert.That(Text(power, "result"), Is.EqualTo("x ^ 3 / 3 + C"));
Assert.That(Text(power, "verification"), Is.EqualTo("passed"));
Assert.That(Issues(power), Has.Member("C stands for any constant."));
Assert.That(withoutClosedForm["complete"]!.GetValue<bool>(), Is.False);
Assert.That(Text(withoutClosedForm, "verification"), Is.EqualTo("not_possible"));
Assert.That(Issues(withoutClosedForm), Has.Member("The tool found no antiderivative in closed form."));
}
}
[Test]
public async Task IntegratesWithBoundsAndChecksByQuadrature()
{
var polynomial = await Compute(SymbolicOperation.INTEGRATE, "x^2", lowerBound: "0", upperBound: "1");
var arctangent = await Compute(SymbolicOperation.INTEGRATE, "1/(1 + x^2)", lowerBound: "0", upperBound: "+oo");
var bose = await Compute(SymbolicOperation.INTEGRATE, "x^3/(e^x - 1)", lowerBound: "0", upperBound: "+oo");
var pole = await Compute(SymbolicOperation.INTEGRATE, "1/x", lowerBound: "-1", upperBound: "1");
using (Assert.EnterMultipleScope())
{
Assert.That(Text(polynomial, "result"), Is.EqualTo("1/3"));
Assert.That(Text(polynomial, "verification"), Is.EqualTo("passed"));
Assert.That(Text(arctangent, "result"), Is.EqualTo("pi / 2"));
Assert.That(Text(arctangent, "verification"), Is.EqualTo("passed"));
Assert.That(bose["complete"]!.GetValue<bool>(), Is.False, "AngouriMath finds no closed form for pi^4/15.");
Assert.That(Issues(bose), Has.Member("The tool found no closed form for this integral."));
Assert.That(Text(pole, "result"), Is.EqualTo("NaN"));
Assert.That(Issues(pole), Has.Some.StartsWith("The result is NaN: the integral has no value"));
Assert.That(Text(pole, "verification"), Is.EqualTo("not_possible"));
}
}
[Test]
public async Task TakesLimits()
{
var sinc = await Compute(SymbolicOperation.LIMIT, "sin(x)/x", approach: "0");
var fromTheRight = await Compute(SymbolicOperation.LIMIT, "1/x", approach: "0", direction: ApproachFrom.Right);
var fromBothSides = await Compute(SymbolicOperation.LIMIT, "1/x", approach: "0");
using (Assert.EnterMultipleScope())
{
Assert.That(Text(sinc, "result"), Is.EqualTo("1"));
Assert.That(Text(sinc, "direction"), Is.EqualTo("both"));
Assert.That(Text(sinc, "verification"), Is.EqualTo("not_possible"));
Assert.That(Text(fromTheRight, "result"), Is.EqualTo("+oo"));
Assert.That(Text(fromTheRight, "direction"), Is.EqualTo("right"));
Assert.That(Text(fromBothSides, "result"), Is.EqualTo("NaN"));
Assert.That(Issues(fromBothSides), Has.Some.StartsWith("The result is NaN, which the tool gives where the limit does not exist"));
}
}
[Test]
public async Task SolvesAndChecksEverySolution()
{
var quadratic = await Compute(SymbolicOperation.SOLVE, "x^2 - 5*x + 6 = 0");
var complex = await Compute(SymbolicOperation.SOLVE, "x^2 + 1 = 0");
var periodic = await Compute(SymbolicOperation.SOLVE, "sin(x) = 1/2");
var withoutEquals = await Compute(SymbolicOperation.SOLVE, "x^2 - 4");
var tiny = await Compute(SymbolicOperation.SOLVE, "1.5e30*x = 3");
using (Assert.EnterMultipleScope())
{
Assert.That(quadratic["solutions"]!.AsArray().Select(solution => solution!["value"]!.GetValue<string>()), Is.EqualTo(new[] { "2", "3" }));
Assert.That(tiny["solutions"]![0]!["decimal"]!.GetValue<string>(), Is.EqualTo("2e-30"), "The decimal of a tiny solution keeps its digits, see NumericEvaluator.EvaluateAt.");
Assert.That(quadratic["solutions"]![0]!["real"]!.GetValue<bool>(), Is.True);
Assert.That(Text(quadratic, "verification"), Is.EqualTo("passed"));
Assert.That(complex["solutions"]![0]!["real"]!.GetValue<bool>(), Is.False);
Assert.That(Text(complex, "verification"), Is.EqualTo("passed"));
Assert.That(Text(periodic, "verification"), Is.EqualTo("passed"), "n_1 takes integer values in the check.");
Assert.That(Issues(periodic), Has.Member("n_1 stands for any integer."));
Assert.That(Issues(withoutEquals), Has.Member("The expression has no '=', so it was solved for expression = 0."));
Assert.That(withoutEquals["solutions"]!.AsArray(), Has.Count.EqualTo(2));
}
}
[Test]
public async Task WorksWithSumsAndDivisors()
{
var gauss = await Compute(SymbolicOperation.SOLVE, "sum(k, k, 1, n) = 55", "n");
var derivative = await Compute(SymbolicOperation.DIFFERENTIATE, "sum(k*x^k, k, 1, 3)", "x");
var divisor = await Compute(SymbolicOperation.SIMPLIFY, "gcd(12, 18) * x");
using (Assert.EnterMultipleScope())
{
Assert.That(gauss["solutions"]!.AsArray().Select(solution => solution!["value"]!.GetValue<string>()), Is.EqualTo(new[] { "10" }), "The closed form holds for n >= 0 only, so -11 is no solution.");
Assert.That(Text(gauss, "verification"), Is.EqualTo("passed"));
Assert.That(Text(derivative, "result"), Is.EqualTo("1 + 4 * x + 9 * x ^ 2"));
Assert.That(Text(derivative, "verification"), Is.EqualTo("passed"));
Assert.That(Text(divisor, "result"), Is.EqualTo("6 * x"));
}
}
[Test]
public async Task SaysWhenThereIsNoSolutionInClosedForm()
{
var quintic = await Compute(SymbolicOperation.SOLVE, "x^5 + 3*x + 1 = 0");
var none = await Compute(SymbolicOperation.SOLVE, "x + 1 = x");
var inequality = await Compute(SymbolicOperation.SOLVE, "x^2 < 4");
using (Assert.EnterMultipleScope())
{
Assert.That(quintic["complete"]!.GetValue<bool>(), Is.False, "Without Newton's method, there are no approximate roots.");
Assert.That(Text(quintic, "verification"), Is.EqualTo("not_possible"));
Assert.That(none["solutions"]!.AsArray(), Is.Empty);
Assert.That(Issues(none), Has.Member("There is no solution."));
Assert.That(Text(inequality, "result"), Is.EqualTo("(-2; 2)"));
Assert.That(inequality.ContainsKey("solutions"), Is.False);
}
}
[Test]
public void RefusesWhatTheOperationCannotTake()
{
using (Assert.EnterMultipleScope())
{
Assert.That(async () => await Compute(SymbolicOperation.SIMPLIFY, "x = 2"), Throws.InstanceOf<MathComputationException>().With.Message.EqualTo("Argument 'expression' is a statement, such as an equation or a comparison, which only the operation 'solve' takes."));
Assert.That(async () => await Compute(SymbolicOperation.DIFFERENTIATE, "a*x^2"), Throws.InstanceOf<MathComputationException>().With.Message.EqualTo("Argument 'expression' has the variables a, x, so argument 'variable' has to say which one to differentiate by."));
Assert.That(async () => await Compute(SymbolicOperation.INTEGRATE, "2"), Throws.InstanceOf<MathComputationException>().With.Message.EqualTo("Argument 'expression' has no variable to integrate over."));
Assert.That(async () => await Compute(SymbolicOperation.SOLVE, "x^2 = 2", "pi"), Throws.InstanceOf<MathComputationException>().With.Message.EqualTo("Argument 'variable' must name a variable, such as x, but was 'pi'."));
Assert.That(async () => await Compute(SymbolicOperation.EXPAND, "(x + y)^500"), Throws.InstanceOf<MathComputationException>().With.Message.Contains("raises a sum to the power of 500"));
}
}
[Test]
public void RefusesArgumentsTheOperationDoesNotUse()
{
using (Assert.EnterMultipleScope())
{
Assert.That(RequestRefusal("""{"operation": "simplify", "expression": "x", "variable": "x"}"""), Is.EqualTo("Argument 'variable' is not used by the operation 'simplify'. Leave it out."));
Assert.That(RequestRefusal("""{"operation": "integrate", "expression": "x", "order": 2}"""), Is.EqualTo("Argument 'order' is not used by the operation 'integrate'. Leave it out."));
Assert.That(RequestRefusal("""{"operation": "integrate", "expression": "x", "lower_bound": "0"}"""), Is.EqualTo("Argument 'lower_bound' needs argument 'upper_bound' as well, since a definite integral has two bounds. Leave both out for an antiderivative."));
Assert.That(RequestRefusal("""{"operation": "limit", "expression": "1/x"}"""), Does.StartWith("Argument 'approach' is required for the operation 'limit'"));
Assert.That(RequestRefusal("""{"operation": "solve", "expression": "x = 1", "direction": "left"}"""), Is.EqualTo("Argument 'direction' is not used by the operation 'solve'. Leave it out."));
Assert.That(RequestRefusal("""{"operation": "differentiate", "expression": "x", "order": 11}"""), Is.EqualTo("Argument 'order' must be at most 10, but was 11. Leave it out for the first derivative."));
Assert.That(RequestRefusal("""{"operation": "derive", "expression": "x"}"""), Is.EqualTo("Argument 'operation' must be one of simplify, expand, factor, differentiate, integrate, limit, solve, but was 'derive'."));
}
}
[Test]
public void ReadsTheArgumentsOfALimit()
{
var request = ComputeSymbolicallyTool.ReadRequest(Arguments("""{"operation": "limit", "expression": "1/x", "variable": null, "approach": "0", "direction": "left"}"""));
using (Assert.EnterMultipleScope())
{
Assert.That(request.Operation, Is.EqualTo(SymbolicOperation.LIMIT));
Assert.That(request.Variable, Is.Null, "In strict mode, a model passes null for an argument it leaves out.");
Assert.That(request.Approach, Is.EqualTo("0"));
Assert.That(request.Direction, Is.EqualTo(ApproachFrom.Left));
Assert.That(request.Order, Is.EqualTo(1));
}
}
[Test]
public async Task ExecutesThroughTheComputationService()
{
var tool = new ComputeSymbolicallyTool(null!, SERVICE);
var result = await tool.ExecuteAsync(Arguments("""{"operation": "factor", "expression": "x^2 - 5*x + 6"}"""), null!);
Assert.That(result.JsonContent!["result"]!.GetValue<string>(), Is.EqualTo("(x - 3) * (x - 2)"));
}
[Test]
public void TellsTheModelNothingAboutTheLibrary()
{
var definition = new ComputeSymbolicallyTool(null!, SERVICE).GetDefinition();
using (Assert.EnterMultipleScope())
{
Assert.That(definition.SystemPromptInstructions, Does.Not.Contain("AngouriMath"), "A model would take the name for something it may call or look up.");
Assert.That(definition.Function.DescriptionForLLM, Does.Not.Contain("AngouriMath"));
Assert.That(definition.Function.Parameters.GetRawText(), Does.Not.Contain("AngouriMath"));
}
}
}
@@ -0,0 +1,129 @@
using AIStudio.Tools.Mathematics;
using AngouriMath;
namespace AIStudio.Tests.Tools.Mathematics;
[TestFixture]
public sealed class DoubleExponentialQuadratureTests
{
private static Func<double, double> Compiled(string expression) => MathExpressionReader.Read(expression, "expression").Compile<double, double>(MathS.Var("x"));
/// <summary>
/// Checks a converged result against the exact value, and that its estimated error is honest.
/// </summary>
private static void AssertIntegral(Func<double, double> integrand, double lower, double upper, double exact, double relativeTolerance = 1e-13)
{
var result = DoubleExponentialQuadrature.Integrate(integrand, lower, upper);
var actualError = Math.Abs(result.Value - exact);
using (Assert.EnterMultipleScope())
{
Assert.That(result.Status, Is.EqualTo(QuadratureStatus.CONVERGED));
Assert.That(result.Value, Is.EqualTo(exact).Within(relativeTolerance * Math.Abs(exact)));
Assert.That(actualError, Is.LessThanOrEqualTo(Math.Max(result.EstimatedError, 1e-15 * Math.Abs(exact))), "The estimated error must not claim more digits than the value has.");
}
}
[Test]
public void IntegratesOverAFiniteInterval()
{
AssertIntegral(x => x * x, 0, 1, 1.0 / 3);
AssertIntegral(Math.Sin, 0, Math.PI, 2);
AssertIntegral(Math.Exp, -1, 2, Math.Exp(2) - Math.Exp(-1));
}
[Test]
public void IntegratesASingularityAtTheLowerBoundZero()
{
AssertIntegral(Math.Log, 0, 1, -1);
AssertIntegral(x => 1 / Math.Sqrt(x), 0, 1, 2);
}
[Test]
public void ReachesASingularityAtAnotherBoundWithFewerDigits()
{
var result = DoubleExponentialQuadrature.Integrate(x => 1 / Math.Sqrt(1 - x), 0, 1);
using (Assert.EnterMultipleScope())
{
Assert.That(result.Status, Is.EqualTo(QuadratureStatus.CONVERGED));
Assert.That(result.Value, Is.EqualTo(2).Within(1e-6));
Assert.That(Math.Abs(result.Value - 2), Is.LessThanOrEqualTo(result.EstimatedError), "Near 1, a double carries few digits of the distance to 1; the estimate has to say so.");
}
}
[Test]
public void IntegratesOverInfiniteIntervals()
{
AssertIntegral(x => Math.Exp(-x), 0, double.PositiveInfinity, 1);
AssertIntegral(Math.Exp, double.NegativeInfinity, 0, 1);
AssertIntegral(x => 1 / (1 + x * x), 0, double.PositiveInfinity, Math.PI / 2);
AssertIntegral(x => Math.Exp(-x * x), double.NegativeInfinity, double.PositiveInfinity, Math.Sqrt(Math.PI));
AssertIntegral(x => Math.Exp(-x), 2, double.PositiveInfinity, Math.Exp(-2));
}
[Test]
public void IntegratesTheExampleOfTheBoseIntegral()
{
// The compiled integrand is 0/0 at 0, and has no finite value for x below about 1e-16, where e^x - 1 is 0 in doubles:
var bose = Compiled("x^3/(e^x-1)");
Assert.That(double.IsFinite(bose(1e-17)), Is.False);
AssertIntegral(bose, 0, double.PositiveInfinity, Math.Pow(Math.PI, 4) / 15);
}
[Test]
public void ReportsAnIntegralWhichDoesNotSettle()
{
using (Assert.EnterMultipleScope())
{
Assert.That(DoubleExponentialQuadrature.Integrate(x => Math.Sin(x) / x, 0, double.PositiveInfinity).Status, Is.EqualTo(QuadratureStatus.NOT_CONVERGED), "sin(x)/x converges only conditionally, which the substitution cannot settle.");
Assert.That(DoubleExponentialQuadrature.Integrate(x => 1 / x, 0, 1).Status, Is.EqualTo(QuadratureStatus.NOT_CONVERGED), "1/x diverges at 0.");
Assert.That(DoubleExponentialQuadrature.Integrate(x => 1 / x, 1, double.PositiveInfinity).Status, Is.EqualTo(QuadratureStatus.NOT_CONVERGED), "1/x diverges at infinity.");
Assert.That(DoubleExponentialQuadrature.Integrate(Math.Sin, 0, double.PositiveInfinity).Status, Is.EqualTo(QuadratureStatus.NOT_CONVERGED));
}
}
[Test]
public void ReportsWhereTheIntegrandHasNoValue()
{
var pole = DoubleExponentialQuadrature.Integrate(x => 1 / x, -1, 1);
var root = DoubleExponentialQuadrature.Integrate(Compiled("sqrt(x - 1)"), 0, 2);
using (Assert.EnterMultipleScope())
{
Assert.That(pole.Status, Is.EqualTo(QuadratureStatus.NO_VALUE));
Assert.That(pole.FailurePoint, Is.Zero, "The pole lies in the middle of the interval.");
Assert.That(root.Status, Is.EqualTo(QuadratureStatus.NO_VALUE));
Assert.That(root.FailurePoint, Is.LessThan(1), "A compiled function stays real, so sqrt has no value below 1.");
}
}
[Test]
public void ChangesTheSignForReversedBounds()
{
var reversed = DoubleExponentialQuadrature.Integrate(x => x * x, 1, 0);
var empty = DoubleExponentialQuadrature.Integrate(x => x * x, 2, 2);
using (Assert.EnterMultipleScope())
{
Assert.That(reversed.Value, Is.EqualTo(-1.0 / 3).Within(1e-15));
Assert.That(empty.Value, Is.Zero);
Assert.That(empty.Status, Is.EqualTo(QuadratureStatus.CONVERGED));
}
}
[Test]
public void SettlesAnIntegralOfZero()
{
var result = DoubleExponentialQuadrature.Integrate(Math.Sin, -1, 1);
using (Assert.EnterMultipleScope())
{
Assert.That(result.Status, Is.EqualTo(QuadratureStatus.CONVERGED));
Assert.That(result.Value, Is.EqualTo(0).Within(1e-15));
}
}
[Test]
public void StopsAtTheCancellationToken()
{
var canceled = new CancellationToken(canceled: true);
Assert.Throws<OperationCanceledException>(() => DoubleExponentialQuadrature.Integrate(x => Math.Sin(x) / x, 0, double.PositiveInfinity, canceled));
}
}
@@ -0,0 +1,119 @@
using AIStudio.Tools.Mathematics;
using AngouriMath;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Tools.Mathematics;
[TestFixture]
public sealed class MathComputationServiceTests
{
private static readonly TimeSpan SHORT_BUDGET = TimeSpan.FromMilliseconds(200);
private static MathComputationService CreateService(TimeSpan? budget = null) => new(NullLogger<MathComputationService>.Instance)
{
TimeBudget = budget ?? MathComputationService.DEFAULT_TIME_BUDGET,
};
/// <summary>
/// A computation which checks its token, and nothing else.
/// </summary>
private static int SpinUntilCanceled(CancellationToken token)
{
while (!token.IsCancellationRequested)
Thread.SpinWait(100);
token.ThrowIfCancellationRequested();
return 0;
}
[Test]
public async Task AComputationRunsWithTheSettingsOfTheTools()
{
var service = CreateService();
var settings = await service.ComputeAsync(_ => new[]
{
Thread.CurrentThread.Name,
MathS.Settings.ExplicitParsingOnly.Value.ToString(),
MathS.Settings.AllowNewton.Value.ToString(),
});
using (Assert.EnterMultipleScope())
{
Assert.That(settings, Is.EqualTo(new[] { "Mathematics", "True", "False" }));
Assert.That(MathS.Settings.ExplicitParsingOnly.Value, Is.False, "The settings stay with the computation.");
}
}
[Test]
public async Task TheLongestExpressionsDoNotOverflowTheStack()
{
// Each of these holds as deep a tree as 2,000 characters allow; on a thread pool thread,
// the first one would end the test run:
var service = CreateService();
var parentheses = new string('(', 999) + "1" + new string(')', 999);
var powerTower = string.Join("^", Enumerable.Repeat("x", 1000));
var sineChain = string.Concat(Enumerable.Repeat("sin(", 399)) + "1" + new string(')', 399);
using (Assert.EnterMultipleScope())
{
Assert.That(await service.ComputeAsync(_ => MathExpressionReader.Read(parentheses, "expression").Evaled), Is.EqualTo(Entity.Number.Integer.Create(1)));
Assert.That(await service.ComputeAsync(_ => MathExpressionReader.Read(powerTower, "expression").Simplify().ToString().Length), Is.GreaterThan(1000));
Assert.That(await service.ComputeAsync(_ => MathExpressionReader.Read(sineChain, "expression").EvalNumerical().ToString()), Does.StartWith("0.08"));
}
}
[Test]
public async Task AComputationWhichChecksItsTokenStopsAtTheBudget()
{
var service = CreateService(SHORT_BUDGET);
var exception = (await Assert.ThrowsAsync<MathComputationException>(() => service.ComputeAsync(SpinUntilCanceled)))!;
Assert.That(exception.Message, Is.EqualTo("The computation did not finish within 0.2 seconds."));
}
[Test]
public async Task AComputationWhichIgnoresItsTokenKeepsTheSlotUntilItEnds()
{
// Not disposed: the stuck computation may still be waiting for it when the test ends.
var service = CreateService(SHORT_BUDGET);
var release = new ManualResetEventSlim();
var stuck = (await Assert.ThrowsAsync<MathComputationException>(() => service.ComputeAsync(_ =>
{
release.Wait(CancellationToken.None);
return 1;
})))!;
var waiting = (await Assert.ThrowsAsync<MathComputationException>(() => service.ComputeAsync(_ => 2)))!;
release.Set();
using (Assert.EnterMultipleScope())
{
Assert.That(stuck.Message, Does.StartWith("The computation did not finish within 0.2 seconds and could not be stopped."));
Assert.That(waiting.Message, Is.EqualTo("Another computation is still running, so this one could not start within 0.2 seconds."));
Assert.That(await service.ComputeAsync(_ => 3), Is.EqualTo(3), "Once the stuck computation ends, the slot is free again.");
}
}
[Test]
public void TheRequestCancelsAComputation()
{
var service = CreateService();
var request = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
Assert.That(() => service.ComputeAsync(SpinUntilCanceled, request.Token), Throws.InstanceOf<OperationCanceledException>());
}
[Test]
public async Task AnExceptionOfAngouriMathBecomesAMessageAndFreesTheSlot()
{
var service = CreateService();
var exception = (await Assert.ThrowsAsync<MathComputationException>(() => service.ComputeAsync(_ => MathExpressionReader.Read("x + 1", "expression").EvalNumerical())))!;
using (Assert.EnterMultipleScope())
{
Assert.That(exception.Message, Does.StartWith("The tool could not compute this:`x + 1` evaluates to `x + 1`"));
Assert.That(await service.ComputeAsync(_ => 4), Is.EqualTo(4));
}
}
}
@@ -0,0 +1,64 @@
using AIStudio.Tools.Mathematics;
using AngouriMath;
namespace AIStudio.Tests.Tools.Mathematics;
[TestFixture]
public sealed class MathExpressionReaderTests
{
private static string Refusal(string text, string argumentName = "expression") => Assert.Throws<MathComputationException>(() => MathExpressionReader.Read(text, argumentName))!.Message;
[Test]
public void ReadsTheSyntaxOfTheTools()
{
using (Assert.EnterMultipleScope())
{
Assert.That(MathExpressionReader.Read("integral(x^3/(e^x-1), x, 0, +oo)", "expression"), Is.TypeOf<Entity.Integralf>());
Assert.That(MathExpressionReader.Read("x^2 = 4", "expression"), Is.TypeOf<Entity.Equalsf>());
Assert.That(MathExpressionReader.Read("x <= 2", "expression"), Is.TypeOf<Entity.LessOrEqualf>(), "The equals sign of <= is no equation.");
Assert.That(MathExpressionReader.Read("x >= 2", "expression"), Is.TypeOf<Entity.GreaterOrEqualf>());
Assert.That(MathExpressionReader.Read("a <> b", "expression").ToString(), Is.EqualTo("not a = b"));
Assert.That(MathExpressionReader.Read(new string('1', MathLimits.MAX_EXPRESSION_LENGTH), "expression"), Is.InstanceOf<Entity.Number.Integer>());
}
}
[Test]
public void RefusesWhatItCannotReadAsMeant()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Refusal(" "), Is.EqualTo("Argument 'expression' is empty."));
Assert.That(Refusal(new string('1', MathLimits.MAX_EXPRESSION_LENGTH + 1)), Is.EqualTo("Argument 'expression' has 2,001 characters, but at most 2,000 are read."));
Assert.That(Refusal("a != b"), Is.EqualTo("Argument 'expression' contains '!=', which the tool reads as a factorial followed by '='. Not-equal is written '<>'."));
Assert.That(Refusal("|x - 1|"), Does.StartWith("Argument 'expression' contains '|', which the tool reads as divisibility."));
Assert.That(Refusal("2 = 2 = 2"), Does.StartWith("Argument 'expression' contains 2 equals signs, but an expression holds one equation at most."));
Assert.That(Refusal("oo", "approach"), Is.EqualTo("Argument 'approach' uses 'oo', which the tool reads as a variable. Infinity is written +oo or -oo."));
Assert.That(Refusal("1/oo"), Does.Contain("uses 'oo'"));
}
}
[Test]
public void NamesTheMissingOperator()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Refusal("2x"), Is.EqualTo("Argument 'expression' has no operator between '2' and 'x' at column 2. Multiplication is written '*', as in 2*x, and a function takes its argument in parentheses, as in sin(x)."));
Assert.That(Refusal("sin x"), Does.StartWith("Argument 'expression' has no operator between 'sin' and 'x' at column 5."));
Assert.That(Refusal("foo(3)"), Is.EqualTo("Argument 'expression' has no operator between 'foo' and '(' at column 4. The tool knows no function named 'foo', so it would read the name as a variable times the parentheses. Multiplication is written '*'."));
}
}
[Test]
public void ShortensAParseErrorToItsColumnAndCause()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Refusal("2 + * 3"), Is.EqualTo("Argument 'expression' cannot be read at column 5: extraneous input '*'."));
Assert.That(Refusal("(2 + 3"), Is.EqualTo("Argument 'expression' cannot be read at column 7: no viable alternative at input '(2+3'."));
Assert.That(Refusal(@"\frac{1}{2}"), Does.StartWith("Argument 'expression' cannot be read at column 1:"));
Assert.That(Refusal("sin(1, 2)"), Is.EqualTo("Argument 'expression' cannot be read: sin should have exactly 1 argument but 2 arguments are provided."));
Assert.That(Refusal("integral(x^2, x, 2)", "lower_bound"), Does.StartWith("Argument 'lower_bound' cannot be read: integral should have exactly 4 arguments or 2 arguments"));
}
}
}
@@ -0,0 +1,79 @@
using AIStudio.Tools.Mathematics;
using AngouriMath;
using PeterO.Numbers;
namespace AIStudio.Tests.Tools.Mathematics;
[TestFixture]
public sealed class MathFormattingTests
{
private static string Format(string value, int digits) => MathFormatting.FormatDecimal(EDecimal.FromString(value), digits);
[Test]
public void WritesDecimalsWithTheirSignificantDigits()
{
var quotient = Entity.Number.Rational.Create(547, 57576).EDecimal;
using (Assert.EnterMultipleScope())
{
Assert.That(MathFormatting.FormatDecimal(quotient, 20), Is.EqualTo("0.0095004863137418368765"));
Assert.That(MathFormatting.FormatDecimal(quotient, 3), Is.EqualTo("0.0095"), "0.00950 at three digits; trailing zeros are left out.");
Assert.That(Format("0.5", 20), Is.EqualTo("0.5"));
Assert.That(Format("120", 20), Is.EqualTo("120"));
Assert.That(Format("1234567.891", 4), Is.EqualTo("1235000"));
Assert.That(Format("-0.000001234", 20), Is.EqualTo("-0.000001234"));
Assert.That(Format("0", 20), Is.EqualTo("0"));
}
}
[Test]
public void WritesVeryLargeAndVerySmallDecimalsInScientificNotation()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Format("602214076000000000000000", 20), Is.EqualTo("6.02214076e+23"));
Assert.That(Format("100000000000000000000", 20), Is.EqualTo("100000000000000000000"), "1e20 is still written out.");
Assert.That(Format("1E-60", 20), Is.EqualTo("1e-60"));
Assert.That(Format("-1.5E-7", 20), Is.EqualTo("-1.5e-7"));
}
}
[Test]
public void WritesValuesWithoutAMeaningAsTheToolsReadThem()
{
using (Assert.EnterMultipleScope())
{
Assert.That(MathFormatting.FormatDecimal(double.NaN, 15), Is.EqualTo("NaN"));
Assert.That(MathFormatting.FormatDecimal(double.PositiveInfinity, 15), Is.EqualTo("+oo"));
Assert.That(MathFormatting.FormatDecimal(EDecimal.NegativeInfinity, 15), Is.EqualTo("-oo"));
Assert.That(MathFormatting.FormatDecimal(Math.PI, 15), Is.EqualTo("3.14159265358979"));
}
}
[Test]
public void CountsTheDigitsTwoValuesShare()
{
using (Assert.EnterMultipleScope())
{
Assert.That(MathFormatting.CountMatchingDigits(EDecimal.FromString("1.0000001"), EDecimal.FromString("1.0000002"), 20), Is.EqualTo(7));
Assert.That(MathFormatting.CountMatchingDigits(EDecimal.FromString("0.99999"), EDecimal.FromString("1.00001"), 20), Is.EqualTo(4), "No digit is the same, but the values are close.");
Assert.That(MathFormatting.CountMatchingDigits(EDecimal.FromString("2.5"), EDecimal.FromString("2.5"), 20), Is.EqualTo(20));
Assert.That(MathFormatting.CountMatchingDigits(EDecimal.FromString("1"), EDecimal.FromString("-1"), 20), Is.Zero);
Assert.That(MathFormatting.CountMatchingDigits(EDecimal.NaN, EDecimal.NaN, 20), Is.Zero);
}
}
[Test]
public void RefusesAResultBeyondTheLimit()
{
var text = new string('1', MathLimits.MAX_RESULT_LENGTH + 1);
using (Assert.EnterMultipleScope())
{
Assert.That(MathFormatting.EnsureLength(new string('1', MathLimits.MAX_RESULT_LENGTH), "The result"), Has.Length.EqualTo(MathLimits.MAX_RESULT_LENGTH));
Assert.That(() => MathFormatting.EnsureLength(text, "The result"), Throws.InstanceOf<MathComputationException>().With.Message.EqualTo("The result has 20,001 characters, but at most 20,000 are returned."));
Assert.That(MathFormatting.ToLatex(MathExpressionReader.Read("547 / 57576", "expression"), "The LaTeX"), Is.EqualTo(@"\frac{547}{57576}"));
Assert.That(MathFormatting.ToText(MathExpressionReader.Read("x^3/(e^x-1)", "expression"), "The result"), Is.EqualTo("x ^ 3 / (e ^ x - 1)"));
}
}
}
@@ -0,0 +1,69 @@
using AIStudio.Tools.Mathematics;
using AngouriMath;
namespace AIStudio.Tests.Tools.Mathematics;
/// <summary>
/// Checks that the guards refuse what AngouriMath could not stop, and nothing it computes quickly.
/// </summary>
/// <remarks>
/// None of the refused expressions is computed here: each of them would run on long past the test.
/// </remarks>
[TestFixture]
public sealed class MathGuardsTests
{
private static Entity Read(string text) => MathExpressionReader.Read(text, "expression");
private static string ExactSizeRefusal(string text) => Assert.Throws<MathComputationException>(() => MathGuards.CheckExactSize(Read(text)))!.Message;
[Test]
public void RefusesAPowerWithTooManyDigits()
{
using (Assert.EnterMultipleScope())
{
Assert.That(ExactSizeRefusal("9^9^9"), Is.EqualTo("'9 ^ 9 ^ 9' would have about 369,693,100 digits as an exact number; its decimal logarithm is about 369693099.632. The tool computes such a number in full and cannot stop midway, so exact numbers are limited to 20,000 digits."));
Assert.That(ExactSizeRefusal("9^9^9^9"), Does.StartWith("'9 ^ 9 ^ 9' would have"), "The inner power is judged first, before its value is needed.");
Assert.That(ExactSizeRefusal("1 + 10^20001"), Does.StartWith("'10 ^ 20001' would have about 20,001 digits"));
Assert.That(ExactSizeRefusal("(1/2)^100000"), Does.Contain("would have about 30,103 digits"), "Numerator and denominator count.");
Assert.That(ExactSizeRefusal("sqrt(2)^1000000"), Does.Contain("would have about"), "sqrt(2)^1000000 is the exact number 2^500000.");
Assert.That(ExactSizeRefusal("x^(9^9^9)"), Does.StartWith("'9 ^ 9 ^ 9' would have"), "An exponent is a number even when the base is a variable.");
Assert.That(ExactSizeRefusal("(e^0 + 1)^(10^9)"), Does.Contain("would have about 301,029,996 digits"), "A constant is no variable: the base is the number 2.");
}
}
[Test]
public void RefusesAFactorialWithTooManyDigits()
{
using (Assert.EnterMultipleScope())
{
Assert.That(ExactSizeRefusal("(10^9)!"), Does.StartWith("'(10 ^ 9)!' would have about 8,565,705,"));
Assert.That(ExactSizeRefusal("10000!"), Does.StartWith("'10000!' would have about 35,660 digits"));
Assert.That(ExactSizeRefusal("(10!)!"), Does.StartWith("'(10!)!' would have about"));
}
}
[Test]
public void LetsThroughWhatIsComputedQuickly()
{
using (Assert.EnterMultipleScope())
{
foreach (var text in new[] { "2^10000", "10^19999", "5000!", "0.5!", "(3!)!", "x^1000000", "2^x", "pi^1000000", "e^(10^6)", "1^(10^100)", "0^(10^100)", "547 / 57576", "integral(x^3/(e^x-1), x, 0, +oo)" })
Assert.DoesNotThrow(() => MathGuards.CheckExactSize(Read(text)), text);
}
}
[Test]
public void RefusesTheExpansionOfAHighPowerOfASum()
{
using (Assert.EnterMultipleScope())
{
var refusal = Assert.Throws<MathComputationException>(() => MathGuards.CheckExpansion(Read("1 + (x + y)^500")))!;
Assert.That(refusal.Message, Is.EqualTo("'(x + y) ^ 500' raises a sum to the power of 500. Where the tool may expand, a sum is raised to exponents up to 200 only, since it cannot stop a larger expansion midway."));
Assert.Throws<MathComputationException>(() => MathGuards.CheckExpansion(Read("(x - 1)^(-300)")));
foreach (var text in new[] { "(x + y)^200", "x^500", "(x * y)^500", "(x + y)^n" })
Assert.DoesNotThrow(() => MathGuards.CheckExpansion(Read(text)), text);
}
}
}
@@ -0,0 +1,123 @@
using AIStudio.Tools.Mathematics;
using AngouriMath;
namespace AIStudio.Tests.Tools.Mathematics;
[TestFixture]
public sealed class NumericCheckTests
{
private static NumericCheckOutcome Check(string left, string right) => NumericCheck.AreEqual(MathExpressionReader.Read(left, "left"), MathExpressionReader.Read(right, "right"));
[Test]
public void PassesWhatIsEqual()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Check("sin(x)^2 + cos(x)^2", "1"), Is.EqualTo(NumericCheckOutcome.PASSED));
Assert.That(Check("(x + y)^2", "x^2 + 2*x*y + y^2"), Is.EqualTo(NumericCheckOutcome.PASSED));
Assert.That(Check("x^4 - 1", "(x + 1)*(x - 1)*(x^2 + 1)"), Is.EqualTo(NumericCheckOutcome.PASSED));
Assert.That(Check("547 / 57576", "1094 / 115152"), Is.EqualTo(NumericCheckOutcome.PASSED));
}
}
[Test]
public void PassesAnExpansionWhoseTermsCancel()
{
// At the sample point x = 1.47, the terms of the expanded form reach about 10^92 and add up to
// about 10^-66; in doubles, or at 50 digits, nothing of the value would be left:
var power = MathExpressionReader.Read("(x - 1)^200", "expression");
Assert.That(NumericCheck.AreEqual(power, power.Expand()), Is.EqualTo(NumericCheckOutcome.PASSED));
}
[Test]
public void FailsWhatDiffers()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Check("x^2", "x^3"), Is.EqualTo(NumericCheckOutcome.FAILED));
Assert.That(Check("(x + y)^2", "x^2 + y^2"), Is.EqualTo(NumericCheckOutcome.FAILED));
Assert.That(Check("pi^4/15", "6.4939394022668"), Is.EqualTo(NumericCheckOutcome.FAILED), "Fourteen digits are not equality.");
Assert.That(Check("sin(x)", "sin(x + 2*pi)"), Is.EqualTo(NumericCheckOutcome.PASSED), "pi is a constant, not a variable which takes the values of the sample points.");
Assert.That(Check("e^x", "x + 1"), Is.EqualTo(NumericCheckOutcome.FAILED));
}
}
[Test]
public void TellsTinyValuesApart()
{
// With an absolute tolerance of 1e-30, any two values below it would pass for equal:
var x = MathS.Var("x");
using (Assert.EnterMultipleScope())
{
Assert.That(Check("6.62607015e-34 * x", "6.62607015e-35 * x"), Is.EqualTo(NumericCheckOutcome.FAILED));
Assert.That(Check("6.62607015e-34 * sin(x)", "6.62607015e-34 * sin(x) * (1 + 1e-20)"), Is.EqualTo(NumericCheckOutcome.FAILED));
Assert.That(Check("6.62607015e-34 * x^2", "x * 6.62607015e-34 * x"), Is.EqualTo(NumericCheckOutcome.PASSED));
Assert.That(Check("sin(2*x) - 2*sin(x)*cos(x)", "0"), Is.EqualTo(NumericCheckOutcome.PASSED), "Against 0, the tolerance stays absolute, since terms which cancel leave their rounding noise.");
Assert.That(NumericCheck.IsDerivativeOf(MathExpressionReader.Read("1.32521403e-33 * x", "derivative"), MathExpressionReader.Read("6.62607015e-34 * x^2", "function"), x), Is.EqualTo(NumericCheckOutcome.PASSED));
Assert.That(NumericCheck.IsDerivativeOf(MathExpressionReader.Read("6.62607015e-34 * x", "derivative"), MathExpressionReader.Read("6.62607015e-34 * x^2", "function"), x), Is.EqualTo(NumericCheckOutcome.FAILED));
}
}
[Test]
public void ComparesWhereAConditionHolds()
{
var simplified = MathExpressionReader.Read("(x^2 - 1)/(x - 1)", "expression").Simplify();
using (Assert.EnterMultipleScope())
{
Assert.That(simplified, Is.TypeOf<Entity.Providedf>());
Assert.That(NumericCheck.AreEqual(simplified, MathExpressionReader.Read("x + 1", "expression")), Is.EqualTo(NumericCheckOutcome.PASSED));
}
}
[Test]
public void IsNotPossibleWithoutValues()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Check("integral(sin(sin(x)), x)", "x"), Is.EqualTo(NumericCheckOutcome.NOT_POSSIBLE), "An integral without a closed form has no value.");
Assert.That(Check("1/(x - x)", "1/(y - y)"), Is.EqualTo(NumericCheckOutcome.NOT_POSSIBLE), "No sample point has a value.");
}
}
[Test]
public void ChecksADerivativeByItsCentralDifference()
{
var x = MathS.Var("x");
using (Assert.EnterMultipleScope())
{
Assert.That(NumericCheck.IsDerivativeOf(MathExpressionReader.Read("2*x*sin(x) + x^2*cos(x)", "derivative"), MathExpressionReader.Read("x^2*sin(x)", "function"), x), Is.EqualTo(NumericCheckOutcome.PASSED));
Assert.That(NumericCheck.IsDerivativeOf(MathExpressionReader.Read("2*x + 1", "derivative"), MathExpressionReader.Read("x^2", "function"), x), Is.EqualTo(NumericCheckOutcome.FAILED));
Assert.That(NumericCheck.IsDerivativeOf(MathExpressionReader.Read("a", "derivative"), MathExpressionReader.Read("a*x + C", "function"), x), Is.EqualTo(NumericCheckOutcome.PASSED), "Other variables, the constant of integration among them, take sample values.");
}
}
[Test]
public void GivesAnIntegerParameterIntegerValues()
{
var parameter = MathS.Var("n_1");
var values = Enumerable.Range(0, 6).Select(point => NumericCheck.SampleAssignment(point, [parameter])[parameter]).Distinct().ToList();
using (Assert.EnterMultipleScope())
{
Assert.That(values, Is.EquivalentTo(new Entity[] { -1, 0, 1 }));
Assert.That(NumericCheck.IsIntegerParameter(MathS.Var("n_12")), Is.True);
Assert.That(NumericCheck.IsIntegerParameter(MathS.Var("n")), Is.False);
Assert.That(NumericCheck.IsIntegerParameter(MathS.Var("n_x")), Is.False);
}
}
[Test]
public void TheSamplePointsAreFixedAndDifferForEachVariable()
{
var variables = new[] { MathS.Var("x"), MathS.Var("y"), MathS.Var("z") };
var first = NumericCheck.SampleAssignment(0, variables);
var again = NumericCheck.SampleAssignment(0, variables);
using (Assert.EnterMultipleScope())
{
Assert.That(again, Is.EqualTo(first));
Assert.That(first.Values.Distinct().Count(), Is.EqualTo(3));
Assert.That(first[variables[0]], Is.EqualTo(Entity.Number.Rational.Create(61, 100)));
Assert.That(NumericCheck.SampleAssignment(1, variables)[variables[0]], Is.Not.EqualTo(first[variables[0]]));
}
}
}
@@ -1,6 +1,11 @@
using AIStudio.Provider;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Mathematics;
using AIStudio.Tools.ToolCallingSystem;
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mailboxes;
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mathematics;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Tools.ToolCalling;
@@ -188,6 +193,39 @@ public sealed class ToolCollectionTests : ToolRegistryTestBase
}
}
[Test]
public void TheMathematicsCollectionGathersTheToolsWhichCompute()
{
var definition = new MathematicsToolCollection().GetDefinition();
using (Assert.EnterMultipleScope())
{
Assert.That(definition.Id, Is.EqualTo(ToolSelectionRules.MATHEMATICS_COLLECTION_ID));
Assert.That(definition.ToolIds, Is.EqualTo(new[] { ToolSelectionRules.CALCULATE_TOOL_ID, ToolSelectionRules.COMPUTE_SYMBOLICALLY_TOOL_ID }));
Assert.That(definition.MinimumProviderConfidence, Is.EqualTo(ConfidenceLevel.NONE), "The tools see nothing but what the model writes into their arguments.");
}
}
[Test]
public async Task TheMathematicsCollectionAppearsOnlyWithItsPreview()
{
var computationService = new MathComputationService(NullLogger<MathComputationService>.Instance);
IToolImplementation[] tools = [new CalculateTool(this.SettingsManager, computationService), new ComputeSymbolicallyTool(this.SettingsManager, computationService)];
var registry = new ToolRegistry(tools, [new CodeToolDefinitionSource(tools)], [new MathematicsToolCollection()], this.SettingsManager, this.CreateToolSettingsService(), NullLogger<ToolRegistry>.Instance);
var withoutPreview = await registry.GetCatalogAsync(AIStudio.Tools.Components.CHAT);
this.SettingsManager.ConfigurationData.App.EnabledPreviewFeatures.Add(PreviewFeatures.PRE_MATHEMATICS_2026);
var withPreview = await registry.GetCatalogAsync(AIStudio.Tools.Components.CHAT);
using (Assert.EnterMultipleScope())
{
Assert.That(withoutPreview, Is.Empty);
Assert.That(withPreview.Select(item => item.Id), Is.EqualTo(new[] { ToolSelectionRules.MATHEMATICS_COLLECTION_ID }), "Both tools appear as one collection.");
Assert.That(registry.GetCollectionId(ToolSelectionRules.COMPUTE_SYMBOLICALLY_TOOL_ID), Is.EqualTo(ToolSelectionRules.MATHEMATICS_COLLECTION_ID));
Assert.That(registry.ExpandSelection([ToolSelectionRules.MATHEMATICS_COLLECTION_ID]), Is.EquivalentTo(new[] { ToolSelectionRules.CALCULATE_TOOL_ID, ToolSelectionRules.COMPUTE_SYMBOLICALLY_TOOL_ID }));
}
}
private Dictionary<string, string> StoredLevels => this.SettingsManager.ConfigurationData.Tools.MinimumProviderConfidenceByToolId;
/// <summary>
+199
View File
@@ -0,0 +1,199 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using AIStudio.Tools.Mathematics;
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mathematics;
using AngouriMath.Core;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.TrimSmokeTest;
/// <summary>
/// Lets the Mathematics tools compute what their tests compute, because AngouriMath is marked as trimmable.
/// </summary>
/// <remarks>
/// The trimmer therefore removes every part of AngouriMath which nothing but reflection reaches, and
/// Trimming.props suppresses the warnings which would name such a part. Each check takes another part of it:
/// the parser, exact and precise arithmetic, LaTeX, Compile for the numerical integration, and the symbolic
/// operations together with their numerical check. The expected values are those of the tests in
/// app/Tests/Tools/Mathematics, which run untrimmed.
/// </remarks>
public static class MathematicsChecks
{
private static readonly MathComputationService SERVICE = new(NullLogger<MathComputationService>.Instance);
/// <summary>
/// Returns a description of every computation which fails or gives another result than untrimmed.
/// </summary>
/// <returns>One line per failure, naming the computation and what went wrong.</returns>
public static IReadOnlyList<string> FindFailures()
{
var failures = new List<string>();
//
// The parser, exact arithmetic, the decimal expansion, and LaTeX:
//
Expect(failures, "calculate 547 / 57576", Calculation("547 / 57576", 20), new()
{
["exact"] = "547/57576",
["exact_latex"] = @"\frac{547}{57576}",
["decimal"] = "0.0095004863137418368765",
});
//
// Arithmetic with many digits, which takes the guard digits:
//
Expect(failures, "calculate pi^4/15 to 60 digits", Calculation("pi^4/15", 60), new()
{
["decimal"] = "6.49393940226682914909602217924700741664850571151236144609786",
["digits"] = "60",
});
//
// Divisors, multiples, and a sum in closed form:
//
Expect(failures, "calculate gcd, lcm, and a sum", Calculation("gcd(12, 18) + lcm(4, 6) + sum(k^2, k, 1, 100)", 20), new()
{
["exact"] = "338368",
});
//
// A tiny value, which AngouriMath's downcasting would turn into 0, and one evaluated without it:
//
Expect(failures, "calculate the energy of a photon of 500 nm", Calculation("6.62607015e-34 * 299792458 / 500e-9", 20), new()
{
["decimal"] = "3.9728917142978574e-19",
["digits"] = "20",
});
Expect(failures, "calculate e^(-100) * pi", Calculation("e^(-100) * pi", 20), new()
{
["decimal"] = "1.1686963357062938093e-43",
});
//
// The numerical integration, whose integrand AngouriMath compiles:
//
Expect(failures, "check the integral of x^3/(e^x-1) from 0 to infinity", Calculation("integral(x^3/(e^x-1), x, 0, +oo) = pi^4/15", 20), new()
{
["numerically_equal"] = "true",
["left/approximate"] = "true",
["right/expression"] = "pi ^ 4 / 15",
});
//
// The symbolic operations, each with its numerical check:
//
Expect(failures, "simplify (x^2 - 1)/(x - 1)", Symbolic(SymbolicOperation.SIMPLIFY, "(x^2 - 1)/(x - 1)"), new()
{
["result"] = "x + 1",
["condition"] = "not x - 1 = 0",
["verification"] = "passed",
});
Expect(failures, "expand (x + 1)^3", Symbolic(SymbolicOperation.EXPAND, "(x + 1)^3"), new()
{
["result"] = "1 + 3 * x + 3 * x ^ 2 + x ^ 3",
["verification"] = "passed",
});
Expect(failures, "factor x^4 - 1", Symbolic(SymbolicOperation.FACTOR, "x^4 - 1"), new()
{
["result"] = "(x + 1) * (x - 1) * (x ^ 2 + 1)",
["verification"] = "passed",
});
Expect(failures, "differentiate x^2*sin(x)", Symbolic(SymbolicOperation.DIFFERENTIATE, "x^2*sin(x)"), new()
{
["result"] = "x ^ 2 * cos(x) + 2 * x * sin(x)",
["verification"] = "passed",
});
Expect(failures, "integrate x^2", Symbolic(SymbolicOperation.INTEGRATE, "x^2"), new()
{
["result"] = "x ^ 3 / 3 + C",
["verification"] = "passed",
});
Expect(failures, "integrate 1/(1 + x^2) from 0 to infinity", Symbolic(SymbolicOperation.INTEGRATE, "1/(1 + x^2)", lowerBound: "0", upperBound: "+oo"), new()
{
["result"] = "pi / 2",
["verification"] = "passed",
});
Expect(failures, "take the limit of sin(x)/x at 0", Symbolic(SymbolicOperation.LIMIT, "sin(x)/x", approach: "0"), new()
{
["result"] = "1",
});
Expect(failures, "solve x^2 - 5*x + 6 = 0", Symbolic(SymbolicOperation.SOLVE, "x^2 - 5*x + 6 = 0"), new()
{
["solutions/0/value"] = "2",
["solutions/1/value"] = "3",
["verification"] = "passed",
});
return failures;
}
private static Func<JsonObject> Calculation(string expression, int digits) => () => SERVICE.ComputeAsync(token => CalculateTool.Calculate(expression, digits, token)).GetAwaiter().GetResult();
private static Func<JsonObject> Symbolic(SymbolicOperation operation, string expression, string? lowerBound = null, string? upperBound = null, string? approach = null) => () => SERVICE.ComputeAsync(_ => ComputeSymbolicallyTool.Compute(new SymbolicRequest(operation, expression, null, 1, lowerBound, upperBound, approach, ApproachFrom.BothSides))).GetAwaiter().GetResult();
/// <summary>
/// Runs a computation and compares the values of its result with the expected ones.
/// </summary>
/// <param name="failures">Where to add a failure.</param>
/// <param name="task">What the computation does, completing "The Mathematics tools could not ...".</param>
/// <param name="compute">The computation.</param>
/// <param name="expected">The expected values, by their path in the result, e.g., solutions/0/value.</param>
private static void Expect(List<string> failures, string task, Func<JsonObject> compute, Dictionary<string, string> expected)
{
JsonObject result;
try
{
result = compute();
}
catch (Exception exception)
{
//
// A member the trimmer removed surfaces as the innermost exception, e.g., below a TypeInitializationException:
//
var cause = exception.GetBaseException();
failures.Add($"The Mathematics tools could not {task}: {cause.GetType().Name}: {cause.Message}");
return;
}
foreach (var (path, value) in expected)
{
var actual = TextAt(result, path);
if (actual != value)
failures.Add($"The Mathematics tools gave '{actual ?? "nothing"}' instead of '{value}' for {path} when asked to {task}.");
}
}
private static string? TextAt(JsonNode? node, string path)
{
foreach (var step in path.Split('/'))
{
if (int.TryParse(step, NumberStyles.None, CultureInfo.InvariantCulture, out var index))
node = node is JsonArray array && index < array.Count ? array[index] : null;
else
node = node is JsonObject json ? json[step] : null;
}
if (node is null)
return null;
return node.GetValueKind() switch
{
JsonValueKind.String => node.GetValue<string>(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => node.ToString(),
};
}
}
+4 -1
View File
@@ -3,7 +3,8 @@ using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.TrimSmokeTest;
/// <summary>
/// Checks whether a trimmed build can still read and write the JSON of AI Studio, see TrimSmokeTest.csproj.
/// Checks whether a trimmed build can still read and write the JSON of AI Studio, and compute with the
/// Mathematics tools, see TrimSmokeTest.csproj.
/// </summary>
public static class Program
{
@@ -28,9 +29,11 @@ public static class Program
failures.AddRange(JsonContractWalker.FindBrokenContracts(root));
failures.AddRange(JsonRoundTrips.FindLosses());
failures.AddRange(MathematicsChecks.FindFailures());
if (failures.Count is 0)
{
Console.WriteLine($"- All {roots.Count} JSON roots can be read and written.");
Console.WriteLine("- The Mathematics tools compute what they compute untrimmed.");
return 0;
}
+3 -2
View File
@@ -2,8 +2,9 @@
<!--
Checks what only a published build of AI Studio lacks. The trimmer removes code of .NET which System.Text.Json
reaches by reflection only, while debug builds and the tests are not trimmed. Hence, this program has to run
published, trimmed by the same Trimming.props as the app; `dotnet run verify` does both.
reaches by reflection only, and the same holds for AngouriMath, which is marked as trimmable, while debug builds
and the tests are not trimmed. Hence, this program has to run published, trimmed by the same Trimming.props as
the app; `dotnet run verify` does both.
-->
<PropertyGroup>
<OutputType>Exe</OutputType>
+1 -1
View File
@@ -35,7 +35,7 @@ It runs the .NET tests, the trim smoke test, the Rust tests, Clippy (`cargo clip
`dotnet run build` runs the gate first and stops when it does not pass. For the quick loop while you are working on something, use `dotnet run build --skip-verify`, and let the gate run before you open a pull request. The same command runs in our GitHub workflow as the `verify` job, on every pull request which changes more than the documentation — including those without the `run-pipeline` label, because a gate which is closed exactly while nobody is looking is not a gate.
Three notes:
- The trim smoke test publishes `app/TrimSmokeTest` trimmed, exactly like the app, and reads and writes the JSON of chats, settings, Visual Briefing projects, backups, and model answers. Debug builds and the tests are not trimmed, so only a published build shows when the trimmer removed code which System.Text.Json needs; AI Studio can then neither load nor save that data. When AI Studio starts to store a new kind of file or to read a new kind of model answer, add its type to `JsonRoots` in `app/TrimSmokeTest`.
- The trim smoke test publishes `app/TrimSmokeTest` trimmed, exactly like the app, and reads and writes the JSON of chats, settings, Visual Briefing projects, backups, and model answers. Debug builds and the tests are not trimmed, so only a published build shows when the trimmer removed code which System.Text.Json needs; AI Studio can then neither load nor save that data. When AI Studio starts to store a new kind of file or to read a new kind of model answer, add its type to `JsonRoots` in `app/TrimSmokeTest`. The smoke test also lets the Mathematics tools compute a few expressions, since AngouriMath is trimmed, too; see `MathematicsChecks` there.
- The Rust half of the gate needs the .NET sidecar (see "One-time mandatory steps" above). While that file is missing, the gate skips the Rust tests and Clippy and says so rather than failing, because the command which produces the sidecar is `dotnet run build` itself.
- `dotnet run verify-models` reports how long ago somebody last read the pages behind the model rules, and names everything older than six months. It is a report and never a failure: a page nobody has looked at for a while is not a page which changed. Everything else about the model rules — whether two rules claim the same names, whether every family names a page and a day, whether every pattern is written the way model names arrive — is checked by the test project, and therefore by `dotnet test`.
+55 -1
View File
@@ -116,7 +116,7 @@ Three methods of the registry translate between both views:
- `ToolRegistry.ExpandSelection` turns a selection into the tools which run. Preparing a request, counting its tokens, the security card of an assistant plugin, and its audit use it, because they are about what the model reads.
- `ToolRegistry.GetCollectionId` names the collection of a tool.
The settings of a tool stay with the tool, also inside a collection. An organization addresses them by `"<toolId>.<fieldName>"`, and the settings dialog of a collection shows one section per tool. `mailboxes` is the only declared collection so far, with `search_mails`, `read_mail`, and `count_mails`.
The settings of a tool stay with the tool, also inside a collection. An organization addresses them by `"<toolId>.<fieldName>"`, and the settings dialog of a collection shows one section per tool. The declared collections so far are `mailboxes`, with `search_mails`, `read_mail`, and `count_mails`, and `mathematics`, with `calculate` and `compute_symbolically`.
## Switched-Off Tools
@@ -253,6 +253,60 @@ Mails are written by others. Everything of a mail which reaches the model, from
Logs name a mailbox and its ID, never a subject, a sender or recipient, a folder, or an attachment. `ToolExecutor` logs the message of every exception, so a mail tool must not throw with such a value either. A `folder` or a `special_folder` the mailboxes do not know is therefore answered in the result, together with the folders to choose from, rather than refused by an exception.
## Mathematics
`calculate` and `compute_symbolically` form the collection `mathematics`, behind the preview `PRE_MATHEMATICS_2026`. Both compute locally with AngouriMath, through the classes in `Tools/Mathematics/`. Nothing leaves AI Studio, so their `OutboundData` is `NONE`, and the collection asks for no provider confidence.
- `calculate` evaluates an expression without free variables: exactly, where the value is rational, and as a decimal with up to 100 significant digits, 20 by default. An expression with `=` evaluates both sides and says in how many digits they agree.
- `compute_symbolically` simplifies, expands, or factors an expression, differentiates it, integrates it with or without bounds, takes a limit, or solves an equation or an inequality. It refuses every argument which the operation does not use.
### Syntax
Both tools read plain syntax, not LaTeX, which `CalculateTool.SYNTAX` describes for both schemas. The chat renders LaTeX, so each result carries a LaTeX version besides. `MathExpressionReader` parses with `ExplicitParsingOnly`: otherwise AngouriMath reads `x2` as `x^2` and `foo(3)` as `foo * 3`, and the model would get an answer to a question it did not ask. Before parsing, the reader refuses what AngouriMath reads differently from what a model means: `!=`, which AngouriMath reads as `a! = b`, while not-equal is `<>`; `|`, which means "divides"; the variable `oo`, while infinity is `+oo` or `-oo`; and more than one `=`. It shortens the parser's messages, which list over 1,000 characters of tokens, to the column and the cause.
Every decimal literal is the fraction it writes, so `0.1 + 0.2` is exactly `3/10` and `6.62607015e-34` is exactly `132521403/200000000000000000000000000000000000000000`. AngouriMath alone does that for some literals only: it downcasts while parsing, keeps `6.62607015e-34` an inexact decimal, and reads `1e-60` as `0`. Simplifying then drops an inexact decimal from a sum, so `1 + 1e-40` would be exactly `1`. The reader therefore parses without downcasting and without AngouriMath's cache of parsed expressions, and turns each literal into its fraction itself.
AngouriMath computes `sum(f, k, a, b)` only in closed form, as for powers of `k`, so `sum(k, k, 1, 10^7)` takes no time, while `sum(1/k, k, 1, 1000)` has no value. Such a sum, an infinite one, or a matrix is refused by `NumericEvaluator` in its own words, since AngouriMath's message addresses a developer.
No text the model reads names AngouriMath: neither the instructions and schemas of the tools, nor a refusal or an issue. Where a sentence needs someone who computes, it says "the tool", as in "The tool found no closed form for this integral.": a model would take the name of the library for something it may call or look up. The tests of both tools check their definitions for it.
### Honest Digits
A decimal shows only the digits the computation vouches for. `NumericEvaluator` evaluates twice, with 10 and with 20 guard digits, and keeps the digits in which both agree, because composite expressions lose a digit or two at the end. When fewer digits remain than were asked for, `issues` says so.
Tiny values need care, as with the Planck constant. By default, AngouriMath downcasts while evaluating, too, and takes a value such as `1.380649e-23 * 300` for the integer `0`. `NumericEvaluator.EvaluateAt` evaluates without downcasting. Then AngouriMath rounds a quotient to as many decimal places as the precision has digits, so the precision grows by the leading zeros of the smallest fraction and of the value, up to 1,000 digits. Simplifying, too, takes a tiny value for `0`: below about `1e-1000`, or near the precision where it evaluates a function. An exact `0` is therefore checked numerically. Rounding noise, as for `sin(pi)`, changes with the precision; a tiny value keeps its digits and is reported as a decimal instead. A `0` that remains after 1,000 digits without being exact comes with a note in `issues` that it may stand for a tiny value.
A definite integral always goes to `DoubleExponentialQuadrature`, never through an antiderivative, which gives wrong values when it jumps between the bounds, and never through `Evaled`, which turns constants into decimals even inside an integral it cannot solve. The quadrature works with doubles and estimates its error. The result is then `approximate`, with its `estimated_error`, and its digits drop to what the error allows, mostly 12 to 15. An integral which does not settle, or which has no finite value at a point, is refused rather than answered with a wrong number.
### Checking Symbolic Results
A symbolic answer is only as good as the library which gave it, and AngouriMath changes answers between releases. `compute_symbolically` therefore checks each result numerically where it can, and reports `passed`, `failed`, or `not_possible` in `verification`:
- `simplify`, `expand`, and `factor`: the expression and the result are equal.
- `differentiate`: each order agrees with a central difference of the order before, at 60 digits.
- `integrate` without bounds: the derivative of the antiderivative is the integrand.
- `integrate` with bounds: the value agrees with the quadrature.
- `solve`: each solution satisfies the equation.
- `limit`: not checked.
`NumericCheck` compares at six fixed sample points. They are exact rationals, so a polynomial is evaluated exactly, however much the terms of its expansion cancel; everything else is evaluated at 50 digits, without downcasting. Two values agree within a relative tolerance, so tiny values, such as those of a formula with physical constants, are still told apart. Against `0`, the tolerance is absolute, because terms which cancel leave their rounding noise. The points are fixed, so the same call always gets the same verdict. Parameters which AngouriMath brings into a solution, such as `n_1` for any integer, take integer values there. Where AngouriMath finds no closed form, `complete` is false, and `result` still holds the integral, the limit, or the equation.
### Limits
AngouriMath does not stop every computation when asked to: `9^9^9`, `(10^9)!`, and the expansion of `(x+y)^500` run on whatever the cancellation token says. `MathGuards` therefore refuses before computing what would take too long. That is a power or a factorial whose exact value would have more than 20,000 digits, estimated from the inside out, and a power of a sum with an exponent above 200, wherever AngouriMath may expand it. An expression has at most 2,000 characters, and a result at most 20,000.
`MathComputationService` runs every computation on a thread of its own with a stack of 64 MB. About 500 nested parentheses overflow the stack of a thread pool thread, and that ends the process. There, it switches off Newton's method for `solve`, whose inexact doubles would pass for exact solutions. It hands the cancellation to AngouriMath. The service has one slot, and a computation gets 10 seconds. The slot becomes free when the computation ends, not when the call gives up: a computation which does not stop holds up the next ones rather than taking more cores.
### Why There Is No Prompt Injection Filter
The tools open no path for content from outside AI Studio. A result holds the expression of the model as AngouriMath read it, and values computed from it; even a parse error repeats only the input of the model. Neither tool calls `PromptInjectionGuardService` or declares `ReturnsUntrustedExternalContent`. A tool of this collection which reads anything else, such as a file, would need both.
### AngouriMath
`AngouriMathBehaviorTests` pins the behavior of AngouriMath which the tools build on, such as the traps of the parser, the digits of its functions, and the computations which ignore cancellation. The version is pinned exactly by `packages.lock.json`. Before raising it, read the release notes for silent breaking changes, and run these tests.
AngouriMath is marked as trimmable, so a published build of AI Studio contains only the parts of it which the trimmer saw being used, and `Trimming.props` suppresses the warnings about the others. The trim smoke test of `dotnet run verify` therefore lets both tools compute a few expressions in a trimmed build, see `MathematicsChecks` in `app/TrimSmokeTest`. When a tool starts to use another part of AngouriMath, such as a new operation, add a check there.
## Checklist
- Add the `IToolImplementation` class, including its `GetDefinition()`.