查看: 242|回复: 3

鸿蒙文搜图九格DSL:ArkTS与NAPI解析求值及缓存

[复制链接]
发表于 1 小时前 | 显示全部楼层 |阅读模式
在鸿蒙文搜图应用里,3×3画布每次点格都会触发一次检索。原文没有把它只当成交互,而是按一门微型DSL来拆:九个格子是语法位置,十个词是词表,`0:蓝|6:明亮`是源程序,Native逐格打分是求值器,三套预设是宏。对开发者真正有价值的地方,是ArkTS侧如何emit、parse、缓存,NAPI边界如何只传string+int,Native侧如何解析并合成trace,以及错误、版本和分享协议怎么设计。

一、先把画布状态收敛成一行源程序

原文的语法很小:用`|`分隔项,用`:`连接位置与词,位置只能是0到8,词来自固定词表。`avg`与`min`不放进源程序,而是执行参数:它们改变约束如何合成为总分,不改变约束本身。这样同一份谱子可以换两种解释执行,程序文本不用重写。

ArkTS侧维护board[9],emit负责丢掉空格并拼成字符串:
  1. export function emit(board: string[]): string {
  2.   const parts: string[] = [];
  3.   for (let i = 0; i < 9; i++) if (board[i].length > 0) parts.push(`${i}:${board[i]}`);
  4.   return parts.join('|');
  5. }
复制代码

解析器生成最小AST,并显式返回错误码。原文用`EMPTY_PROGRAM`、`BAD_CELL`、`UNKNOWN_WORD`、`DUPLICATE_CELL`区分失败原因。其中重复格是一个语义空白:画布上不可能出现同格两个词,但文本协议允许手抄出现。原文倾向报错而不是后者覆盖前者,因为静默覆盖会让分享出去的程序在不同版本、不同解析顺序下得到不同结果。
  1. export type Cell = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
  2. export type Mode = 'avg' | 'min';
  3. export interface Placement { cell: Cell; word: string; }
  4. export interface Program { placements: Placement[]; mode: Mode; }
  5. export type ParseError = 'EMPTY_PROGRAM' | 'BAD_CELL' | 'UNKNOWN_WORD' | 'DUPLICATE_CELL';
  6. export function parse(source: string, mode: Mode = 'avg') {
  7.   const errors: ParseError[] = [];
  8.   const placements: Placement[] = [];
  9.   const seen = new Set<number>();
  10.   if (source.trim().length === 0) {
  11.     return { program: { placements: [], mode }, errors: ['EMPTY_PROGRAM'] };
  12.   }
  13.   for (const raw of source.split('|')) {
  14.     const seg = raw.trim();
  15.     if (seg.length === 0) continue;
  16.     const colon = seg.indexOf(':');
  17.     const cell = Number(colon >= 0 ? seg.slice(0, colon) : '');
  18.     const word = colon >= 0 ? seg.slice(colon + 1) : '';
  19.     if (!Number.isInteger(cell) || cell < 0 || cell > 8) { errors.push('BAD_CELL'); continue; }
  20.     if (!VOCAB.includes(word)) { errors.push('UNKNOWN_WORD'); continue; }
  21.     if (seen.has(cell)) { errors.push('DUPLICATE_CELL'); continue; }
  22.     seen.add(cell);
  23.     placements.push({ cell: cell as Cell, word });
  24.   }
  25.   return { program: { placements, mode }, errors };
  26. }
复制代码

二、求值结果不能只有总分,还要有逐格trace

原文把一张图切成九格,每格有一组可观测量,一个词对应一支探针,返回0到1的满足度。程序求值时,未出现在程序里的格子不参与计算,返回-1作为哨兵,UI映射成灰格。这里的关键是区分“未约束”和“约束得0分”:前者是没测,后者是测了且完全不符。混在一起会让热力图整片飘红。
  1. export interface Trace { id: number; total: number; cells: number[]; }
  2. export function evaluate(program: Program, probes: (cell: number, word: string) => number, id: number): Trace {
  3.   const cells: number[] = new Array(9).fill(-1);
  4.   const scores: number[] = [];
  5.   for (const p of program.placements) {
  6.     const s = probes(p.cell, p.word);
  7.     cells[p.cell] = s;
  8.     scores.push(s);
  9.   }
  10.   if (scores.length === 0) return { id, total: 0, cells };
  11.   const total = program.mode === 'min' ? Math.min(...scores) : scores.reduce((a, b) => a + b, 0) / scores.length;
  12.   return { id, total, cells };
  13. }
复制代码

原文第六张截图那组数字能说明两种模式的差异:九格里七格接近1.00,两格是0.12与0.17。`avg`把短板平均掉,得到约0.72;`min`直接取0.12。同一份程序、同一份数据,解释策略决定它像不像一张“好图”,所以mode必须显式写进程序,不能藏在实现里。

三、编译链分四层,跨语言只传一行字符串

原文的编译链是:画布board[9]在UI层可变,emit丢掉空格拼成源程序,parse校验位置和词表生成AST,normalize生成缓存键,再通过NAPI把string+int传到Native;Native侧ParseBoard拆词、查UTF-8字节词表,Evaluate每格取特征、算探针、合成总分,最后返回JSON给UI画热力九格。

Native侧手写`find(':')`而不是引JSON库,原因是这门语言没有嵌套结构、没有可选字段、没有转义需求:词表是固定UTF-8字节串,位置是一位数字。为此引入JSON解析器反而增加跨语言不一致风险。原文还强调词表用UTF-8字节转义是词法层编码约定,保证ArkTS传来的“绿”与Native里的字节表示无论源码文件是什么编码都能相等。
  1. struct Placement { int cell = 0; Kind kind = K_UNKNOWN; };
  2. static std::vector<Placement> ParseBoard(const std::string& src) {
  3.   std::vector<Placement> out;
  4.   size_t start = 0;
  5.   while (start <= src.size()) {
  6.     size_t end = src.find('|', start);
  7.     if (end == std::string::npos) end = src.size();
  8.     if (end > start) {
  9.       std::string seg = src.substr(start, end - start);
  10.       size_t colon = seg.find(':');
  11.       if (colon != std::string::npos && colon == 1 && seg[0] >= '0' && seg[0] <= '8') {
  12.         Kind k = Lookup(seg.substr(colon + 1));
  13.         if (k != K_UNKNOWN) out.push_back(Placement{seg[0] - '0', k});
  14.       }
  15.     }
  16.     if (end == src.size()) break;
  17.     start = end + 1;
  18.   }
  19.   return out;
  20. }
复制代码

四、规范化与缓存:交换律的直接工程收益

原文指出交换律有一个直接收益:编译产物比较可以与书写顺序无关。`0:复杂|4:蓝`和`4:蓝|0:复杂`规范化后得到同一个缓存键。ArkTS侧用normalize排序并拼上mode,QueryCache最多保留64项,超出后删除最早一项。
  1. export function normalize(program: Program): string {
  2.   const items = program.placements.map(p => `${p.cell}:${p.word}`).sort();
  3.   return `${program.mode}#${items.join('|')}`;
  4. }
  5. export class QueryCache {
  6.   private store = new Map<string, Trace[]>();
  7.   get(program: Program): Trace[] | null { return this.store.get(normalize(program)) ?? null; }
  8.   put(program: Program, traces: Trace[]): void {
  9.     if (this.store.size > 64) {
  10.       const first = this.store.keys().next().value;
  11.       if (first !== undefined) this.store.delete(first);
  12.     }
  13.     this.store.set(normalize(program), traces);
  14.   }
  15. }
复制代码

五、预设是宏,不是模板

三套预设看起来只是帮用户填好九格,但原文按宏实现,得到三个好处:可展开、可参数化、可版本化。宏展开后就是普通源程序,可继续逐格编辑;withSubstitution能把“雪山天光”里的“蓝”整体换成“绿”,生成新谱子。version字段不能省:词表新增词或探针阈值调整后,同名旧宏可能产生不同结果。序列化时始终保留展开后的src,宏名只作可读附注。宏是缩写,程序才是事实。
  1. export interface Macro { name: string; version: number; cells: string[]; }
  2. export interface Serialized { v: number; macro?: string; macroVersion?: number; mode: Mode; src: string; }
  3. export function serialize(p: Program, macro?: Macro): string {
  4.   const doc: Serialized = { v: 1, macro: macro?.name, macroVersion: macro?.version, mode: p.mode, src: emitFromProgram(p) };
  5.   return JSON.stringify(doc);
  6. }
复制代码

六、错误必须可见,覆盖率要和分数一起读

原文强调文本协议才是分享与存档的载体,它必须自己站得住。多词查询里若未识别实词被静默忽略,用户以为写了三个条件,引擎只执行一个,还会给出看起来合理的分数。未知词必须是一次可见失败,宁可不出结果。诊断层把BAD_CELL、UNKNOWN_WORD、DUPLICATE_CELL作为error,把高度相关词同时出现作为REDUNDANT_TERM警告:不违法,但会稀释程序信息量,提示比替用户做决定更合适。

另一个容易误导的是覆盖率。只摆一格时覆盖率1/9,任何图只要那一格碰巧符合就能拿满分。原文展示的“碧海晴空100分”正是短程序语义弱的表现。一个低成本修法是给总分乘先验因子,alpha取0.5时单格程序满分被压到约0.67,九格铺满不受影响。它不是严格统计校正,但足以让“摆一格得100分”不再误导。
  1. export interface Diagnostic { level: 'error' | 'warning'; code: ParseError | 'REDUNDANT_TERM'; at: string; }
  2. export function coverage(p: Program): number { return p.placements.length / 9; }
  3. export function optimize(p: Program): Program {
  4.   const seen = new Set<number>();
  5.   const placements = p.placements.slice().sort((a, b) => a.cell - b.cell).filter(x => {
  6.     if (seen.has(x.cell)) return false;
  7.     seen.add(x.cell);
  8.     return true;
  9.   });
  10.   return { placements, mode: p.mode };
  11. }
复制代码

七、分享短码要带版本前缀

界面是临时的,程序不是。原文用`MOSAIC1.`作为协议标识,短码由mode标记、版本和源程序base64url组成。以后语法扩展就升到MOSAIC2,接收方看到不认识的前缀直接拒绝,而不是按旧规则误解析出一堆未识别词。短码里始终保留展开后的src,即使接收方没有同名预设,程序依然完整;即使将来探针阈值调整,至少能明确知道这份结果属于v1语义。
  1. export function shareCode(doc: Serialized): string {
  2.   const head = `${doc.mode === 'min' ? 'M' : 'A'}${doc.v}`;
  3.   const body = Buffer.from(doc.src, 'utf8').toString('base64url');
  4.   return `MOSAIC1.${head}.${body}`;
  5. }
复制代码

八、几个实现取舍

mode为什么不放进语法?因为它改变的是约束如何合成为总分,不是约束是什么。放在参数里,同一份谱子可以换两种解释执行,缓存键只需在规范化时带上一位标记。重复格为什么报错?静默覆盖会让分享出去的程序在不同版本、不同解析顺序下得到不同结果,语言层面拒绝歧义比在实现里挑约定更安全。avg和min怎么选?想找“大致这个感觉”用avg;九个位置都有明确要求、一个都不能错,用min。

这套九格DSL的代码量不大,但它把鸿蒙文搜图里的UI交互、ArkTS状态、NAPI跨语言边界、Native求值和分享协议串成了一条可验证链路。原文最值得复用的不是“九格”本身,而是几个工程习惯:程序文本始终可序列化,跨语言只传最小字符串,错误码和trace一起返回,缓存键先规范化,分享格式先带版本。
回复

使用道具 举报

发表于 1 小时前 | 显示全部楼层

Re: 鸿蒙文搜图九格DSL:ArkTS与NAPI解析求值及缓存

这个拆法挺清楚,把画布交互收敛成一行源程序后,emit、parse、求值和缓存的边界都更容易讲明白。几个取舍我觉得合理:重复格报错而不是静默覆盖,能避免分享协议在不同解析顺序下出现歧义;avg 和 min 作为解释策略,而不是写进源程序文本,也让同一份谱子能换解释执行;trace 里用 -1 区分“未约束”和“约束得0分”很关键,否则热力图会把没测过的格子也标成短板。 有个实现细节想提醒一下:emit 示例里的循环条件看起来像笔误,遍历 i 时应该判断 board 是否为空,而不是 board.length,拼接的也应是 board。另外 Program 里带了 mode,但正文说 mode 不放进源程序,这个边界最好在分享和缓存时写清楚,避免把执行参数误当成源程序的一部分。 后面如果继续写 NAPI 和缓存,建议缓存键至少把源程序、mode、词表版本和探针版本都纳入,不然同一行程序换了解释策略或词表后很容易命中旧结果。期待看到四层编译链和错误、版本、分享协议那部分。
回复 支持 反对

使用道具 举报

发表于 1 小时前 | 显示全部楼层

Re: 鸿蒙文搜图九格DSL:ArkTS与NAPI解析求值及缓存

这个拆法很有意思,把九宫格从 UI 状态提升成可分享、可回放的小型源程序,emit、parse、求值、NAPI 边界分层比较清楚。尤其认同两点:mode 作为执行参数而不是写进约束本身,同一份程序能换 avg/min 两种解释;trace 用 -1 区分“未约束”和“约束得 0 分”,UI 上灰格和红格就不会混。重复格选择报错而不是静默覆盖,也很适合分享协议和跨版本稳定性的要求。 看代码有两个小边界可以再抠一下。emit 里用 board.length 大于 0 来判断是否拼入,更像是在判断数组非空,不能代表第 i 格真的有词;如果某一格是空串,仍可能拼出“i:”这样的项,建议改成判断 board 去空格后是否非空。parse 里没有冒号时,Number 空串会得到 0,于是可能先被当成合法格,最后报 UNKNOWN_WORD 而不是 BAD_CELL;最好先检查冒号是否存在,再转数字。另外空段跳过和尾随竖线的语义,建议在协议里明确写死,缓存和分享时会更稳。 整体上这套 DSL 加显式错误码、trace 和跨语言只传字符串加整数的方向很扎实,继续展开 NAPI 边界和 trace 合成应该会很有参考价值。
回复 支持 反对

使用道具 举报

发表于 1 小时前 | 显示全部楼层

Re: 鸿蒙文搜图九格DSL:ArkTS与NAPI解析求值及缓存

楼主这个拆法挺有意思,把九格画布收敛成一行源程序,再用 emit、parse、Native 求值和 trace 串起来,整体边界很清楚。尤其把未约束返回 -1、约束得 0 分单独区分,这点很关键,否则热力图确实容易把“没测”和“测了不符”混成一片红。DUPLICATE_CELL 选择报错而不是静默覆盖也合理,分享出去的文本协议最怕隐式覆盖。 不过首帖里 emit 那段好像有个小笔误:循环里判断的是 board.length,拼进去的也是 board,按语义应该是 board 吧。另外 parse 里如果输入是类似连续竖线这种 trim 后非空、但 split 后全是空段的情况,可能返回空 placements 且 errors 为空,和 EMPTY_PROGRAM 的语义会有点打架,建议对“解析后没有任何有效 placement”也归到 EMPTY_PROGRAM,或者明确这是合法空程序。 还有 mode 的问题:Program 接口里有 mode,但源程序文本里没有写,parse 是作为参数传进去的。首帖说 mode 必须显式写进程序,这个方向我赞成,那分享协议里最好把 mode 和 source 一起带上,否则同一行源程序换 avg 或 min 解释,结果会变但文本看起来一样。缓存键也可以顺着这个思路设计:parse 缓存依赖 source 和词表版本,evaluate 缓存依
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-11-10 03:50 , Processed in 0.021789 second(s), 29 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部