From 438b8653841710b206aee85d6ca7d82c02f53fa7 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Sun, 27 Sep 2026 03:28:31 +0900 Subject: [PATCH] =?UTF-8?q?TEST:=20=EC=98=A4=ED=94=88=EC=86=8C=EC=8A=A4=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EC=86=8C=EC=9D=98=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=EC=BB=A4=EB=B0=8B=EC=9C=BC=EB=A1=9C=20=EB=A7=8C=EB=93=A0=20?= =?UTF-8?q?=EA=B3=B5=EA=B0=9C=20=EB=B2=A4=EC=B9=98=EB=A7=88=ED=81=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README의 맥락 적중률은 비공개 프로젝트 하나로 잰 것이라 재현할 수 없었음. flask·hono·commons-lang의 실제 커밋 60개에서 질문(커밋 메시지)과 정답(고친 코드 파일)을 뽑고 부모 커밋 시점에서 재도록 함. 비교에 키워드 검색(맞은 곳 앞뒤만)과 무작위 기준, 처음 3개 파일 지표를 더해 많이 가져올수록 유리한 효과를 드러냄. 한국어로 옮긴 질문도 같은 정답으로 잼. README 수치를 이 결과로 바꿈. Closes #7 --- README.ko.md | 13 +- README.md | 13 +- eval/README.md | 15 ++ eval/bench/commons-lang.json | 190 ++++++++++++++++++++++ eval/bench/flask.json | 193 +++++++++++++++++++++++ eval/bench/hono.json | 190 ++++++++++++++++++++++ eval/bench/make.mjs | 91 +++++++++++ eval/bench/run.mjs | 71 +++++++++ "eval/bench/\352\262\260\352\263\274.md" | 22 +++ eval/retrieval.mjs | 150 +++++++++++++----- "eval/\352\262\260\352\263\274.md" | 2 + 11 files changed, 902 insertions(+), 48 deletions(-) create mode 100644 eval/bench/commons-lang.json create mode 100644 eval/bench/flask.json create mode 100644 eval/bench/hono.json create mode 100644 eval/bench/make.mjs create mode 100644 eval/bench/run.mjs create mode 100644 "eval/bench/\352\262\260\352\263\274.md" diff --git a/README.ko.md b/README.ko.md index 541bf35..06f1506 100644 --- a/README.ko.md +++ b/README.ko.md @@ -25,14 +25,15 @@ 엔진이 이해하는 언어(심볼, 호출 관계, 지도, 영향 반경): Rust, Python, TypeScript/JavaScript, Java, Go, C#. 그 밖의 파일도 코드 색은 나오지만 심볼은 없습니다. -비공개 프로젝트(코드 파일 1,772개, TypeScript/JavaScript)에서 정답 파일을 직접 정한 질문 20개로, 같은 8,000 토큰 예산에서 비교했습니다: +**공개 벤치마크.** [Flask](https://github.com/pallets/flask)(Python), [Hono](https://github.com/honojs/hono)(TypeScript), [Apache Commons Lang](https://github.com/apache/commons-lang)(Java)의 실제 커밋 60개로 쟀습니다. 질문은 커밋 메시지, 정답 파일은 그 커밋이 고친 코드 파일이고, 각 질문은 그 커밋의 부모 시점에서 잽니다. 정답을 사람이 고르지 않습니다. 같은 8,000 토큰 예산, 모델 호출 없음: -| | 키워드 검색 (grep 후 파일 통째로 읽기) | Lantern | -|---|---|---| -| 정답 파일 적중률 (평균) | 24% | **67%** | -| 정답 파일을 하나라도 가져온 질문 | 35% | **90%** | +| | 키워드 검색 (grep 후 파일 통째로) | 키워드 검색 (grep, 맞은 곳 앞뒤만) | Lantern | +|---|---|---|---| +| 정답 파일 적중률 (평균) | 13% | 41% | **88%** | +| 처음 3개 파일 안에 정답 | 15% | 30% | **65%** | +| 같은 질문을 한국어로: 적중률 / 처음 3개 | 13% / 13% | 38% / 27% | **76% / 43%** | -방법과 한계: [eval/결과.md](eval/결과.md). 평가 스크립트는 `eval/`에 있으니 자기 프로젝트와 질문으로 돌려 볼 수 있습니다 ([형식](eval/README.md)). 오픈소스 코드베이스로 만든 공개 벤치마크를 준비하고 있습니다. +Lantern이 가져온 것과 같은 수의 파일을 무작위로 고르면 정답을 가져올 확률은 10%입니다. 한국어 질문은 같은 커밋 메시지를 사람이 옮긴 것입니다. `node eval/bench/run.mjs`로 재현할 수 있습니다 ([결과](eval/bench/결과.md), [방법](eval/README.md)). 자기 프로젝트와 질문으로는 `eval/retrieval.mjs`를 쓰면 됩니다. 맥락 엔진은 CLI와 MCP 서버로 따로 쓸 수도 있습니다: diff --git a/README.md b/README.md index 7e9acf7..f63abf1 100644 --- a/README.md +++ b/README.md @@ -25,14 +25,15 @@ The core is a local context engine (`crates/lantern-context`, Rust + tree-sitter Languages the engine understands (symbols, calls, map, impact radius): Rust, Python, TypeScript/JavaScript, Java, Go, C#. Other files still open with syntax colors, but have no symbols. -On a private 1,772-file TypeScript/JavaScript project with 20 hand-labelled questions, at the same 8,000-token budget: +**Public benchmark.** 60 real commits from [Flask](https://github.com/pallets/flask) (Python), [Hono](https://github.com/honojs/hono) (TypeScript), and [Apache Commons Lang](https://github.com/apache/commons-lang) (Java). The question is the commit message, the answer files are the code files that commit changed, and each question is measured at the commit's parent. Nobody hand-picks the answers. Same 8,000-token budget, no model calls: -| | Keyword search (grep, then read whole files) | Lantern | -|---|---|---| -| Answer files retrieved (mean recall) | 24% | **67%** | -| Questions with at least one answer file | 35% | **90%** | +| | Keyword search (grep, read whole files) | Keyword search (grep, snippets around matches) | Lantern | +|---|---|---|---| +| Answer files retrieved (mean recall) | 13% | 41% | **88%** | +| An answer file among the first 3 files | 15% | 30% | **65%** | +| Same questions in Korean: recall / first 3 | 13% / 13% | 38% / 27% | **76% / 43%** | -Method and caveats: [eval/결과.md](eval/결과.md) (Korean). The evaluation script is in `eval/`; bring your own project and question set ([format](eval/README.md)). A public benchmark on an open-source codebase is planned. +Picking as many files at random as Lantern returns would hit an answer file 10% of the time. Korean questions are human translations of the same commit messages. Reproduce with `node eval/bench/run.mjs` ([results](eval/bench/결과.md), [method](eval/README.md)). Bring your own project and questions with `eval/retrieval.mjs`. The engine also runs on its own as a CLI and an MCP server, so you can use it from other agents: diff --git a/eval/README.md b/eval/README.md index be99de1..fb67e2e 100644 --- a/eval/README.md +++ b/eval/README.md @@ -2,6 +2,21 @@ 맥락 엔진이 "답하려면 꼭 봐야 하는 파일"을 얼마나 가져오는지 잽니다. 결과 해석은 [결과.md](결과.md). +## 공개 벤치마크 (`eval/bench`, 누구나 재현) + +```bash +cargo build --release -p lantern-context +node eval/bench/run.mjs --dir <저장소를 받을 폴더> +``` + +오픈소스 저장소 3개(flask, hono, commons-lang)를 받아 고정한 커밋으로 맞추고, 영어·한국어 질문 60개씩으로 잰 뒤 [bench/결과.md](bench/결과.md)를 새로 씁니다. + +- **질문과 정답은 실제 커밋에서** (`bench/make.mjs`): 질문 = 커밋 메시지 제목, 정답 파일 = 그 커밋이 고친 코드 파일(테스트 제외), 평가 시점 = 그 커밋의 부모. 사람이 정답을 고르지 않아 엔진을 아는 사람의 편향이 들어가지 않습니다 +- **거르는 커밋**: 병합, 버전·문서·빌드·린트 손질, 새로 만든·지운·옮긴 파일이 있는 커밋, 코드 파일 4개 이상, 메시지에 정답 파일 이름이 나오는 커밋, 같은 정답 파일 묶음은 2개까지 +- **한국어 질문**(`q_ko`): 같은 커밋 메시지를 사람이 옮긴 것. 식별자는 영어 그대로 둡니다 +- **비교 방식**: 키워드 검색(파일 통째로), 키워드 검색(맞은 줄 앞뒤 12줄씩), Lantern. 그리고 Lantern과 같은 수의 파일을 무작위로 골랐을 때의 기대 적중률 +- **한계**: 커밋 메시지는 짧고 영어라 실제 사용자 질문과 다릅니다. 정답 파일은 '고친 파일'이라, 읽어야 했지만 고치지 않은 파일은 정답에 없습니다 + ## 맥락 적중률 (모델 호출 없음, 무료) ```bash diff --git a/eval/bench/commons-lang.json b/eval/bench/commons-lang.json new file mode 100644 index 0000000..6615c6c --- /dev/null +++ b/eval/bench/commons-lang.json @@ -0,0 +1,190 @@ +{ + "project": "commons-lang", + "url": "https://github.com/apache/commons-lang", + "ref": "29624cdb50ecd794207d561345b2fc9ca3a9d326", + "made_by": "eval/bench/make.mjs", + "questions": [ + { + "q": "Fix mutable time zone keys in the FastDateFormat cache", + "q_ko": "FastDateFormat 캐시의 변경 가능한 시간대 키 수정", + "golden": [ + "src/main/java/org/apache/commons/lang3/time/AbstractFormatCache.java" + ], + "commit": "55dcbc4bb1449aa9f7c6ee422cc22b465a35fa2f", + "parent": "d86ec875bdc7676d76c162d393e81db7dee01f2d" + }, + { + "q": "Reflection builders' cycle registry unregisters per-visit: cycle-SAFE but DAG-exponential, a 40-level reference diamond drives ~2^40 traversals and unbounded output (f032).", + "q_ko": "리플렉션 빌더의 순환 참조 등록이 방문마다 해제되어 깊은 참조 구조에서 순회가 지수적으로 늘어나는 문제", + "golden": [ + "src/main/java/org/apache/commons/lang3/builder/ToStringStyle.java" + ], + "commit": "4303a36de3a8b29d19b88e7ca5901f78a536de72", + "parent": "11e2dad29e2ae58d3bd604233af55183ccee1d17" + }, + { + "q": "StrBuilder.indexOf materializes the whole builder as a String per call; deleteAll/replaceAll multiply it into ~750 GB churn on a 1 MB builder (f026).", + "q_ko": "StrBuilder.indexOf가 호출마다 전체를 String으로 만들어 deleteAll과 replaceAll이 메모리를 과하게 쓰는 문제", + "golden": [ + "src/main/java/org/apache/commons/lang3/CharSequenceUtils.java" + ], + "commit": "4d053dc896df7c1165673637607fcc86a146eb10", + "parent": "2e16df6260fb207cf45ae456e25e391cb7ef9bf8" + }, + { + "q": "Attacker-controlled exception message forges frames in getRootCauseStackTrace output AND suppresses all real frames, beyond cosmetic log spoofing (f025).", + "q_ko": "공격자가 만든 예외 메시지가 getRootCauseStackTrace 출력에 가짜 프레임을 끼워 넣고 진짜 프레임을 숨기는 문제", + "golden": [ + "src/main/java/org/apache/commons/lang3/exception/ExceptionUtils.java" + ], + "commit": "2e16df6260fb207cf45ae456e25e391cb7ef9bf8", + "parent": "2dfaafa2576afc5534575fa27967b622e86580b9" + }, + { + "q": "default caches the first failure forever, has no size bound or eviction, and runs the user computation inside the ConcurrentHashMap bin lock (blocking unrelated keys, deadlocking reentrant use) (f011).", + "q_ko": "기본값이 첫 실패를 영원히 캐시하고 크기 제한도 없으며 ConcurrentHashMap 잠금 안에서 사용자 계산을 실행하는 문제", + "golden": [ + "src/main/java/org/apache/commons/lang3/concurrent/Memoizer.java" + ], + "commit": "1aef131265a6b2c8a5ded4301dd92468c7d826c5", + "parent": "fd6361792d1e021462215386d69dd3e0d1b59229" + }, + { + "q": "keep stripStart and stripEnd off surrogate pair boundaries", + "q_ko": "stripStart와 stripEnd가 서로게이트 쌍 경계를 자르지 않게", + "golden": [ + "src/main/java/org/apache/commons/lang3/StringUtils.java" + ], + "commit": "c747711133c3e13d8d2bdab27a2eb7b11f1c6d7f", + "parent": "8bf8b78700c778b10049131f51b687195e039da9" + }, + { + "q": "Keep chop from splitting a trailing surrogate pair", + "q_ko": "chop이 끝의 서로게이트 쌍을 쪼개지 않게", + "golden": [ + "src/main/java/org/apache/commons/lang3/StringUtils.java" + ], + "commit": "62f6edfd96008fd10cd22bf854cf4e8d2c008ae2", + "parent": "9ed5d0ba95652ffcbb912894ce67d81a2fbd60df" + }, + { + "q": "Handle ParsePosition index beyond source string length cleanly", + "q_ko": "ParsePosition 인덱스가 원본 문자열 길이를 넘을 때 깔끔하게 처리", + "golden": [ + "src/main/java/org/apache/commons/lang3/time/FastDateParser.java" + ], + "commit": "e0dba35f9e41dfdbbbb646fcd377b8af8ef7344e", + "parent": "94ad3a756aad97add12e48ed8ff54d630a927d4b" + }, + { + "q": "fix getMatchingMethod false ambiguity for boxed primitive args", + "q_ko": "박싱된 기본형 인자에서 getMatchingMethod가 잘못 모호하다고 하는 문제 수정", + "golden": [ + "src/main/java/org/apache/commons/lang3/reflect/MethodUtils.java" + ], + "commit": "aae7f0e61c51040dbdc7556f9eca5786eb9f63bd", + "parent": "dfaea97de4dd175781eeb552ff9eb29db767acc3" + }, + { + "q": "fix unescapeCsv bounds error on a single quote field", + "q_ko": "따옴표 하나뿐인 필드에서 unescapeCsv 범위 오류 수정", + "golden": [ + "src/main/java/org/apache/commons/lang3/StringEscapeUtils.java" + ], + "commit": "6dbbbb308bb77ba9c0ddab1ae400ee2ff76026b4", + "parent": "c4c294b0e04024d361d710985f8500f7d2412232" + }, + { + "q": "keep initials from splitting a supplementary code point", + "q_ko": "initials가 보조 코드 포인트를 쪼개지 않게", + "golden": [ + "src/main/java/org/apache/commons/lang3/text/WordUtils.java" + ], + "commit": "6bef246b2f10699a56a3010bbc6fa11d241430c6", + "parent": "226bf3975afd430b6b74c23a0c9cbae9f9b90de5" + }, + { + "q": "strip L suffix for oversized hex literals in createNumber", + "q_ko": "createNumber에서 너무 큰 16진수 리터럴의 L 접미사 제거", + "golden": [ + "src/main/java/org/apache/commons/lang3/math/NumberUtils.java" + ], + "commit": "18063c1f6d935d60d0e7341d68434ab4c88a34ee", + "parent": "e10003978ba6bacb7b32c27816b68b858404e967" + }, + { + "q": "Add tests to ensure nulls compare correctly", + "q_ko": "null이 올바르게 비교되는지 확인하는 테스트 추가", + "golden": [ + "src/main/java/org/apache/commons/lang3/compare/ObjectToStringComparator.java" + ], + "commit": "60d4c9843891f07596d2caf52fb8689d80179344", + "parent": "3e111f128a1f5a2f1e7df78c5cd6e8206eada959" + }, + { + "q": "reject doubled leading sign in createBigInteger", + "q_ko": "createBigInteger에서 앞에 부호가 두 번 오면 거부", + "golden": [ + "src/main/java/org/apache/commons/lang3/math/NumberUtils.java" + ], + "commit": "060ab31e517d856292e97428345821b5b289249a", + "parent": "44298fe0c0df0688827bcbf07677c3706de00f88" + }, + { + "q": "fix lookup translator code point count for supplementary keys", + "q_ko": "보조 문자 키에 대한 lookup translator의 코드 포인트 개수 수정", + "golden": [ + "src/main/java/org/apache/commons/lang3/text/translate/LookupTranslator.java" + ], + "commit": "80bf07c61721520ef12679fc394ac5584c1e9536", + "parent": "1e071b3dd5a16cd74b3dd991b7d0b03e581c8d7a" + }, + { + "q": "NaN bypass in primitive double range validators.", + "q_ko": "기본형 double 범위 검증에서 NaN이 통과하는 문제", + "golden": [ + "src/main/java/org/apache/commons/lang3/Validate.java" + ], + "commit": "864dd6ea887fd394f15e3b55203d6f52f717373b", + "parent": "4b09471db039085a3114f79ece97ca7fd9bd6f1e" + }, + { + "q": "Annotation lookup matches unrelated overloads via assignable-parameter resolution", + "q_ko": "애너테이션 조회가 대입 가능한 파라미터 해석 때문에 관련 없는 오버로드와 매칭되는 문제", + "golden": [ + "src/main/java/org/apache/commons/lang3/reflect/MethodUtils.java" + ], + "commit": "daa17b8629a7a9921ffe42e7c2c0f0051af42571", + "parent": "0c7942f416ec7dbf163e72ce0a62a138f11822ad" + }, + { + "q": "No need to end exception messages with an exclamation!", + "q_ko": "예외 메시지를 느낌표로 끝내지 않기", + "golden": [ + "src/main/java/org/apache/commons/lang3/concurrent/BackgroundInitializer.java", + "src/main/java/org/apache/commons/lang3/concurrent/MultiBackgroundInitializer.java", + "src/main/java/org/apache/commons/lang3/concurrent/TimedSemaphore.java" + ], + "commit": "23a730b8ed392ba424cc275df793dcae88f4cebe", + "parent": "7138b8c001b9c60176598a21c0d15db43f8d8a1f" + }, + { + "q": "Make stopInstant be in sync with stopTimeNanos for split", + "q_ko": "split할 때 stopInstant를 stopTimeNanos와 맞추기", + "golden": [ + "src/main/java/org/apache/commons/lang3/time/StopWatch.java" + ], + "commit": "aa47951bd0223d674d2b85d06ccc6f7f1749a682", + "parent": "185edc4b8594e20d81baad65a199a211e2137616" + }, + { + "q": "Use a single return in indexesOf() methods", + "q_ko": "indexesOf() 메서드에서 return을 하나만 쓰기", + "golden": [ + "src/main/java/org/apache/commons/lang3/ArrayUtils.java" + ], + "commit": "becbcffffe673ad062b2b389a2a31dce7565841e", + "parent": "a4cf528306f96784ed9c8e4cacb59e128fea92b3" + } + ] +} diff --git a/eval/bench/flask.json b/eval/bench/flask.json new file mode 100644 index 0000000..b79f1aa --- /dev/null +++ b/eval/bench/flask.json @@ -0,0 +1,193 @@ +{ + "project": "flask", + "url": "https://github.com/pallets/flask", + "ref": "d73fa1cdcbd8b1465c151db8924ba58b1dd14e35", + "made_by": "eval/bench/make.mjs", + "questions": [ + { + "q": "support query in methodview", + "q_ko": "MethodView에서 query 메서드 지원", + "golden": [ + "src/flask/views.py" + ], + "commit": "2a8a38b051fc248865730bf3511bf2e2ea325e81", + "parent": "d8eaaba824655046958d1a97f11780de460c3271" + }, + { + "q": "add app.query route decorator", + "q_ko": "app.query 라우트 데코레이터 추가", + "golden": [ + "src/flask/sansio/scaffold.py" + ], + "commit": "89992954ec71b594b1b911f98977cdc8ad46a057", + "parent": "3596b1ab61cea85edb8970e83ff61daa073facf8" + }, + { + "q": "Fix IPv6 server name parsing", + "q_ko": "IPv6 서버 이름 파싱 수정", + "golden": [ + "src/flask/app.py" + ], + "commit": "7203feabf723edae0286ae5dc64fec8ac4c91735", + "parent": "de8429ffda8cfb54db175529e2bae72a24e1fa7e" + }, + { + "q": "Fix IPv6 session transactions", + "q_ko": "IPv6에서 세션 트랜잭션 수정", + "golden": [ + "src/flask/testing.py" + ], + "commit": "de8429ffda8cfb54db175529e2bae72a24e1fa7e", + "parent": "514fc6b3e8402e4c646d5284e97a4f0ab50a7c4b" + }, + { + "q": "Any for CertParamType type", + "q_ko": "CertParamType 타입에 Any 사용", + "golden": [ + "src/flask/cli.py" + ], + "commit": "36e4a824f340fdee7ed50937ba8e7f6bc7d17f81", + "parent": "954f5684e4841aad84a8eec7ace7b81a0d3f6831" + }, + { + "q": "separate copy per call", + "q_ko": "호출마다 복사본을 따로 만들기", + "golden": [ + "src/flask/ctx.py" + ], + "commit": "06ea505ce2b2042af26e96d35ebf159af7c0869d", + "parent": "2ac89889f4cc330eabd50f295dcef02828522c69" + }, + { + "q": "document that headers must be set before streaming", + "q_ko": "스트리밍 전에 헤더를 설정해야 한다는 점을 명시", + "golden": [ + "src/flask/ctx.py", + "src/flask/helpers.py" + ], + "commit": "a29f88ce6f2f9843bd6fcbbfce1390a2071965d6", + "parent": "f00ad424ee3b050d382cc5b4aabb18afbb5e4ae7" + }, + { + "q": "add back opening session on context push", + "q_ko": "컨텍스트를 push할 때 세션을 여는 동작 되돌리기", + "golden": [ + "src/flask/ctx.py" + ], + "commit": "a411a2434b09d9cbaa4074232b2c247d591bb655", + "parent": "daca74d93a0edc458e618136de7d14803f06bc28" + }, + { + "q": "Abort if the instance folder cannot be created", + "q_ko": "인스턴스 폴더를 만들 수 없으면 중단", + "golden": [ + "examples/tutorial/flaskr/__init__.py" + ], + "commit": "3d03098a97ddc6a908aa4a50c2ef7381f8297d0a", + "parent": "407eb76b27884848383a37c7274654f0271e4bc4" + }, + { + "q": "redirect defaults to 303", + "q_ko": "리다이렉트 기본값을 303으로", + "golden": [ + "src/flask/helpers.py", + "src/flask/sansio/app.py" + ], + "commit": "eca5fd1dfdc614c2df876cc32018a7d71f84ea82", + "parent": "eb58d862cc4a8f31a369b6e9ad1724e9e642f13f" + }, + { + "q": "pass context through dispatch methods", + "q_ko": "디스패치 메서드에 컨텍스트 전달", + "golden": [ + "src/flask/app.py", + "src/flask/ctx.py", + "src/flask/templating.py" + ], + "commit": "6a64969009bf1898bd302e6a9633f71942523f7f", + "parent": "adf363679da2d9a5ddc564bb2da563c7ca083916" + }, + { + "q": "use template_filter without parens", + "q_ko": "괄호 없이 template_filter 사용", + "golden": [ + "src/flask/sansio/app.py", + "src/flask/sansio/blueprints.py" + ], + "commit": "daf1510a4bda652ac2cfca03ec5567b03ddf8bf1", + "parent": "85c5d93cbd049c4bd0679c36fd1ddcae8c37b642" + }, + { + "q": "refactor stream_with_context for async views", + "q_ko": "비동기 뷰를 위해 stream_with_context 리팩터링", + "golden": [ + "src/flask/helpers.py" + ], + "commit": "9822a0351574790cb66c652fcc396ad7aa2b09d8", + "parent": "49b7e7bc8fb69d605719991d1c0a99fcee689053" + }, + { + "q": "relax type hint for bytes io", + "q_ko": "바이트 IO의 타입 힌트 완화", + "golden": [ + "src/flask/helpers.py" + ], + "commit": "d44f1c65239c3e33509224aa1931631243dbfa7f", + "parent": "c56c5ec7c41e6ffd02516bf7cb80dd2ba9ba7aa3" + }, + { + "q": "push preserved contexts in correct order", + "q_ko": "보존된 컨텍스트를 올바른 순서로 push", + "golden": [ + "src/flask/testing.py" + ], + "commit": "53b8f08218796d95764c5252bccd8a50a173a6fb", + "parent": "5addaf833b2e8c7a616f89dd8ad5a44b07d7c000" + }, + { + "q": "secret key rotation: fix key list ordering", + "q_ko": "시크릿 키 교체: 키 목록 순서 수정", + "golden": [ + "src/flask/sessions.py" + ], + "commit": "fb54159861708558b5f5658ebdc14709d984361c", + "parent": "941efd4a36ed0f27e13758874f95e3aa1d3ee163" + }, + { + "q": "Accept `AsyncIterable` for responses", + "q_ko": "응답으로 AsyncIterable 허용", + "golden": [ + "src/flask/typing.py" + ], + "commit": "410e5ab7ed0ef326fa8b5164a633863f137ffff5", + "parent": "bfffe87d4c2ea255b9a51432bebb3d28741245c4" + }, + { + "q": "better type checking during deprecation", + "q_ko": "지원 중단 처리 중 타입 검사 개선", + "golden": [ + "src/flask/__init__.py" + ], + "commit": "7d5d1874581209f66ad7bcdfb7f85b2b9a7ca471", + "parent": "c7c8dc38ea2890d62b7aaff7e4c016e4b98101b0" + }, + { + "q": "Handle help arg by itself the same as no args", + "q_ko": "help 인자만 있을 때 인자가 없을 때와 똑같이 처리", + "golden": [ + "src/flask/cli.py" + ], + "commit": "da600394865c06a4772c433c1a560453dcf4f868", + "parent": "08c480b3b31e896698bb773069026a61eb53d158" + }, + { + "q": "remove previously deprecated code", + "q_ko": "이전에 지원 중단된 코드 삭제", + "golden": [ + "src/flask/__init__.py" + ], + "commit": "d22bfcd4cfe3b93daddfce8521f77447ca8d6b84", + "parent": "4fec712f329ceecd38729af6404a2c32aed83648" + } + ] +} diff --git a/eval/bench/hono.json b/eval/bench/hono.json new file mode 100644 index 0000000..700332f --- /dev/null +++ b/eval/bench/hono.json @@ -0,0 +1,190 @@ +{ + "project": "hono", + "url": "https://github.com/honojs/hono", + "ref": "ee0622e14487444211942eec2f6ab7ccede4c6af", + "made_by": "eval/bench/make.mjs", + "questions": [ + { + "q": "allow returning a Blob as a response body", + "q_ko": "응답 본문으로 Blob을 반환할 수 있게", + "golden": [ + "src/context.ts" + ], + "commit": "90d02fb1645c12a65d27f39594d2129db2065ba7", + "parent": "5f31392d724cf98c9e48a3de119a55284583ad5f" + }, + { + "q": "fail with a descriptive error on a malformed event", + "q_ko": "잘못된 이벤트가 오면 알기 쉬운 오류로 실패", + "golden": [ + "src/adapter/lambda-edge/handler.ts" + ], + "commit": "6cadf7537385c6e3df9cbd6d05c9b84c82ae0361", + "parent": "de310ac063febf3e7b73f6206142d0e88800661f" + }, + { + "q": "sync content type detection with aws-lambda", + "q_ko": "콘텐츠 타입 판별을 aws-lambda와 맞추기", + "golden": [ + "src/adapter/lambda-edge/handler.ts" + ], + "commit": "de310ac063febf3e7b73f6206142d0e88800661f", + "parent": "28e8572cd265b4da1160ce6cd51919bb70516e2c" + }, + { + "q": "preserve empty query parameters", + "q_ko": "빈 쿼리 파라미터 유지", + "golden": [ + "src/adapter/aws-lambda/handler.ts" + ], + "commit": "28e8572cd265b4da1160ce6cd51919bb70516e2c", + "parent": "0d86899d28e146da0c8760201b2d08ddae732eaa" + }, + { + "q": "treat binary +xml archive media types as binary", + "q_ko": "+xml 압축 파일 미디어 타입을 바이너리로 처리", + "golden": [ + "src/adapter/aws-lambda/handler.ts" + ], + "commit": "0d86899d28e146da0c8760201b2d08ddae732eaa", + "parent": "52febbcc30bc509d5e4987577e63906fe08fc471" + }, + { + "q": "don't break responses with unparseable JSON bodies", + "q_ko": "파싱할 수 없는 JSON 본문이 있어도 응답이 깨지지 않게", + "golden": [ + "src/middleware/pretty-json/index.ts" + ], + "commit": "f950277cd3d9264622785e82785f21c648da699a", + "parent": "00ee875728e2369b98a9ab7dda0690b77afedcff" + }, + { + "q": "don't match an empty path segment as a param", + "q_ko": "빈 경로 조각을 파라미터로 매칭하지 않기", + "golden": [ + "src/router/linear-router/router.ts" + ], + "commit": "00ee875728e2369b98a9ab7dda0690b77afedcff", + "parent": "f5a53463ad2ff791fc02418b645750ce75a260ad" + }, + { + "q": "match media types and language tags case-insensitively", + "q_ko": "미디어 타입과 언어 태그를 대소문자 구분 없이 매칭", + "golden": [ + "src/helper/accepts/accepts.ts" + ], + "commit": "f5a53463ad2ff791fc02418b645750ce75a260ad", + "parent": "cb5bea3614e6591e8ab933ef9a38ce7addf1d09a" + }, + { + "q": "replace Suspense and ErrorBoundary content across newlines", + "q_ko": "줄바꿈이 있어도 Suspense와 ErrorBoundary 내용 교체", + "golden": [ + "src/jsx/components.ts", + "src/jsx/streaming.ts" + ], + "commit": "cb5bea3614e6591e8ab933ef9a38ce7addf1d09a", + "parent": "098e11912ab244c5c33931de007f04dc8e3c2929" + }, + { + "q": "optimize matching-head child lookup during reconciliation", + "q_ko": "재조정 중 head 자식 찾기 최적화", + "golden": [ + "src/jsx/dom/render.ts" + ], + "commit": "e8c8c21244f87da80aaa5ac5ab9c92f3faca2489", + "parent": "8755b17fbcfdee76511eeb460e18e94e6c9a8d30" + }, + { + "q": "clamp a negative q to 0, not 1", + "q_ko": "음수 q 값을 1이 아니라 0으로 제한", + "golden": [ + "src/utils/accept.ts" + ], + "commit": "9b4e9c2864c30ea9275cf426e2d2716bd9f05195", + "parent": "65cff90e152dbbbcdde2808af15b48344676edfe" + }, + { + "q": "treat the q parameter name as case-insensitive", + "q_ko": "q 파라미터 이름을 대소문자 구분 없이 처리", + "golden": [ + "src/utils/accept.ts" + ], + "commit": "65cff90e152dbbbcdde2808af15b48344676edfe", + "parent": "f147de5ea1eeadd4b4851ab075999673e140a303" + }, + { + "q": "skip accept entries with quality 0 when matching", + "q_ko": "매칭할 때 품질 값이 0인 accept 항목 건너뛰기", + "golden": [ + "src/helper/accepts/accepts.ts", + "src/middleware/language/language.ts" + ], + "commit": "f147de5ea1eeadd4b4851ab075999673e140a303", + "parent": "90e1b948467961718afd3ae34af9dee582b42248" + }, + { + "q": "use forEach for consistent handler iteration", + "q_ko": "핸들러를 일관되게 순회하도록 forEach 사용", + "golden": [ + "src/hono-base.ts" + ], + "commit": "dac5d5794c6134711e469c5e69d09cd6274e1fc1", + "parent": "ec648d683768ec5093315e5dc694c05594fec185" + }, + { + "q": "normalize root WebSocket URLs", + "q_ko": "루트 WebSocket URL 정규화", + "golden": [ + "src/client/client.ts" + ], + "commit": "499c35ebda35777fd35a7dd1906dd4f2687da61e", + "parent": "50b8788cf54cb60112b7cd93642bc5094901475c" + }, + { + "q": "keep a param value of \"index\" in $url() and $path()", + "q_ko": "$url()과 $path()에서 파라미터 값 index 유지", + "golden": [ + "src/client/client.ts" + ], + "commit": "50b8788cf54cb60112b7cd93642bc5094901475c", + "parent": "06880c4a2b04de9dd74217f26dd831209b9c01f1" + }, + { + "q": "do not let abort listeners crash abort()", + "q_ko": "abort 리스너가 abort()를 망가뜨리지 않게", + "golden": [ + "src/utils/stream.ts" + ], + "commit": "5e5b83d6ed963a2bc7d8384002e1c953648253b1", + "parent": "241ae4c72b7ab732e425f40ea28cd3af2e78d8a2" + }, + { + "q": "drop stale content length for cloned FormData", + "q_ko": "복제한 FormData의 오래된 content length 제거", + "golden": [ + "src/request.ts" + ], + "commit": "612b59c0227b421090d46721cd5a98243ad8c6ec", + "parent": "73794bdabeadca93676cdbd54aa82d018feb69b8" + }, + { + "q": "support wildcard media types and specificity ordering in defaultMatch", + "q_ko": "defaultMatch에서 와일드카드 미디어 타입과 구체성 순서 지원", + "golden": [ + "src/helper/accepts/accepts.ts" + ], + "commit": "28a9c128912de19f3fb28b00ae289c9b93f6f392", + "parent": "1096d66e03a3fd16a5cc5f912c2d5e8fd6d990b0" + }, + { + "q": "skip an undefined entry inside a query array", + "q_ko": "쿼리 배열 안의 undefined 항목 건너뛰기", + "golden": [ + "src/client/utils.ts" + ], + "commit": "2059584f84187a688354956e0647269834ccf3a6", + "parent": "d6c0b0de163a3b709e4dc57de0e0e8581aa08b7b" + } + ] +} diff --git a/eval/bench/make.mjs b/eval/bench/make.mjs new file mode 100644 index 0000000..beb14a6 --- /dev/null +++ b/eval/bench/make.mjs @@ -0,0 +1,91 @@ +// 공개 벤치마크 만들기: 오픈소스 저장소의 실제 커밋에서 질문과 정답 파일을 뽑는다. +// 질문 = 커밋 메시지 제목, 정답 파일 = 그 커밋이 고친 코드 파일, 평가 시점 = 그 커밋의 부모. +// 사람이 정답을 고르지 않으므로 엔진을 아는 사람의 편향이 들어가지 않고, 누구나 같은 커밋으로 재현할 수 있다. +// +// 사용: node eval/bench/make.mjs --repo <클론 폴더> --name flask --url https://github.com/pallets/flask [--count 20] +// 결과: eval/bench/.json (고정한 커밋과 질문 목록) + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const argv = process.argv.slice(2); +const opt = (name, fallback) => { + const i = argv.indexOf(`--${name}`); + return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback; +}; +const REPO = path.resolve(opt("repo", "")); +const NAME = opt("name", path.basename(REPO)); +const URL = opt("url", ""); +const COUNT = Number(opt("count", "20")); +const REF = opt("ref", "HEAD"); +if (!fs.existsSync(path.join(REPO, ".git"))) { + console.error("--repo 가 필요합니다"); + process.exit(2); +} +const git = (...a) => execFileSync("git", ["-C", REPO, ...a], { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 }); + +// 맥락 엔진이 읽는 언어와 같은 기준 +const CODE = /\.(ts|tsx|js|jsx|mjs|cjs|py|rs|java|go|cs)$/; +const isTest = (p) => { + const l = p.toLowerCase(); + const file = p.split("/").pop() ?? ""; + const stem = file.replace(/\.[^.]+$/, ""); + return /(^|\/)(tests?|__tests__|spec)\//.test(l) || /\.(test|spec)\./.test(l) || /_test\./.test(l) || file.startsWith("test_") || /(Test|Tests|IT)$/.test(stem); +}; +// 질문으로 쓰기 어려운 커밋 (버전·문서·빌드·되돌리기 등) +const SKIP = /^(merge|bump|release|revert|chore|docs?|ci|build|style|test|tests|typo|format|lint|deps|update dependencies|prepare|version)\b/i; +// 도구·문서 손질 (코드 동작을 묻는 질문이 되지 않는다) +const SKIP_ANY = /\b(ruff|noqa|lint|linter|eslint|prettier|mypy|pyright|flake8|checkstyle|spotbugs|pmd|javadoc|docs?|docstrings?|typos?|changelog|readme|copyright|license|whitespace|formatting|comments?|unused|ternary)\b/i; +/** 같은 정답 파일 묶음은 이 수까지만 (한 파일에 몰린 이력이 결과를 좌우하지 않게) */ +const MAX_SAME_GOLDEN = 2; + +/** "fix(router): handle trailing slash (#123)" → "handle trailing slash" */ +function clean(subject) { + return subject + .replace(/^\[[^\]]*\]\s*/, "") + .replace(/^[a-z]+(\([^)]*\))?!?:\s*/i, "") + .replace(/\s*\(#\d+\)\s*$/, "") + .replace(/\s*#\d+\s*$/, "") + .trim(); +} + +const head = git("rev-parse", REF).trim(); +const log = git("log", "--no-merges", "--format=@@%H %P%n%s", "--name-status", "-n", "4000", head); +const questions = []; +for (const block of log.split("@@").slice(1)) { + if (questions.length >= COUNT) break; + const [first, subject, ...rest] = block.split("\n"); + const [sha, ...parents] = first.trim().split(" "); + if (parents.length !== 1 || !subject || SKIP.test(subject.trim()) || SKIP_ANY.test(subject)) continue; + const changes = rest.filter(Boolean).map((l) => l.split("\t")); + if (changes.length > 6) continue; // 큰 리팩터링은 질문 하나로 가리키기 어렵다 + const code = changes.filter(([, p]) => p && CODE.test(p) && !isTest(p)); + // 새로 만든·지운·옮긴 파일은 부모 시점에 없거나 이름이 달라 찾을 수 없다 + if (!code.length || code.length > 3 || code.some(([s]) => s !== "M")) continue; + const golden = code.map(([, p]) => p); + const q = clean(subject); + if (q.length < 20 || q.split(/\s+/).length < 4) continue; + // 메시지에 파일 이름이 나오면 검색이 아니라 받아쓰기가 된다 + const leaks = golden.some((g) => { + const stem = g.split("/").pop().replace(/\.[^.]+$/, "").toLowerCase(); + return stem.length >= 3 && q.toLowerCase().includes(stem); + }); + if (leaks) continue; + const key = [...golden].sort().join("|"); + if (questions.some((x) => x.q === q) || questions.filter((x) => [...x.golden].sort().join("|") === key).length >= MAX_SAME_GOLDEN) continue; + questions.push({ q, q_ko: null, golden, commit: sha, parent: parents[0] }); +} + +const file = path.join(HERE, `${NAME}.json`); +// 다시 만들어도 사람이 옮긴 한국어 질문은 같은 커밋이면 그대로 둔다 +if (fs.existsSync(file)) { + const prev = new Map(JSON.parse(fs.readFileSync(file, "utf8")).questions.map((x) => [x.commit, x.q_ko])); + for (const x of questions) x.q_ko = prev.get(x.commit) ?? null; +} +const out = { project: NAME, url: URL, ref: head, made_by: "eval/bench/make.mjs", questions }; +fs.writeFileSync(file, JSON.stringify(out, null, 2) + "\n"); +console.log(`${file}: 질문 ${questions.length}개 (기준 커밋 ${head.slice(0, 10)})`); +for (const [i, x] of questions.entries()) console.log(`${String(i + 1).padStart(2)}. ${x.q} → ${x.golden.join(", ")}`); diff --git a/eval/bench/run.mjs b/eval/bench/run.mjs new file mode 100644 index 0000000..66f9466 --- /dev/null +++ b/eval/bench/run.mjs @@ -0,0 +1,71 @@ +// 공개 벤치마크 한 번에 돌리기: 저장소를 받아(없으면) 고정한 커밋으로 맞추고, 영어·한국어 질문으로 잰 뒤 +// eval/bench/결과.md에 합계를 쓴다. 모델 호출 없음, 무료. +// +// 사용: cargo build --release -p lantern-context +// node eval/bench/run.mjs --dir <저장소를 받을 폴더> [--budget 8000] + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const argv = process.argv.slice(2); +const opt = (name, fallback) => { + const i = argv.indexOf(`--${name}`); + return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback; +}; +const DIR = path.resolve(opt("dir", path.join(HERE, "..", "results", "bench"))); +const BUDGET = opt("budget", "8000"); +const RESULTS = path.join(DIR, "results"); +fs.mkdirSync(RESULTS, { recursive: true }); + +const sets = fs.readdirSync(HERE).filter((f) => f.endsWith(".json")).sort(); +const table = []; +for (const f of sets) { + const set = JSON.parse(fs.readFileSync(path.join(HERE, f), "utf8")); + const repo = path.join(DIR, set.project); + if (!fs.existsSync(path.join(repo, ".git"))) { + console.log(`${set.url} 받는 중…`); + execFileSync("git", ["-c", "core.longpaths=true", "clone", "-q", "--filter=blob:none", "--no-checkout", set.url, repo], { stdio: "inherit" }); + execFileSync("git", ["-C", repo, "config", "core.longpaths", "true"]); + } + execFileSync("git", ["-C", repo, "checkout", "-q", "-f", "--detach", set.ref], { stdio: "inherit" }); + for (const lang of ["en", "ko"]) { + console.log(`\n== ${set.project} (${lang})`); + execFileSync("node", [path.join(HERE, "..", "retrieval.mjs"), "--project", repo, "--golden", path.join(HERE, f), "--budget", BUDGET, "--out", RESULTS, "--lang", lang], { stdio: "inherit" }); + // retrieval-<프로젝트>[-ko]-<시각>.json (영어 쪽이 한국어 파일을 집지 않게 시각 앞까지 정확히 맞춘다) + const name = new RegExp(`^retrieval-${set.project.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}${lang === "ko" ? "-ko" : ""}-\\d{4}-.*\\.json$`); + const latest = fs.readdirSync(RESULTS).filter((x) => name.test(x)).sort().pop(); + const r = JSON.parse(fs.readFileSync(path.join(RESULTS, latest), "utf8")); + table.push({ project: set.project, url: set.url, ref: set.ref, lang, n: r.rows.length, s: r.summary }); + } +} + +const pct = (x) => `${Math.round(x * 100)}%`; +const avg = (rows, f) => rows.reduce((n, r) => n + f(r), 0) / rows.length; +const line = (label, rows) => + `| ${label} | ${rows.reduce((n, r) => n + r.n, 0)} | ${pct(avg(rows, (r) => r.s.grep.recall))} | ${pct(avg(rows, (r) => r.s.snippets.recall))} | **${pct(avg(rows, (r) => r.s.lantern.recall))}** | ${pct(avg(rows, (r) => r.s.grep.top3))} | ${pct(avg(rows, (r) => r.s.snippets.top3))} | **${pct(avg(rows, (r) => r.s.lantern.top3))}** | ${pct(avg(rows, (r) => r.s.chance))} |`; +const md = [ + "# 공개 벤치마크 결과", + "", + `\`node eval/bench/run.mjs\`가 쓴 파일입니다. 예산 ${BUDGET} 토큰 · 모델 호출 없음 · ${new Date().toISOString().slice(0, 10)}`, + "", + "질문은 오픈소스 저장소의 실제 커밋 메시지, 정답 파일은 그 커밋이 고친 코드 파일이고, 각 질문은 그 커밋의 부모 시점에서 잽니다 (만드는 법: `eval/bench/make.mjs`). 한국어 질문은 같은 커밋 메시지를 사람이 옮긴 것이고 정답 파일은 같습니다.", + "", + "- 적중률: 정답 파일 중 가져온 비율의 평균", + "- 앞 3개: 가져온 파일 중 처음 3개 안에 정답 파일이 있는 질문 비율 (많이 가져올수록 유리한 효과를 뺀 지표)", + "- 무작위: Lantern이 가져온 것과 같은 수의 파일을 무작위로 골랐을 때 정답을 하나라도 가져올 확률", + "", + "| 저장소 (언어) | 질문 | 키워드 적중률 | 키워드(조각) 적중률 | Lantern 적중률 | 키워드 앞 3개 | 키워드(조각) 앞 3개 | Lantern 앞 3개 | 무작위 |", + "|---|---|---|---|---|---|---|---|---|", + ...table.map((r) => line(`[${r.project}](${r.url}/tree/${r.ref.slice(0, 10)}) ${r.lang === "ko" ? "한국어" : "영어"}`, [r])), + line("**합계 (영어)**", table.filter((r) => r.lang === "en")), + line("**합계 (한국어)**", table.filter((r) => r.lang === "ko")), + "", + "키워드: 질문의 단어로 grep → 많이 맞은 파일부터 통째로 읽기. 키워드(조각): 같은 grep에서 맞은 줄 앞뒤 12줄만 가져와 예산을 여러 파일에 나누기.", + "", +].join("\n"); +fs.writeFileSync(path.join(HERE, "결과.md"), md); +console.log(`\n${path.join(HERE, "결과.md")}`); +console.log(md); diff --git "a/eval/bench/\352\262\260\352\263\274.md" "b/eval/bench/\352\262\260\352\263\274.md" new file mode 100644 index 0000000..f6dffba --- /dev/null +++ "b/eval/bench/\352\262\260\352\263\274.md" @@ -0,0 +1,22 @@ +# 공개 벤치마크 결과 + +`node eval/bench/run.mjs`가 쓴 파일입니다. 예산 8000 토큰 · 모델 호출 없음 · 2026-09-26 + +질문은 오픈소스 저장소의 실제 커밋 메시지, 정답 파일은 그 커밋이 고친 코드 파일이고, 각 질문은 그 커밋의 부모 시점에서 잽니다 (만드는 법: `eval/bench/make.mjs`). 한국어 질문은 같은 커밋 메시지를 사람이 옮긴 것이고 정답 파일은 같습니다. + +- 적중률: 정답 파일 중 가져온 비율의 평균 +- 앞 3개: 가져온 파일 중 처음 3개 안에 정답 파일이 있는 질문 비율 (많이 가져올수록 유리한 효과를 뺀 지표) +- 무작위: Lantern이 가져온 것과 같은 수의 파일을 무작위로 골랐을 때 정답을 하나라도 가져올 확률 + +| 저장소 (언어) | 질문 | 키워드 적중률 | 키워드(조각) 적중률 | Lantern 적중률 | 키워드 앞 3개 | 키워드(조각) 앞 3개 | Lantern 앞 3개 | 무작위 | +|---|---|---|---|---|---|---|---|---| +| [commons-lang](https://github.com/apache/commons-lang/tree/29624cdb50) 영어 | 20 | 20% | 45% | **85%** | 20% | 40% | **70%** | 3% | +| [commons-lang](https://github.com/apache/commons-lang/tree/29624cdb50) 한국어 | 20 | 15% | 50% | **80%** | 15% | 45% | **60%** | 3% | +| [flask](https://github.com/pallets/flask/tree/d73fa1cdcb) 영어 | 20 | 14% | 49% | **93%** | 20% | 35% | **65%** | 19% | +| [flask](https://github.com/pallets/flask/tree/d73fa1cdcb) 한국어 | 20 | 20% | 35% | **73%** | 20% | 25% | **45%** | 15% | +| [hono](https://github.com/honojs/hono/tree/ee0622e144) 영어 | 20 | 5% | 30% | **85%** | 5% | 15% | **60%** | 6% | +| [hono](https://github.com/honojs/hono/tree/ee0622e144) 한국어 | 20 | 5% | 28% | **75%** | 5% | 10% | **25%** | 6% | +| **합계 (영어)** | 60 | 13% | 41% | **88%** | 15% | 30% | **65%** | 10% | +| **합계 (한국어)** | 60 | 13% | 38% | **76%** | 13% | 27% | **43%** | 8% | + +키워드: 질문의 단어로 grep → 많이 맞은 파일부터 통째로 읽기. 키워드(조각): 같은 grep에서 맞은 줄 앞뒤 12줄만 가져와 예산을 여러 파일에 나누기. diff --git a/eval/retrieval.mjs b/eval/retrieval.mjs index 037d3b6..2242b58 100644 --- a/eval/retrieval.mjs +++ b/eval/retrieval.mjs @@ -4,8 +4,12 @@ // B: Lantern 맥락 엔진 (lantern context) // 이 정답 파일을 얼마나 가져오는지 잰다. 모델이 좋은 답을 하려면 먼저 이 파일을 봐야 한다. // -// 사용: node eval/retrieval.mjs --project <프로젝트> --golden <정답 파일 JSON> [--budget 8000] [--lantern target/release/lantern.exe] +// 사용: node eval/retrieval.mjs --project <프로젝트> --golden <정답 파일 JSON> [--budget 8000] [--lantern target/release/lantern.exe] [--lang ko] // 결과: eval/results/retrieval-<시각>.md / .json +// +// 질문에 `parent`(커밋)가 있으면 질문마다 프로젝트를 그 커밋으로 꺼내서 잰다 (eval/bench의 공개 벤치마크). +// 이때 프로젝트는 변경 없는 git 클론이어야 하고, 끝나면 원래 커밋으로 돌려 놓는다. +// --lang ko: 질문의 한국어판(`q_ko`)으로 잰다. 정답 파일은 같다. import { execFileSync } from "node:child_process"; import fs from "node:fs"; @@ -28,11 +32,29 @@ if (!fs.existsSync(PROJECT) || !GOLDEN) { console.error("--project <평가할 프로젝트 폴더>와 --golden <정답 파일 JSON>이 필요합니다. 형식은 eval/README.md"); process.exit(2); } +const LANG = opt("lang", "en"); const golden = JSON.parse(fs.readFileSync(path.resolve(GOLDEN), "utf8")); -const questions = golden.questions; +const questions = golden.questions + .map((q) => (LANG === "ko" ? { ...q, q: q.q_ko } : q)) + .filter((q) => q.q); + +// ── 커밋별 평가: 질문마다 부모 커밋으로 꺼낸다 ───────────────── +const git = (...a) => execFileSync("git", ["-C", PROJECT, ...a], { encoding: "utf8" }).trim(); +const needsCheckout = questions.some((q) => q.parent); +let original = null; +if (needsCheckout) { + if (git("status", "--porcelain", "--untracked-files=no")) { + console.error(`${PROJECT}에 커밋하지 않은 변경이 있습니다. 커밋별 평가는 깨끗한 클론에서만 합니다.`); + process.exit(2); + } + original = git("rev-parse", "HEAD"); +} +function checkout(rev) { + if (rev) execFileSync("git", ["-C", PROJECT, "-c", "core.longpaths=true", "checkout", "-q", "--detach", rev], { stdio: "ignore" }); +} // ── 프로젝트 파일 (맥락 엔진과 같은 기준: 코드 파일, 무시 폴더 제외) ───────── -const CODE = /\.(ts|tsx|js|jsx|mjs|cjs|py|rs)$/; +const CODE = /\.(ts|tsx|js|jsx|mjs|cjs|py|rs|java|go|cs)$/; const IGNORE = new Set([".git", "node_modules", "target", "dist", "build", ".next", ".lantern", "coverage", ".turbo", ".venv", "__pycache__"]); function* walk(dir) { for (const e of fs.readdirSync(dir, { withFileTypes: true })) { @@ -45,10 +67,12 @@ function* walk(dir) { const rel = (p) => path.relative(PROJECT, p).split(path.sep).join("/"); // 맥락 엔진과 같은 기준으로 빌드 결과물(한 줄이 매우 긴 압축 파일)은 뺀다 (indexer.rs is_unindexable) const minified = (t) => t.length > 20_000 && t.length / t.split("\n").length > 500; -const files = [...walk(PROJECT)] - .filter((p) => fs.statSync(p).size < 400_000) - .map((p) => ({ path: rel(p), text: fs.readFileSync(p, "utf8") })) - .filter((f) => !minified(f.text)); +const readFiles = () => + [...walk(PROJECT)] + .filter((p) => fs.statSync(p).size < 400_000) + .map((p) => ({ path: rel(p), text: fs.readFileSync(p, "utf8") })) + .filter((f) => !minified(f.text)); +let files = readFiles(); // 토큰은 맥락 엔진과 같은 거친 추정 (글자 4개 ≈ 1토큰) const tokens = (s) => Math.ceil(s.length / 4); @@ -60,9 +84,8 @@ function keywords(q) { return [...new Set([...latin, ...korean])].filter((w) => !STOP.has(w)); } -function grepRetrieve(q) { - const terms = keywords(q); - const scored = files +function grepScored(terms) { + return files .map((f) => { const lower = f.text.toLowerCase(); const pathLower = f.path.toLowerCase(); @@ -79,6 +102,11 @@ function grepRetrieve(q) { }) .filter((x) => x.distinct > 0) .sort((a, b) => b.distinct - a.distinct || b.hits - a.hits || a.f.path.localeCompare(b.f.path)); +} + +function grepRetrieve(q) { + const terms = keywords(q); + const scored = grepScored(terms); // 예산 안에서 맞은 파일을 통째로 (예산보다 큰 파일은 앞부분만) const picked = []; let used = 0; @@ -91,9 +119,40 @@ function grepRetrieve(q) { return { files: picked, used, terms }; } +/** 키워드 검색 (조각): 파일을 통째로 읽지 않고 맞은 줄 앞뒤만 가져와 같은 예산을 여러 파일에 나눈다 (rg -C 방식) */ +const WINDOW = 12; +function grepSnippets(q) { + const terms = keywords(q); + const picked = []; + let used = 0; + for (const x of grepScored(terms)) { + if (used >= BUDGET) break; + const lines = x.f.text.split("\n"); + const take = new Set(); + lines.forEach((l, i) => { + const lower = l.toLowerCase(); + if (terms.some((t) => lower.includes(t))) for (let j = Math.max(0, i - WINDOW); j <= Math.min(lines.length - 1, i + WINDOW); j++) take.add(j); + }); + if (!take.size) take.add(0); // 경로만 맞음: 첫 줄부터 + const text = [...take].sort((a, b) => a - b).map((i) => lines[i]).join("\n"); + // 파일 하나가 예산을 다 먹지 않게, 한 파일은 예산의 1/4까지 + used += Math.min(tokens(text), BUDGET / 4, BUDGET - used); + picked.push(x.f.path); + } + return { files: picked, used, terms }; +} + +/** 파일 n개를 무작위로 골랐을 때 정답 파일을 하나라도 가져올 확률 */ +function chanceAny(n, total, g) { + if (n <= 0) return 0; + let miss = 1; + for (let i = 0; i < n; i++) miss *= Math.max(0, total - g - i) / (total - i); + return 1 - miss; +} + // ── B: Lantern ───────────────────────────────────────────── function lanternRetrieve(q) { - const env = { ...process.env, LANTERN_INDEX_DIR: process.env.LANTERN_INDEX_DIR || path.join(OUT, "index") }; + const env = { ...process.env, LANTERN_INDEX_DIR: process.env.LANTERN_INDEX_DIR || path.join(OUT, `index-${path.basename(PROJECT)}`) }; const out = execFileSync(LANTERN_BIN, ["-C", PROJECT, "context", q, "--budget", String(BUDGET), "--json"], { encoding: "utf8", env, maxBuffer: 64 * 1024 * 1024 }); const r = JSON.parse(out); const picked = []; @@ -108,7 +167,7 @@ function score(picked, q) { // 같은 역할의 다른 구현(also_ok)을 가져왔으면 부분 점수 대신 따로 센다 const alt = (q.also_ok ?? []).filter((g) => set.has(g)); const firstRank = picked.findIndex((p) => q.golden.includes(p)); - return { recall: hit.length / q.golden.length, all: hit.length === q.golden.length, any: hit.length > 0, hit, alt, rank: firstRank < 0 ? null : firstRank + 1 }; + return { recall: hit.length / q.golden.length, all: hit.length === q.golden.length, any: hit.length > 0, top3: firstRank >= 0 && firstRank < 3, hit, alt, rank: firstRank < 0 ? null : firstRank + 1 }; } // 인덱스를 먼저 만든다 (첫 질문 시간에 인덱싱이 섞이지 않게) @@ -116,47 +175,65 @@ console.log(`프로젝트 파일 ${files.length}개, 질문 ${questions.length} lanternRetrieve("warmup"); const rows = questions.map((q, i) => { + if (q.parent) { + checkout(q.parent); + files = readFiles(); + } const a = grepRetrieve(q.q); + const s = grepSnippets(q.q); const b = lanternRetrieve(q.q); const sa = score(a.files, q); + const ss = score(s.files, q); const sb = score(b.files, q); - console.log(`${String(i + 1).padStart(2)}. 키워드 ${Math.round(sa.recall * 100)}% · Lantern ${Math.round(sb.recall * 100)}% — ${q.q}`); - return { q: q.q, golden: q.golden, grep: { ...a, ...sa }, lantern: { ...b, ...sb } }; + const chance = chanceAny(b.files.length, files.length, q.golden.length); + console.log(`${String(i + 1).padStart(2)}. 키워드 ${Math.round(sa.recall * 100)}% · 키워드(조각) ${Math.round(ss.recall * 100)}% · Lantern ${Math.round(sb.recall * 100)}% — ${q.q}`); + return { q: q.q, golden: q.golden, commit: q.commit, grep: { ...a, ...sa }, snippets: { ...s, ...ss }, lantern: { ...b, ...sb }, chance }; }); +if (original) checkout(original); const mean = (k, f) => rows.reduce((n, r) => n + f(r[k]), 0) / rows.length; const pct = (x) => `${Math.round(x * 100)}%`; -const summary = { - grep: { recall: mean("grep", (x) => x.recall), all: mean("grep", (x) => (x.all ? 1 : 0)), any: mean("grep", (x) => (x.any ? 1 : 0)), tokens: mean("grep", (x) => x.used), files: mean("grep", (x) => x.files.length) }, - lantern: { recall: mean("lantern", (x) => x.recall), all: mean("lantern", (x) => (x.all ? 1 : 0)), any: mean("lantern", (x) => (x.any ? 1 : 0)), tokens: mean("lantern", (x) => x.used), files: mean("lantern", (x) => x.files.length) }, -}; -const wins = rows.filter((r) => r.lantern.recall > r.grep.recall).length; -const losses = rows.filter((r) => r.lantern.recall < r.grep.recall).length; +const sum = (k) => ({ + recall: mean(k, (x) => x.recall), + all: mean(k, (x) => (x.all ? 1 : 0)), + any: mean(k, (x) => (x.any ? 1 : 0)), + top3: mean(k, (x) => (x.top3 ? 1 : 0)), + tokens: mean(k, (x) => x.used), + files: mean(k, (x) => x.files.length), +}); +const summary = { grep: sum("grep"), snippets: sum("snippets"), lantern: sum("lantern"), chance: rows.reduce((n, r) => n + r.chance, 0) / rows.length }; +const wins = rows.filter((r) => r.lantern.recall > Math.max(r.grep.recall, r.snippets.recall)).length; +const losses = rows.filter((r) => r.lantern.recall < Math.max(r.grep.recall, r.snippets.recall)).length; fs.mkdirSync(OUT, { recursive: true }); -const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, "-"); -fs.writeFileSync(path.join(OUT, `retrieval-${stamp}.json`), JSON.stringify({ project: path.basename(PROJECT), budget: BUDGET, summary, rows }, null, 2)); +const stamp = `${path.basename(PROJECT)}${LANG === "ko" ? "-ko" : ""}-${new Date().toISOString().slice(0, 16).replace(/[:T]/g, "-")}`; +fs.writeFileSync(path.join(OUT, `retrieval-${stamp}.json`), JSON.stringify({ project: path.basename(PROJECT), lang: LANG, budget: BUDGET, summary, rows }, null, 2)); const md = [ `# 맥락 적중률 (${stamp})`, "", - `프로젝트 \`${path.basename(PROJECT)}\` · 코드 파일 ${files.length}개 · 질문 ${rows.length}개 · 예산 ${BUDGET} 토큰 · 모델 호출 없음`, + `프로젝트 \`${path.basename(PROJECT)}\` · 코드 파일 ${files.length}개 · 질문 ${rows.length}개${LANG === "ko" ? " (한국어)" : ""} · 예산 ${BUDGET} 토큰 · 모델 호출 없음`, "", - "정답 파일: 질문에 답하려면 꼭 봐야 하는 파일 (`eval/golden-*.json`). 같은 예산에서 각 방식이 정답 파일을 몇 개 가져오는지 잰다.", + "정답 파일: 질문에 답하려면 꼭 봐야 하는 파일. 같은 예산에서 각 방식이 정답 파일을 몇 개 가져오는지 잰다.", + "- 키워드 검색: 질문의 단어로 grep → 많이 맞은 파일부터 통째로 읽기", + "- 키워드 검색 (조각): 같은 grep에서 맞은 줄 앞뒤 12줄만 가져와 예산을 여러 파일에 나누기", + "- 앞 3개 파일: 가져온 파일 중 처음 3개 안에 정답 파일이 있는가 (많이 가져올수록 유리한 효과를 뺀 지표)", + "- 무작위 기준: Lantern이 가져온 것과 같은 수의 파일을 무작위로 골랐을 때 정답을 하나라도 가져올 확률", "", - "| 지표 | 키워드 검색 | Lantern |", - "|---|---|---|", - `| 정답 파일 적중률 (평균) | ${pct(summary.grep.recall)} | ${pct(summary.lantern.recall)} |`, - `| 정답 파일을 모두 가져온 질문 | ${pct(summary.grep.all)} | ${pct(summary.lantern.all)} |`, - `| 정답 파일을 하나라도 가져온 질문 | ${pct(summary.grep.any)} | ${pct(summary.lantern.any)} |`, - `| 평균 사용 토큰 | ${Math.round(summary.grep.tokens)} | ${Math.round(summary.lantern.tokens)} |`, - `| 평균 가져온 파일 수 | ${summary.grep.files.toFixed(1)} | ${summary.lantern.files.toFixed(1)} |`, - `| Lantern 승 / 무 / 패 | | ${wins} / ${rows.length - wins - losses} / ${losses} |`, + "| 지표 | 키워드 검색 | 키워드 (조각) | Lantern |", + "|---|---|---|---|", + `| 정답 파일 적중률 (평균) | ${pct(summary.grep.recall)} | ${pct(summary.snippets.recall)} | ${pct(summary.lantern.recall)} |`, + `| 정답 파일을 모두 가져온 질문 | ${pct(summary.grep.all)} | ${pct(summary.snippets.all)} | ${pct(summary.lantern.all)} |`, + `| 정답 파일을 하나라도 가져온 질문 | ${pct(summary.grep.any)} | ${pct(summary.snippets.any)} | ${pct(summary.lantern.any)} (무작위 ${pct(summary.chance)}) |`, + `| 앞 3개 파일에 정답 | ${pct(summary.grep.top3)} | ${pct(summary.snippets.top3)} | ${pct(summary.lantern.top3)} |`, + `| 평균 사용 토큰 | ${Math.round(summary.grep.tokens)} | ${Math.round(summary.snippets.tokens)} | ${Math.round(summary.lantern.tokens)} |`, + `| 평균 가져온 파일 수 | ${summary.grep.files.toFixed(1)} | ${summary.snippets.files.toFixed(1)} | ${summary.lantern.files.toFixed(1)} |`, + `| Lantern 승 / 무 / 패 (두 키워드 방식 중 나은 쪽과) | | | ${wins} / ${rows.length - wins - losses} / ${losses} |`, "", "## 질문별", "", - "| # | 질문 | 정답 파일 | 키워드 | Lantern | Lantern 첫 정답 순위 |", - "|---|---|---|---|---|---|", - ...rows.map((r, i) => `| ${i + 1} | ${r.q} | ${r.golden.map((g) => `\`${g.split("/").pop()}\``).join(", ")} | ${pct(r.grep.recall)} | ${pct(r.lantern.recall)} | ${r.lantern.rank ?? "-"} |`), + "| # | 질문 | 정답 파일 | 키워드 | 키워드 (조각) | Lantern | Lantern 첫 정답 순위 |", + "|---|---|---|---|---|---|---|", + ...rows.map((r, i) => `| ${i + 1} | ${r.q} | ${r.golden.map((g) => `\`${g.split("/").pop()}\``).join(", ")} | ${pct(r.grep.recall)} | ${pct(r.snippets.recall)} | ${pct(r.lantern.recall)} | ${r.lantern.rank ?? "-"} |`), "", "## 놓친 정답 파일", "", @@ -167,4 +244,5 @@ const md = [ ].join("\n"); const mdPath = path.join(OUT, `retrieval-${stamp}.md`); fs.writeFileSync(mdPath, md); -console.log(`\n키워드 ${pct(summary.grep.recall)} vs Lantern ${pct(summary.lantern.recall)} (정답 파일 적중률)\n보고서: ${mdPath}`); +console.log(`\n정답 파일 적중률: 키워드 ${pct(summary.grep.recall)} · 키워드(조각) ${pct(summary.snippets.recall)} · Lantern ${pct(summary.lantern.recall)} (무작위 ${pct(summary.chance)})`); +console.log(`앞 3개 파일에 정답: 키워드 ${pct(summary.grep.top3)} · 키워드(조각) ${pct(summary.snippets.top3)} · Lantern ${pct(summary.lantern.top3)}\n보고서: ${mdPath}`); diff --git "a/eval/\352\262\260\352\263\274.md" "b/eval/\352\262\260\352\263\274.md" index 484991e..f37a7a9 100644 --- "a/eval/\352\262\260\352\263\274.md" +++ "b/eval/\352\262\260\352\263\274.md" @@ -1,5 +1,7 @@ # Phase 0 검증 결과 +> 누구나 재현할 수 있는 공개 벤치마크(오픈소스 저장소 3개, 실제 커밋 60개) 결과는 [bench/결과.md](bench/결과.md)에 있습니다. 아래는 개발 초기에 비공개 프로젝트로 잰 기록입니다. + > 비공개 프로젝트(JellySafe)로 잰 결과입니다. 정답 파일 목록(`golden-jellysafe.json`), 질문 파일, 보고서(`eval/results/`)에는 그 프로젝트의 파일 경로가 들어 있어 공개 저장소에 올리지 않았습니다. 같은 방법으로 직접 재는 법은 [README.md](README.md). ## 1. 정량 (자동, `lantern bench`)