From fc4163f3e43c27f1ccd5d413f885556878640cb3 Mon Sep 17 00:00:00 2001 From: dohomeworkbyzzy <2745349885@qq.com> Date: Thu, 22 Oct 2026 10:05:11 +0800 Subject: [PATCH] feat: add verified first-order implicit differentiation with teaching steps --- README.md | 21 +++++- backend/app.py | 3 + backend/engine.py | 11 +++- backend/implicit.py | 114 +++++++++++++++++++++++++++++++++ backend/parser.py | 15 ++++- tests/browser/implicit.spec.ts | 49 ++++++++++++++ tests/test_api.py | 16 +++++ tests/test_implicit.py | 99 ++++++++++++++++++++++++++++ web/Calculator.tsx | 11 ++-- web/Steps.tsx | 2 +- 10 files changed, 328 insertions(+), 13 deletions(-) create mode 100644 backend/implicit.py create mode 100644 tests/browser/implicit.spec.ts create mode 100644 tests/test_implicit.py diff --git a/README.md b/README.md index ea97bbd..8dc6e15 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ ## 已实现 -- **积分与求导**:精确结果、中文规则树、独立验证,答案 / 完整步骤 / 逐步提示;支持定积分、常见反常积分、高阶导数和偏导。 +- **积分与求导**:精确结果、中文规则树、独立验证,答案 / 完整步骤 / 逐步提示;支持定积分、常见反常积分、高阶导数、偏导及二元关系的一阶隐函数求导。 - **矩阵与行列式**:表格输入、矩阵运算、秩、线性方程组、行最简形、增广矩阵求逆;展示可重放的初等行变换,与 SymPy 交叉验证。 - **本地题库**:数学一 / 二 / 三;导入 PDF、扫描 PDF、DOCX,保留原文件和可用截图,人工校对、拆分、合并及裁切。 - **做题工作台**:题目与草稿动态分配空间,可以拖动调整;计算器可收起、全屏或浮动,结果与完整步骤可插入笔记。 @@ -78,7 +78,7 @@ bash start.sh 试卷自动拆题是候选结果,需要人工校对。扫描件 OCR 需要本地 Tesseract;DOCX 保留原版式截图需要 LibreOffice,否则仅提取可识别的内容。OCR 公式送入计算器前必须确认。 -原始触控板书写仅支持 Windows 上可读取的 Precision Touchpad,建议使用外接鼠标。不同设备需要实际试写确认,普通鼠标画布可以直接使用。笔迹暂不自动识别成公式,隐函数求导、自动批改和排行榜尚未实现。 +原始触控板书写仅支持 Windows 上可读取的 Precision Touchpad,建议使用外接鼠标。不同设备需要实际试写确认,普通鼠标画布可以直接使用。笔迹暂不自动识别成公式,自动批改和排行榜尚未实现。隐函数模式目前只支持二元关系的一阶求导,不支持高阶隐导数、额外参数或自动分支分析。 ## 数据与开发 @@ -131,3 +131,20 @@ npm run build 配套手机测试版下载、黑屏操作的实际含义及许可说明,请阅读 [手机黑屏草稿使用说明](docs/手机黑屏草稿使用说明.md)。APK 是纯黑画面和最低窗口亮度下的触摸操作,不是系统锁屏后手写。版本变化见 [更新记录](CHANGELOG.md)。 ![小窗口优先显示题目与草稿](docs/screenshots/compact-practice.png) + + +### 一阶隐函数求导 + +在「求导」中选择「一阶隐函数求导」,输入 `x^2+y^2=1` 或 `sin(x*y)=x+y`,指定不同的自变量和因变量。也支持公式输入;省略等号时将表达式作为 F=0。可同时填写两个点坐标,检查该点是否满足方程及原始表达式的实数定义域。 + +过程展示移项、将 y 看成 y(x) 后的链式法则、整理含 y′ 的项、除以 Fy 和化简。最终答案同时通过 SymPy `idiff` 与全微分关系回代检查。原关系须在所考察点邻域内满足 C¹,且 Fy≠0;原方程中的分母、对数、根式等定义域限制不会因化简而取消。本功能不自动证明邻域光滑性、存在性或唯一分支。 + +Fy=0 时不直接断言不能求导:Fx≠0 时仅在 C¹ 条件下报告无有限导数;Fx=0 或无法确定时报告当前方法无法判定。例如 `y^3=x^3` 在原点仍可能可导,需进一步分析。`verified` 表示条件下的符号结果已验证;`steps_complete=false` 可以只表示中文规则未完整展开,不意味着导数不存在。独立验证失败时隐藏最终导数,返回 `unverified`。 + +接口复用 `POST /api/math/differentiate`: + +```json +{"expression":"x^2+y^2=1","mode":"implicit","variable":"x","dependent_variable":"y","point":{"x":"3/5","y":"4/5"}} +``` + +`point` 可省略。隐函数模式只接受 `order=1`,不接受 `variables`;额外符号参数、连等式和非数学输入会被拒绝。普通模式默认 `mode=explicit`,现有普通求导、混合偏导、积分和历史 API 保持兼容;隐函数的模式、点坐标及步骤也保存到原计算历史中。 diff --git a/backend/app.py b/backend/app.py index 05f4552..2eafff8 100644 --- a/backend/app.py +++ b/backend/app.py @@ -67,6 +67,9 @@ class IntegrateInput(CalculusInput): lower:str|None=Field(default=None,max_length=100) upper:str|None=Field(default=None,max_length=100) class DifferentiateInput(CalculusInput): + mode:Literal['explicit','implicit']='explicit' + dependent_variable:str=Field(default='y',min_length=1,max_length=1) + point:dict[str,str]|None=Field(default=None,max_length=2) order:int=Field(default=1,ge=1,le=8) variables:list[str]|None=Field(default=None,max_length=8) class MatrixInput(Input): diff --git a/backend/engine.py b/backend/engine.py index 4a01b6a..9709362 100644 --- a/backend/engine.py +++ b/backend/engine.py @@ -201,14 +201,16 @@ def integrate_job(req): out['steps']=steps+bounded_steps; return out -def derivative_trace(f,x,tr,depth=0): +def derivative_trace(f,x,tr,depth=0,dependent=None): actual=s.diff(f,x); kids=[]; formula=None if depth>18: tr.complete=False return tr.step('symbolic_derivative',s.Derivative(f,x,evaluate=False),actual,'表达式过深,仅给出符号求导结果。',kind='derivative',verified=True) - def sub(g): return derivative_trace(g,x,tr,depth+1) + def sub(g): return derivative_trace(g,x,tr,depth+1,dependent) if not f.has(x): rule='constant'; rhs=s.S.Zero; explanation='对当前变量,常数和其他独立变量的导数为零。' elif f==x: rule='identity'; rhs=s.S.One; explanation='变量对自身的导数为 1。' + elif dependent is not None and f==dependent: + rule='dependent_variable'; rhs=actual; explanation='将因变量视为自变量的函数,其导数保留为一阶导数,不能按独立变量处理为零。' elif f.is_Add: rule='sum'; kids=[sub(g) for g in f.args]; rhs=sum(s.diff(g,x) for g in f.args); explanation='按求导的线性性质逐项求导。' elif f.is_Mul: @@ -239,6 +241,11 @@ def sub(g): return derivative_trace(g,x,tr,depth+1) def differentiate_job(req): + if req.get('mode','explicit')=='implicit': + from .implicit import implicit_differentiate_job + return implicit_differentiate_job(req) + if req.get('mode','explicit')!='explicit': raise MathInputError('不支持的求导模式。') + if req.get('point') is not None: raise MathInputError('点值检查仅用于隐函数模式。') tr=Trace(); f=s.simplify(parse(req['expression'])); x=variable(req.get('variable','x')) if f.has(s.zoo,s.nan,s.oo,-s.oo):raise MathInputError('待求导函数不能包含无穷大或未定义常量。') order=req.get('order',1) diff --git a/backend/implicit.py b/backend/implicit.py new file mode 100644 index 0000000..208e4e8 --- /dev/null +++ b/backend/implicit.py @@ -0,0 +1,114 @@ +"""First-order implicit differentiation without solving for a branch of y.""" +import sympy as s +from .parser import parse, parse_equation, variable, MathInputError +from .engine import Trace, response, equal, derivative_trace, all_verified + + +def implicit_differentiate_job(req): + if type(req.get('order',1)) is not int or req.get('order',1)!=1 or req.get('variables') is not None: + raise MathInputError('隐函数模式仅支持一阶求导,不能同时指定混合偏导变量序列。') + x=variable(req.get('variable','x')); y=variable(req.get('dependent_variable','y')) + if x==y: raise MathInputError('自变量和因变量必须不同。') + lhs,rhs=parse_equation(req['expression']) + if (lhs.free_symbols|rhs.free_symbols)-{x,y}: + raise MathInputError('当前隐函数模式仅支持所选的两个变量,不支持额外参数。') + if any(v.has(s.zoo,s.nan,s.oo,-s.oo) for v in (lhs,rhs)): + raise MathInputError('方程不能包含无穷大或未定义常量。') + raw=s.Add(lhs,-rhs,evaluate=False); f=s.simplify(raw) + if f.has(s.zoo,s.nan,s.oo,-s.oo) or any( + not node.free_symbols and (node.is_real is False or node.is_finite is False) + for node in s.preorder_traversal(raw)): + raise MathInputError('方程包含未定义或非实数常量,请检查实数定义域。') + fx=s.simplify(s.diff(f,x)); fy=s.simplify(s.diff(f,y)) + tr=Trace(); steps=[]; equation=s.Eq(lhs,rhs,evaluate=False) + assumptions=['所有变量为实数;须满足原方程及原始表达式的实数定义域(化简不扩大定义域)。', + '在所考察点的邻域内,原关系 F 的一阶偏导数须连续(C¹)。', + s.latex(s.Eq(f,0,evaluate=False)),s.latex(fy)+r'\ne0'] + tr.warnings.append('Fy≠0 是此公式适用的充分条件,并非可微的必要条件;Fy=0 时不能直接用该公式判断导数不存在。') + if raw.has(s.Abs): + tr.warnings.append('含绝对值:须另行检查折点;符号结果仅在原关系满足 C¹ 条件的区域使用。') + + # Keep the unsimplified operands for point-domain checks, including removable holes. + point=req.get('point'); substitutions=None + if point is not None: + if not isinstance(point,dict) or set(point)!={str(x),str(y)}: + raise MathInputError('点坐标必须恰好包含自变量和因变量。') + substitutions={v:s.simplify(parse(point[str(v)])) for v in (x,y)} + if any(v.free_symbols or v.is_real is not True or v.is_finite is not True for v in substitutions.values()): + raise MathInputError('点坐标必须是明确的有限实数。') + for node in s.preorder_traversal(raw): + value=node.subs(substitutions,simultaneous=True) + if value.is_real is not True or value.is_finite is not True: + raise MathInputError('该点不在原始表达式可确认的实数定义域内。') + if not equal(raw.subs(substitutions,simultaneous=True),0): + raise MathInputError('给定点不满足原方程。') + assumptions.append('点坐标:'+', '.join(f'{v}={value}' for v,value in substitutions.items())) + + def relation(rule,before,after,explanation,ok,**extra): + return tr.step(rule,before,after,explanation,kind='implicit_relation',verified=ok,**extra) + steps.append(relation('implicit_rearrange',equation,s.Eq(f,0,evaluate=False), + '将右侧移到左侧,记 F=左侧−右侧;保留原方程的定义域限制。',equal(raw,f))) + yfun=s.Function(str(y))(x); lifted=raw.subs(y,yfun) + chain=derivative_trace(lifted,x,tr,dependent=yfun) + derivative=s.diff(lifted,x); p=s.Symbol(str(y)+"'") + total=derivative.subs(s.diff(yfun,x),p).subs(yfun,y) + collected=fx+fy*p + steps.append(relation('implicit_chain',s.Eq(s.Derivative(lifted,x,evaluate=False),0,evaluate=False), + s.Eq(total,0,evaluate=False),'把因变量看成函数,逐项使用乘积法则和链式法则。', + equal(total,collected),children=[chain],formula_latex=r'\frac{dF}{d'+str(x)+r'}=F_'+str(x)+r'+F_'+str(y)+r'\frac{d'+str(y)+r'}{d'+str(x)+r'}=0')) + steps.append(relation('implicit_collect',s.Eq(total,0,evaluate=False), + s.Eq(fy*p,-fx,evaluate=False),'合并含因变量导数的项,再将其余项移到右侧。',equal(total,collected))) + + at_fy=fy if substitutions is None else s.simplify(fy.subs(substitutions,simultaneous=True)) + if substitutions is not None: + partials=[v.subs(substitutions,simultaneous=True) for v in (fx,fy)] + if any(v.is_real is not True or v.is_finite is not True for v in partials): + raise MathInputError('该点的偏导数无法确认为有限实数,当前 C¹ 隐函数公式不适用;不能据此断言导数不存在。') + if at_fy.is_zero is True: + tr.complete=False + assumptions.remove(s.latex(fy)+r'\ne0') + assumptions.append('此处 Fy=0,不满足导数公式的除法条件;只分析一阶关系。') + at_fx=fx if substitutions is None else s.simplify(fx.subs(substitutions,simultaneous=True)) + status='no_finite_derivative' if at_fx.is_zero is False else 'undetermined' + tr.warnings.append('Fy=0 且 Fx≠0:在 C¹ 条件下不存在有限的因变量导数。' if status=='no_finite_derivative' + else 'Fy=0 且一阶关系退化:当前方法无法判定,需分析分支或使用更高阶信息;不代表不能求导。') + out=response('differentiate',equation,None,'一阶隐函数求导 · 特殊点分析',tr,False,assumptions, + status=status,mode='implicit') + out.update(steps=steps,result_latex=r'\text{'+('不存在有限导数(须满足 C¹)' if status=='no_finite_derivative' else '当前方法无法判定')+'}') + return out + + result=s.simplify(-fx/fy) + # Independent route: SymPy's implicit differentiation, plus total-derivative residual. + try: + reference=s.idiff(f,y,x) + verified=equal(result,reference) and equal(total.subs(p,result),0) + except (ValueError,NotImplementedError,TypeError): + reference=None; verified=False + if result.has(s.Derivative,s.zoo,s.nan,s.oo,-s.oo): + verified=False + if not verified: + tr.complete=False; tr.warnings.append('未能通过 SymPy 独立验证,不展示未经验证的导数。') + out=response('differentiate',equation,None,'一阶隐函数求导 · 验证未完成',tr,False,assumptions,status='unverified',mode='implicit') + out.update(steps=steps,result_latex=r'\text{结果验证未完成}'); return out + quotient=-fx/fy + steps.append(relation('implicit_solve',s.Eq(fy*p,-fx,evaluate=False),s.Eq(p,quotient,evaluate=False), + '在所列条件下除以 F 的因变量偏导数,得到导数公式。',equal(fy*quotient,-fx))) + steps.append(tr.step('implicit_simplify',quotient,result,'化简导数,原方程的定义域与非零条件仍然保留。')) + check=relation('implicit_verification',result,reference, + '独立调用 SymPy idiff,并把导数代回全微分关系,残差应为零。',verified) + check['verification'].update(expression=str(f),variable=str(x),dependent_variable=str(y), + residual=str(s.simplify(total.subs(p,result))),sympy_result=str(reference)) + steps.append(check) + if substitutions is not None: + value=s.simplify(result.subs(substitutions,simultaneous=True)) + if value.is_real is not True or value.is_finite is not True: + raise MathInputError('无法确认该点的导数为有限实数,请检查光滑性和定义域。') + steps.append(tr.step('implicit_point',result,value,'代入点坐标;仍须检查该点邻域内的 C¹ 条件。', + kind='point_substitution',verified=equal(result.subs(substitutions,simultaneous=True),value))) + result=value + if not all_verified(steps): + steps=[]; tr.complete=False; tr.warnings.append('部分推导未通过检查,已隐藏;最终结果已通过独立验证。') + out=response('differentiate',equation,result,'一阶隐函数求导 · 链式法则 + SymPy idiff 独立验证',tr,True, + assumptions,status='conditional',mode='implicit') + out.update(steps=steps,result_latex=r'\frac{d'+str(y)+r'}{d'+str(x)+r'}='+s.latex(result)) + return out diff --git a/backend/parser.py b/backend/parser.py index 752cf35..c8cc2c9 100644 --- a/backend/parser.py +++ b/backend/parser.py @@ -12,10 +12,19 @@ class MathInputError(ValueError): CONSTANTS = {'pi': s.pi, 'e': s.E, 'E': s.E, 'oo': s.oo} def variable(name): - if not re.fullmatch('[a-df-z]', name): + if not isinstance(name,str) or not re.fullmatch('[a-df-z]', name): raise MathInputError('变量请使用单个英文字母(e 为自然常数)。') return s.Symbol(name, real=True) +def parse_equation(text): + """Only this entry point accepts '='; both sides retain the closed grammar.""" + if not isinstance(text,str) or not text.strip() or len(text)>600: + raise MathInputError('方程不能为空且不能超过 600 字符。') + if text.count('=')>1: + raise MathInputError('只支持一个等号的方程,不支持连等式。') + sides=text.split('=') if '=' in text else [text,'0'] + return parse(sides[0],evaluate_functions=False),parse(sides[1],evaluate_functions=False) + class LatexReader: def __init__(self, text): self.text, self.i = text, 0 @@ -61,7 +70,7 @@ def read(self, end=None): TOKEN=re.compile(r'\s*(?:(\d+(?:\.\d+)?|\.\d+)|([A-Za-z]+)|(\*\*|[+\-*/^(),|]))') -def parse(text): +def parse(text,*,evaluate_functions=True): if not isinstance(text,str) or not text.strip() or len(text)>600: raise MathInputError('表达式不能为空且不能超过 600 字符。') text=text.strip().replace('−','-').replace('×','*').replace('÷','/').replace('π','pi').replace('∞','oo') @@ -101,7 +110,7 @@ def expr(min_bp=0): if tokens[i]!=')': raise MathInputError('函数仅支持一个参数且括号必须完整。') i+=1 else: arg=expr(25) - lhs=FUNCTIONS[tok](arg) + lhs=FUNCTIONS[tok](arg,evaluate=evaluate_functions) if exponent is not None: lhs=s.Pow(lhs,exponent,evaluate=False) elif tok in CONSTANTS: lhs=CONSTANTS[tok] elif re.fullmatch('[a-df-z]',tok): lhs=variable(tok) diff --git a/tests/browser/implicit.spec.ts b/tests/browser/implicit.spec.ts new file mode 100644 index 0000000..a170214 --- /dev/null +++ b/tests/browser/implicit.spec.ts @@ -0,0 +1,49 @@ +import {test,expect} from '@playwright/test'; + +test('implicit chain rule, point and history restore',async({page})=>{ + await page.goto('/'); + await page.getByRole('button',{name:'求导',exact:true}).click(); + await page.getByLabel('求导模式').selectOption('implicit'); + await expect(page.getByLabel('导数阶数')).toHaveCount(0); + await expect(page.getByLabel('混合偏导')).toHaveCount(0); + await page.getByRole('button',{name:'文本表达式',exact:true}).click(); + await page.getByLabel('文本表达式',{exact:true}).fill('x^2+y^2=1'); + await page.getByLabel('自变量点坐标').fill('3/5'); + await page.getByLabel('因变量点坐标').fill('4/5'); + await page.getByRole('button',{name:'开始计算',exact:true}).click(); + await expect(page.locator('.verified-badge')).toContainText('验证通过'); + await expect(page.locator('.step summary').filter({hasText:'隐函数链式法则'})).toBeVisible(); + await expect(page.locator('.step summary').filter({hasText:'整理含导数的项'})).toBeVisible(); + await expect(page.locator('.conditions')).toContainText('C¹'); + await page.getByRole('button',{name:'计算历史',exact:true}).click(); + await page.locator('.history-item').first().click(); + await expect(page.getByLabel('求导模式')).toHaveValue('implicit'); + await expect(page.getByLabel('自变量点坐标')).toHaveValue('3/5'); + await expect(page.getByLabel('因变量点坐标')).toHaveValue('4/5'); + await page.getByLabel('自变量点坐标').fill('0'); + await page.getByLabel('因变量点坐标').fill('0'); + await page.getByLabel('文本表达式',{exact:true}).fill('y^3=x^3'); + await page.getByRole('button',{name:'开始计算',exact:true}).click(); + await expect(page.locator('.warnings')).toContainText('当前方法无法判定'); + await expect(page.locator('.answer-box')).toContainText('当前方法无法判定'); + await page.getByLabel('求导模式').selectOption('explicit'); + await expect(page.getByLabel('导数阶数')).toBeVisible(); + await page.getByLabel('文本表达式',{exact:true}).fill('sin(x^2)'); + await page.getByRole('button',{name:'开始计算',exact:true}).click(); + await expect(page.locator('.answer-box')).toContainText('cos'); + await expect(page.locator('.verified-badge')).toContainText('验证通过'); +}); + +test('formula input accepts an implicit equation',async({page})=>{ + await page.goto('/'); + await page.getByRole('button',{name:'求导',exact:true}).click(); + await page.getByLabel('求导模式').selectOption('implicit'); + const field=page.locator('math-field'); + await field.evaluate(el=>{ + (el as HTMLElement & {value:string}).value='x^2+y^2=1'; + el.dispatchEvent(new Event('input',{bubbles:true})); + }); + await page.getByRole('button',{name:'开始计算',exact:true}).click(); + await expect(page.locator('.verified-badge')).toContainText('验证通过'); + await expect(page.locator('.step summary').filter({hasText:'隐函数独立验证'})).toBeVisible(); +}); diff --git a/tests/test_api.py b/tests/test_api.py index bd0c7a3..2428d79 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -98,3 +98,19 @@ def test_concurrent_note_write_conflict(client): assert client.get('/api/questions/'+q).json()['attempt']['notes']=='窗口 A 的笔记' update=client.put('/api/questions/'+q+'/attempt',json={'notes':'窗口 A 的新笔记','expected_updated_at':r.json()['updated_at']}) assert update.status_code==200 + +def test_implicit_api_history_and_validation(client): + qid=create(client) + body={'expression':'x^2+y^2=1','mode':'implicit','dependent_variable':'y', + 'point':{'x':'3/5','y':'4/5'},'question_id':qid} + r=client.post('/api/math/differentiate',json=body) + assert r.status_code==200,r.text + result=r.json();assert result['verified'] and result['exact_result']=='-3/4' + h=client.get('/api/math/history',params={'question_id':qid}).json()[0] + assert h['request']['mode']=='implicit' and h['request']['point']==body['point'] + assert h['result']['steps']==result['steps'] + assert client.post(f"/api/questions/{qid}/calculations/{result['history_id']}",json={}).status_code==200 + for extra in [{'order':2},{'variables':['x']},{'dependent_variable':'x'},{'mode':'unknown'},{'point':{'x':'0'}}]: + assert client.post('/api/math/differentiate',json={**body,**extra}).status_code==422 + assert client.post('/api/math/differentiate',json={'expression':'x=y'}).status_code==422 + assert len(client.get('/api/math/history',params={'question_id':qid}).json())==1 diff --git a/tests/test_implicit.py b/tests/test_implicit.py new file mode 100644 index 0000000..bba6612 --- /dev/null +++ b/tests/test_implicit.py @@ -0,0 +1,99 @@ +"""Independent mathematical checks; no production parsing of SymPy strings.""" +import pytest +import sympy as s +from backend.engine import compute +from backend.parser import parse, parse_equation, MathInputError + +x,y=s.symbols('x y',real=True) + +def run(expression,**kwargs): + return compute('differentiate',dict(expression=expression,mode='implicit',**kwargs)) + +def flatten(steps): + for step in steps: + yield step + yield from flatten(step['children']) + +@pytest.mark.parametrize('expression,expected',[ + ('x^2+y^2=1',-x/y),('x^3+y^3=3*x*y',(y-x*x)/(y*y-x)), + ('sin(x*y)=x+y',(1-y*s.cos(x*y))/(x*s.cos(x*y)-1)), + ('exp(y)+x*y=2',-y/(x+s.exp(y))), + ('log(x+y)=x-y',(x+y-1)/(x+y+1)), + ('sin(x+y^2)+y=0',-s.cos(x+y*y)/(2*y*s.cos(x+y*y)+1)), + ('x/y+y=2',-y/(y*y-x)),('y^2=x',1/(2*y)), + (r'\frac{x^2}{4}+y^2=1',-x/(4*y)),('x^2+y^2-1',-x/y)]) +def test_implicit_results_and_trace(expression,expected): + out=run(expression) + assert out['verified'] and out['steps_complete'] and out['status']=='conditional' + got=s.sympify(out['exact_result'],locals={'x':x,'y':y}) + assert s.simplify(got-expected)==0 + lhs,rhs=parse_equation(expression); f=lhs-rhs + assert s.simplify(s.idiff(f,y,x)-got)==0 + assert s.simplify(s.diff(f,x)+s.diff(f,y)*got)==0 + rules={st['rule'] for st in flatten(out['steps'])} + assert {'implicit_rearrange','implicit_chain','implicit_collect','implicit_solve','implicit_simplify','implicit_verification','dependent_variable'}<=rules + assert all(st['verified'] for st in flatten(out['steps'])) + # Check the actual chain-rule equations independently, including y(x). + local={'x':x,'y':s.Function('y')} + for st in flatten(out['steps']): + v=st['verification'] + if v['kind']=='derivative': + assert s.simplify(s.diff(s.sympify(v['expression'],locals=local),x)-s.sympify(v['after'],locals=local))==0 + if st['rule']=='implicit_verification':assert s.sympify(v['residual'])==0 + assert any('C¹' in a for a in out['assumptions']) + assert any(r'\ne0' in a for a in out['assumptions']) + +def test_other_variable_direction(): + out=run('t^2+z^2=1',variable='t',dependent_variable='z') + assert out['verified'] and s.simplify(parse(out['exact_result'])+parse('t/z'))==0 + +def test_regular_point(): + out=run('x^2+y^2=1',point={'x':'3/5','y':'4/5'}) + assert out['exact_result']=='-3/4' and out['verified'] + assert out['steps'][-1]['rule']=='implicit_point' + +@pytest.mark.parametrize('expression,point,status',[ + ('x^2+y^2=1',{'x':'1','y':'0'},'no_finite_derivative'), + ('y^2=x',{'x':'0','y':'0'},'no_finite_derivative'), + ('y^3=x^3',{'x':'0','y':'0'},'undetermined'), + ('y^2=x^2',{'x':'0','y':'0'},'undetermined'), + ('y^2=0',None,'conditional'),('x=0',None,'no_finite_derivative'), + ('0=0',None,'undetermined')]) +def test_special_points_do_not_divide_by_zero(expression,point,status): + out=run(expression,**({'point':point} if point is not None else {})) + assert out['status']==status + if status!='conditional': + assert out['exact_result'] is None and not out['verified'] and not out['steps_complete'] + +def test_unexpanded_trace_is_not_failure_to_differentiate(): + out=run('x^x+y=1') + assert out['verified'] and not out['steps_complete'] and out['exact_result'] is not None + assert s.simplify(parse(out['exact_result'])+x**x*(s.log(x)+1))==0 + assert any('没有完整展开' in w for w in out['warnings']) + +@pytest.mark.parametrize('expression,kwargs',[ + ('x^2+y^2=1',{'variable':'y'}),('x+y+z=1',{}), + ('x+y=1',{'order':2}),('x+y=1',{'order':True}), + ('x+y=1',{'variables':['x']}),('x+y=1',{'variables':[]}), + ('x=y=1',{}),('x==y',{}),('=y',{}),('x=',{}),('x<=y',{}), + ('x=y;1',{}),("x=__import__('os')",{}),('x=oo',{}), + ('x+y=1',{'point':{'x':'0'}}),('x+y=1',{'point':{'x':'0','y':'0'}}), + ('x+y=1',{'point':{'x':'a','y':'1'}}), + ('x+y=1',{'point':{'x':'oo','y':'1'}}), + ('x/x+y=1',{'point':{'x':'0','y':'0'}}), + ('log(x/x)+y=x',{'point':{'x':'0','y':'0'}}), + ('sqrt(x/x)+y=1',{'point':{'x':'0','y':'0'}}), + ('sqrt(-1)+y=0',{}),('log(0)+y=0',{}), + ('log(x)+y=0',{'point':{'x':'-1','y':'0'}}), + ('sqrt(y)=x',{'point':{'x':'0','y':'0'}}), +]) +def test_invalid_inputs_and_domain(expression,kwargs): + with pytest.raises(MathInputError):run(expression,**kwargs) + +def test_expression_parser_stays_closed(): + with pytest.raises(MathInputError):parse('x=y') + +def test_failed_independent_verification_is_not_a_false_answer(monkeypatch): + monkeypatch.setattr(s,'idiff',lambda *args: s.Integer(123)) + out=run('x^2+y^2=1') + assert out['status']=='unverified' and out['exact_result'] is None and not out['verified'] diff --git a/web/Calculator.tsx b/web/Calculator.tsx index 4e8acd4..f055ed7 100644 --- a/web/Calculator.tsx +++ b/web/Calculator.tsx @@ -19,6 +19,7 @@ export function Calculator(props:CalculatorProps){ const [tab,setTab]=useState('integrate');const [expression,setExpression]=useState('x\\cdot e^x');const field=useRef(null); const [inputMode,setInputMode]=useState<'math'|'plain'>('math');const [plain,setPlain]=useState('x*exp(x)'); const [variable,setVariable]=useState('x');const [definite,setDefinite]=useState(false);const [lower,setLower]=useState('0');const [upper,setUpper]=useState('1');const [order,setOrder]=useState(1);const [partial,setPartial]=useState(''); + const [derivativeMode,setDerivativeMode]=useState('explicit');const [dependent,setDependent]=useState('y');const [pointX,setPointX]=useState('');const [pointY,setPointY]=useState(''); const [matrix,setMatrix]=useState(initial);const [matrixB,setMatrixB]=useState([['1'],['0']]);const [action,setAction]=useState('rref');const [method,setMethod]=useState('elimination'); const [mode,setMode]=useState('full');const [result,setResult]=useState(null);const [busy,setBusy]=useState(false);const [error,setError]=useState('');const [history,setHistory]=useState([]);const [showHistory,setShowHistory]=useState(false);const [historyOffset,setHistoryOffset]=useState(0);const [hasMore,setHasMore]=useState(false); const identity=useRef(questionId);identity.current=questionId;const active=useRef(true);useEffect(()=>{active.current=true;return()=>{active.current=false;};},[]); @@ -29,7 +30,7 @@ export function Calculator(props:CalculatorProps){ async function calculate(){ if(busy)return;setBusy(true);setError('');const boundId=questionId; const matrixTab=tab==='matrix'||tab==='determinant';const op=matrixTab?'matrix':tab; - const body=matrixTab?{matrix,matrix_b:tab==='matrix'&&['add','subtract','multiply','solve'].includes(action)?matrixB:undefined,action:tab==='determinant'?'determinant':action,method,question_id:questionId}:{expression:inputMode==='math'?expression:plain,variable,question_id:questionId,...(tab==='integrate'&&definite?{lower,upper}:{}),...(tab==='differentiate'?{order,...(partial.trim()?{variables:partial.split(',').map(v=>v.trim())}:{})}:{})}; + const body=matrixTab?{matrix,matrix_b:tab==='matrix'&&['add','subtract','multiply','solve'].includes(action)?matrixB:undefined,action:tab==='determinant'?'determinant':action,method,question_id:questionId}:{expression:inputMode==='math'?expression:plain,variable,question_id:questionId,...(tab==='integrate'&&definite?{lower,upper}:{}),...(tab==='differentiate'?(derivativeMode==='implicit'?{mode:'implicit',dependent_variable:dependent,...(pointX.trim()||pointY.trim()?{point:{[variable]:pointX,[dependent]:pointY}}:{})}:{order,...(partial.trim()?{variables:partial.split(',').map(v=>v.trim())}:{})}):{})}; try{const data=await api('/api/math/'+op,json(body));if(active.current&&identity.current===boundId){setResult(data);await loadHistory();}} catch(e){if(active.current&&identity.current===boundId)setError((e as Error).message);} finally{if(active.current)setBusy(false);} @@ -37,15 +38,15 @@ export function Calculator(props:CalculatorProps){ function restore(h:History){ setResult(h.result);setShowHistory(false);const r=h.request;setTab(h.operation==='matrix'&&r.action==='determinant'?'determinant':h.operation); if(h.operation==='matrix'){setMatrix(r.matrix as string[][]);if(r.matrix_b)setMatrixB(r.matrix_b as string[][]);setAction(r.action as string);setMethod((r.method as string)||'elimination');} - else {setPlain(r.expression as string);setExpression(r.expression as string);setInputMode('plain');setVariable((r.variable as string)||'x');setDefinite(r.lower!==undefined);if(r.lower)setLower(r.lower as string);if(r.upper)setUpper(r.upper as string);setOrder((r.order as number)||1);setPartial((r.variables as string[]||[]).join(','));} + else {setPlain(r.expression as string);setExpression(r.expression as string);setInputMode('plain');setVariable((r.variable as string)||'x');setDefinite(r.lower!==undefined);if(r.lower)setLower(r.lower as string);if(r.upper)setUpper(r.upper as string);setOrder((r.order as number)||1);setPartial((r.variables as string[]||[]).join(','));setDerivativeMode((r.mode as string)||'explicit');setDependent((r.dependent_variable as string)||'y');const point=r.point as Record|undefined;setPointX(point?.[(r.variable as string)||'x']||'');setPointY(point?.[(r.dependent_variable as string)||'y']||'');} } return
{compact?'数学计算器':'计算工作台'}
{props.onFloat&&}{props.onFullscreen&&}{props.onCollapse&&}
{[['integrate','∫','积分'],['differentiate','∂','求导'],['determinant','|A|','行列式'],['matrix','▦','矩阵']].map(([id,icon,label])=>)}
{showHistory?
{questionId?'本题计算记录':'独立计算记录'}{history.length} 条已加载
{history.length===0?

还没有计算记录

:history.map(h=>)}{hasMore&&}
:
{tab==='integrate'?'积分设置':tab==='differentiate'?'求导设置':tab==='determinant'?'行列式设置':'矩阵设置'}
- {(tab==='integrate'||tab==='differentiate')?<>
{inputMode==='math'?: