Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .bazelrc
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ common --color=yes
test --test_output=errors

# Lint configuration
build:lint --aspects=//tools/lint:linters.bzl%ktlint,//tools/lint:linters.bzl%buildifier
build:lint --aspects=//tools/lint:linters.bzl%detekt,//tools/lint:linters.bzl%buildifier
build:lint --output_groups=rules_lint_human
build:lint --@aspect_rules_lint//lint:fail_on_violation
1 change: 0 additions & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ trim_trailing_whitespace = true
[*.{kt,kts}]
indent_size = 4
max_line_length = 120
ktlint_code_style = intellij_idea
ij_kotlin_allow_trailing_comma = true
ij_kotlin_allow_trailing_comma_on_call_site = true

Expand Down
70 changes: 70 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Calque — Agent Operational Contract

## Verification Gates (Non-Negotiable)
Before proposing, committing, or opening a PR for any change, the agent MUST run and verify 0 errors on all three gates:
1. `bazelisk run //:format.check` (Auto-fix with `bazelisk run //:format` if needed)
2. `bazelisk build --config=lint //...` (Detekt + Buildifier via custom Bazel aspect)
3. `bazelisk test //...` (Pure JVM unit tests)

## Git Commit Guidelines
Before creating a commit, always:
1. Run formatters and linters: `bazelisk run //:format` and verify with `bazelisk run //:format.check` and `bazelisk build --config=lint //...`.
2. Run and verify that all relevant unit tests pass (`bazelisk test //...`).

### Commit Message
When creating a commit message, follow these guidelines:
- **Title:** Use a concise title. Prefix the title with `[LLM]` (e.g., `[LLM] Implement algorithmic cognate detector with unit tests`).
- **Description:** The description should include a short description of the issue (bug, feature-request, crash, chore, etc.) and a short description of the solution.
- **No AI attribution:** Do not add AI agent attribution or signatures to commit messages (e.g., "By: Claude", "Generated by AI", "Antigravity", etc.).

## Tests
- **When asked to suggest tests for a function or file:**
- Do not implement anything or suggest how to implement.
- Look only at the code and suggest tests based on functionality and error cases.
- Identify the "happy path" (core functionality) cases and mark them as such in your suggestions.
- Identify the error cases and mark them as such in your suggestions. Estimate importance based on likelihood.
- Identify the edge cases and mark them as such in your suggestions. Estimate importance based on likelihood.
- **When implementing tests:**
- For Kotlin, the test file name follows the pattern `[OriginalFileName]Test.kt` (e.g., `CognateDetectorTest.kt`).
- For Java, the test file name follows the pattern `[OriginalFileName]Test.java`.
- Prefer creating fakes over mocks or patches. But, if it is simpler to patch or mock, do that.
- Core logic under `core/*` must test against standard JVM targets (`kt_jvm_test`) without Android dependencies.
- **Running Tests:**
- Run all tests in the workspace: `bazelisk test //...`
- Run tests for a specific target: `bazelisk test //core/linguistics:cognate_detector_test`

## Naming
Use inclusive language when creating variables, functions, class names, stubs, test fixtures, etc.:
- Do not use "dummy", instead use "fake", "mock", "noop", etc.
- Do not use "blacklist", instead use "disallow-list".
- Do not use "whitelist", instead use "allow-list".
- Stay away from: "master", "slave", "insane", "dumb", etc.
- Use gender-neutral pronouns.

## Prohibitions ("NEVER")
- **NEVER** use Gradle (`gradle`, `./gradlew`, `build.gradle.kts`, etc.). Calque is 100% Bazel managed with `bazelisk`.
- **NEVER** import `android.*` inside `core/*`. Core packages (`//core/linguistics`, `//core/data`, `//core/domain`) are pure JVM to ensure ultra-fast testing and clean modularity. Android dependencies belong exclusively under `//app/*`.
- **NEVER** re-introduce `ktlint` or its rules. Code formatting is owned solely by `ktfmt` (`--kotlinlang-style`, 120 chars) and static analysis is owned by `detekt` (`detekt.yml`).
- **NEVER** commit undocumented or untested linguistic/data algorithms. Every parser, analyzer, detector, or converter must have corresponding unit tests.
- **NEVER** push over HTTPS token if workflow scope fails; use SSH (`git@github.com:AnySoftKeyboard/calque.git`).
- **NEVER** create large, multi-concept PRs. Break down features into small, scoped, reviewable steps. Use stacked PRs (`gh pr create --base <parent> --head <feature>`).
- **NEVER** forget to register every created or updated PR with the MCP `link_pull_request` tool (including all layers in a stack).

## Directory Roles & Architecture
- `core/linguistics/`: Linguistic algorithms, cognate detection, morphology, phonetic distance, dictionary tokenization.
- `core/data/`: Database models, SQLite / binary dictionary stores, Trie indexing, file ingestion.
- `core/domain/`: End-to-end suggestion engine, scoring pipelines, bilingual query dispatcher.
- `app/`: Android InputMethodService, IME keyboards, configuration UI, Android lifecycle hooks.
- `tools/`: Bazel custom aspects (`detekt.bzl`), formatting configs (`ktfmt`, `buildifier`), CI workflows.
- `docs/`: All architecture and domain documentation (e.g. `docs/INGESTION.md`, `docs/ARCHITECTURE.md`).

## Standard Workflow for New Features
1. **Branch**: Create a feature branch off the appropriate base branch.
2. **Implement**: Write code conforming to Kotlin idioms and project constraints (avoid magic numbers, keep functions focused).
3. **Test**: Write comprehensive JUnit tests for all logic following the naming conventions.
4. **Format & Lint**:
- `bazelisk run //:format`
- `bazelisk run //:format.check`
- `bazelisk build --config=lint //...`
- `bazelisk test //...`
5. **PR & Link**: Commit (following commit message guidelines with `[LLM]` prefix and no attribution), push to origin via SSH, open PR via `gh pr create`, and immediately link via `link_pull_request`.
2 changes: 1 addition & 1 deletion BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package(default_visibility = ["//visibility:public"])

exports_files([
".editorconfig",
"ktlint-baseline.xml",
"detekt.yml",
])

alias(
Expand Down
7 changes: 2 additions & 5 deletions MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ bazel_dep(name = "buildifier_prebuilt", version = "10.1.0", dev_dependency = Tru
maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven")
maven.install(
artifacts = [
"io.gitlab.arturbosch.detekt:detekt-cli:1.23.8",
"junit:junit:4.13.2",
"org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0",
"org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0",
Expand All @@ -27,11 +28,7 @@ maven.install(
)
use_repo(maven, "maven")

# Lint & Format tools
# Formatting tools
format_tools = use_extension("@aspect_rules_lint//format:extensions.bzl", "tools")
format_tools.ktfmt()
use_repo(format_tools, "ktfmt")

lint_tools = use_extension("@aspect_rules_lint//lint:extensions.bzl", "tools")
lint_tools.ktlint()
use_repo(lint_tools, "com_github_pinterest_ktlint")
25 changes: 0 additions & 25 deletions MODULE.bazel.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import kotlin.math.min
* Levenshtein distance on word stems.
*/
object CognateDetector {
private const val MIN_WORD_LENGTH = 2
private const val MIN_EXACT_MATCH_LENGTH = 3
private const val MIN_STEM_LENGTH = 2
private const val MIN_FUZZY_STEM_LENGTH = 3
private const val MAX_FUZZY_DISTANCE = 1

/** Suffix conversion pairs between Spanish and English. */
private val SUFFIX_MAP: List<Pair<String, String>> =
listOf(
Expand Down Expand Up @@ -60,7 +66,7 @@ object CognateDetector {
*/
fun detectCognate(spanishWord: String, englishGloss: String): Boolean {
val es = normalize(spanishWord)
if (es.length <= 2) return false
if (es.length <= MIN_WORD_LENGTH) return false

val candidates = extractCandidateWords(englishGloss)
if (candidates.isEmpty()) return false
Expand All @@ -73,38 +79,32 @@ object CognateDetector {
val es = normalize(spanishWord)
val en = normalize(englishWord)

if (es.length <= 2 || en.length <= 2) return false
if (es.length <= MIN_WORD_LENGTH || en.length <= MIN_WORD_LENGTH) return false

// Direct identical match for words > 3 characters (e.g. "natural" == "natural")
if (es.length > 3 && es == en) {
if (es.length > MIN_EXACT_MATCH_LENGTH && es == en) {
return true
}

// Direct match after accent normalization (e.g. "café" -> "cafe")
val esUnaccented = stripAccents(es)
if (esUnaccented.length > 3 && esUnaccented == en) {
if (esUnaccented.length > MIN_EXACT_MATCH_LENGTH && esUnaccented == en) {
return true
}

// Suffix mapping and stem comparison
for ((esSuffix, enSuffix) in SUFFIX_MAP) {
return SUFFIX_MAP.any { (esSuffix, enSuffix) ->
if (es.endsWith(esSuffix) && en.endsWith(enSuffix)) {
val esStem = stripAccents(es.removeSuffix(esSuffix))
val enStem = en.removeSuffix(enSuffix)

// Exact match on short stems (e.g. "nación" -> "na" == "nation" -> "na")
if (esStem == enStem && esStem.length >= 2) {
return true
}

// Levenshtein distance <= 1 for stems of length >= 3
if (esStem.length >= 3 && levenshteinDistance(esStem, enStem) <= 1) {
return true
}
(esStem == enStem && esStem.length >= MIN_STEM_LENGTH) ||
(esStem.length >= MIN_FUZZY_STEM_LENGTH &&
levenshteinDistance(esStem, enStem) <= MAX_FUZZY_DISTANCE)
} else {
false
}
}

return false
}

/** Normalizes a word by lowercasing and trimming whitespace and non-letter edge characters. */
Expand Down Expand Up @@ -138,7 +138,7 @@ object CognateDetector {
val words = WORD_TOKEN_REGEX.findAll(withoutParticle).map { it.value }
for (word in words) {
val normalized = normalize(word)
if (normalized.length >= 2 && normalized !in result) {
if (normalized.length >= MIN_WORD_LENGTH && normalized !in result) {
result.add(normalized)
}
}
Expand Down
Loading
Loading