diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86d2356..656b27f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,28 @@ jobs: key: qmd-models-v1 - run: npx vitest run + e2e: + name: E2E Tests + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + - run: npm install + - name: Cache qmd models + uses: actions/cache@v4 + with: + path: ~/.cache/qmd/models + key: qmd-models-v1 + # qmd refuses LLM ops when CI=true; node-llama-cpp rejects empty CI via + # env-var.asBool. Unset CI so both see it as absent (qmd default: enabled, + # node-llama-cpp default: "false"). + - run: | + unset CI + npx vitest run --config vitest.e2e.config.ts + install-npm-global: name: Test npm global install runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 9fd521f..f4e69fa 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ coverage # logs logs +!test/e2e/corpus/logs _.log report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json diff --git a/test/e2e/corpus/logs/cooking-diary.md b/test/e2e/corpus/logs/cooking-diary.md new file mode 100644 index 0000000..96e46eb --- /dev/null +++ b/test/e2e/corpus/logs/cooking-diary.md @@ -0,0 +1,39 @@ +--- +title: Cooking Diary +created: 2024-01-01T08:00:00.000Z +modified: 2024-03-01T08:00:00.000Z +tags: ["cooking", "diary"] +type: log +--- + +## 2024-02-27T19:30:00.000Z {#e-c001000000000001} + +Made carbonara tonight — proper Roman style with guanciale and pecorino romano. The sauce broke the first attempt because I added the egg mixture while the pan was still on high heat. Started over, removed the pan from the heat, let it cool for a minute, then added the egg-pecorino paste with a splash of pasta water. Tossed vigorously — silky and coating. The guanciale was key; pancetta I tried last month didn't have the same fat quality or depth of flavor. + +## 2024-02-22T20:00:00.000Z {#e-c001000000000002} + +Third attempt at sourdough this week. Finally nailed the bulk fermentation timing — 4.5 hours at 74°F kitchen temperature. The dough doubled and had visible bubbles on the surface. Shaping has improved; the banneton left a nice pattern. Baked in the Dutch oven: 500°F covered 20 minutes, then 450°F uncovered 22 minutes. The crust crackled as it cooled. Open crumb — probably 75% hydration was right for this flour. + +## 2024-02-16T19:00:00.000Z {#e-c001000000000003} + +Beef stock from roasted bones. Used knuckle bones and marrow bones, roasted at 425°F for 50 minutes — they came out a deep mahogany. Deglazed the pan to get the fond. Simmered for 8 hours with mirepoix, parsley stems, and bay leaves. Skimmed foam three times. Strained through cheesecloth. The stock set to a very firm jelly in the fridge. Concentrated, rich, and slightly gelatinous — exactly right. + +## 2024-02-10T18:30:00.000Z {#e-c001000000000004} + +Tried sous vide strip steak for the first time. Set the bath to 131°F for 2 hours. Finished in a screaming hot cast iron skillet with clarified butter for 60 seconds per side. Crust was beautiful but I should have dried the steak more thoroughly after the bath — there was a bit of steaming before the Maillard reaction took hold. The interior was perfectly edge-to-edge medium-rare. No grey band at all. + +## 2024-02-04T20:00:00.000Z {#e-c001000000000005} + +Knife skills practice — julienne carrots and zucchini. My julienne strips are still uneven (2–4mm range). The issue is the guiding hand: my knuckles aren't staying consistent. Need to be more conscious of the spacing. The cuts on the zucchini looked better than the carrots because the zucchini is softer and more forgiving. Will practice this every Sunday until it's automatic. + +## 2024-01-28T19:30:00.000Z {#e-c001000000000006} + +Chicken stock attempt. Used a carcass from a roasted chicken plus extra backs and necks. Did not roast this time — made a white stock. Brought to a simmer slowly and skimmed extensively. Simmered 4 hours. The stock has good body (gelatinous) and clean flavor. Better than my previous attempts where I boiled rather than simmered — that produced a greasy, cloudy result. + +## 2024-01-20T13:00:00.000Z {#e-c001000000000007} + +Practiced sourdough scoring — the slash before baking that controls oven spring direction. Used a lame (razor blade on a stick) at a 30-degree angle to the surface rather than perpendicular. The ear rose beautifully. Previous loaves had flat, blunt scores because I was holding the blade straight up. The angled slash creates an overhang that lifts as a flap during the steam phase. + +## 2024-01-15T20:00:00.000Z {#e-c001000000000008} + +Experimented with pasta dough hydration. Standard recipe is 100g 00 flour to 1 egg yolk. Tried adding a tablespoon of olive oil — the pasta was silkier but harder to roll thin without tearing. Reverted to the simpler recipe. Also tried the Marcella Hazan approach of 100g flour to 1 whole egg (no oil): slightly stiffer dough, rolled out fine to pasta sheets, held up better in the boiling water. diff --git a/test/e2e/corpus/logs/dev-journal.md b/test/e2e/corpus/logs/dev-journal.md new file mode 100644 index 0000000..8048d19 --- /dev/null +++ b/test/e2e/corpus/logs/dev-journal.md @@ -0,0 +1,47 @@ +--- +title: Dev Journal +created: 2024-01-01T08:00:00.000Z +modified: 2024-03-01T08:00:00.000Z +tags: ["dev", "journal"] +type: log +--- + +## 2024-02-28T14:00:00.000Z {#e-d001000000000001} + +Traced a subtle Rust lifetime bug today. The issue was that a reference to a temporary was being held across an await point. The fix was to clone the value before the async block. Lifetimes and async in Rust interact in non-obvious ways because the compiler cannot allow references to live in the state machine across suspension points if they point to stack memory that may not exist when the task resumes. + +## 2024-02-25T10:00:00.000Z {#e-d001000000000002} + +Spent the morning profiling a slow database query. The culprit was a missing index on the foreign key column in a JOIN. Adding the index dropped query time from 800ms to 12ms. EXPLAIN ANALYZE in PostgreSQL is invaluable — always run it before assuming you know where the bottleneck is. + +## 2024-02-20T09:30:00.000Z {#e-d001000000000003} + +Learned about Go's `sync.Map` today. It is designed for cases where entries are written once and read many times, or when goroutines access disjoint keys. For most use cases, a regular map with a `sync.RWMutex` is simpler and more flexible. The sync.Map documentation explicitly says it should not be used as a general-purpose concurrent map replacement. + +## 2024-02-15T16:00:00.000Z {#e-d001000000000004} + +Debugged a race condition in a Node.js service. Two async functions were both checking-and-then-setting a value in Redis without a lock. The fix was a Lua script in Redis that performs the check-and-set atomically. Redis processes Lua scripts in a single-threaded atomic operation — no interleaving is possible. + +## 2024-02-10T11:00:00.000Z {#e-d001000000000005} + +Explored WebAssembly (Wasm) for running compute-intensive code in the browser. The main insight: Wasm does not automatically make code faster than JavaScript for most tasks. It shines when: (1) porting existing C/C++/Rust code, (2) running tight numeric loops that the JS JIT cannot optimize as well, (3) avoiding GC pauses in latency-sensitive code. + +## 2024-02-05T13:00:00.000Z {#e-d001000000000006} + +Investigated CSS container queries. Unlike media queries that respond to the viewport, container queries respond to the parent container's size. This makes reusable components truly self-contained — the component adapts to its context, not the global viewport. Browser support is now strong enough for production use. + +## 2024-01-30T09:00:00.000Z {#e-d001000000000007} + +Learned that Python's `asyncio.gather` runs coroutines concurrently but not in parallel (still single-threaded). For CPU-bound work, you need `ProcessPoolExecutor` via `loop.run_in_executor`. The distinction between concurrency (managing multiple tasks) and parallelism (running them simultaneously on multiple cores) is often blurred in Python documentation. + +## 2024-01-25T14:30:00.000Z {#e-d001000000000008} + +Discovered the `git bisect` command properly today. Binary search through commits to find the one that introduced a bug. Start with `git bisect start`, mark the bad commit, mark a known good commit, and then run your test script. Git checks out the midpoint commit each time. Saves enormous time compared to manually checking commits. + +## 2024-01-20T10:00:00.000Z {#e-d001000000000009} + +Explored eBPF for observability. Linux's eBPF allows attaching small sandboxed programs to kernel events (system calls, network packets, function calls) without kernel modules or reboots. Tools like Cilium, Falco, and bpftrace are built on it. The main limitation is the verifier — programs must be provably terminating and memory-safe. + +## 2024-01-15T08:00:00.000Z {#e-d001000000000010} + +Fixed a memory leak in a C++ application. The issue was a `shared_ptr` cycle: object A held a `shared_ptr` to B, and B held a `shared_ptr` back to A. Neither reference count ever reached zero. The fix was to make one of the back-pointers a `weak_ptr`. The `weak_ptr` does not increment the reference count and must be promoted to `shared_ptr` before use. diff --git a/test/e2e/corpus/logs/reading-log.md b/test/e2e/corpus/logs/reading-log.md new file mode 100644 index 0000000..6b005a2 --- /dev/null +++ b/test/e2e/corpus/logs/reading-log.md @@ -0,0 +1,39 @@ +--- +title: Reading Log +created: 2024-01-01T08:00:00.000Z +modified: 2024-03-01T08:00:00.000Z +tags: ["reading", "books"] +type: log +--- + +## 2024-02-20T20:00:00.000Z {#e-r001000000000001} + +**The Art of Doing Science and Engineering** — Richard Hamming. A series of lectures on how to have a great career in technical fields. The central argument: you need to study successes more than failures, develop good taste in problems, and work on important problems rather than merely tractable ones. The chapter on learning to learn is the most practically useful. Hamming argues that the way you think about a problem determines what solutions you can find — changing your mental model is more powerful than adding information. + +## 2024-02-12T21:00:00.000Z {#e-r001000000000002} + +**Thinking, Fast and Slow** — Daniel Kahneman. The dual-process theory of cognition: System 1 (fast, automatic, heuristic) and System 2 (slow, deliberate, analytical). Most interesting to me was the section on cognitive biases as features rather than bugs — they are energy-saving heuristics that fail under specific conditions. The chapter on anchoring has permanently changed how I approach salary negotiations and pricing. + +## 2024-02-05T19:30:00.000Z {#e-r001000000000003} + +**A Pattern Language** — Christopher Alexander. A catalog of architectural and urban design patterns at every scale — from room proportions to city streets. Alexander argues that certain spatial configurations repeatedly produce buildings and places that feel alive and comfortable to humans, and these are not a matter of taste but of objective measurable qualities. The concept of pattern languages directly inspired the software design patterns movement. A beautiful and unusual book. + +## 2024-01-28T20:00:00.000Z {#e-r001000000000004} + +**The Design of Everyday Things** — Don Norman. The seminal book on affordances, signifiers, and feedback loops in product design. The central insight: when something is hard to use, the fault is almost always the design, not the user. Every bad UI I encounter now has a Norman critique attached to it in my head. Conceptual models and the gulf of evaluation/execution are frameworks I use constantly. + +## 2024-01-20T19:00:00.000Z {#e-r001000000000005} + +**The Pragmatic Programmer** — Hunt and Thomas. One of the books that shaped how I think about software craftsmanship. The DRY principle, tracer bullets, broken windows — all from here. The most lasting idea: your career is your responsibility, not your employer's. Invest in your skills deliberately. Re-reading it now reveals how much I had absorbed without knowing where it came from. + +## 2024-01-12T21:00:00.000Z {#e-r001000000000006} + +**Range** — David Epstein. A counterargument to the 10,000-hour rule and early specialization. Epstein argues that breadth of experience — especially early in a career — produces better long-term outcomes in complex, "wicked" learning environments where feedback is delayed and rules change. Tiger Woods-style early specialization works in well-defined domains; most of life is a kind of-learning environment where range is an advantage. + +## 2024-01-05T20:30:00.000Z {#e-r001000000000007} + +**Hackers and Painters** — Paul Graham. A collection of essays on creativity, programming, and startups. The essay "Beating the Averages" is the famous Lisp advocacy piece. The most interesting essay for me was "How to Make Wealth" — the argument that startups are a compressed way to do decades of economic value creation in a few years. Worth reading for the intellectual honesty even where you disagree with the conclusions. + +## 2024-01-02T19:00:00.000Z {#e-r001000000000008} + +**Why We Sleep** — Matthew Walker. A comprehensive case for the importance of sleep backed by neuroscience. The most alarming finding: sleeping less than 6 hours per night for two weeks produces cognitive impairment equivalent to 24 hours of total sleep deprivation, but subjects rate themselves as only slightly impaired. We are catastrophically bad at self-assessing sleep deprivation. The sections on memory consolidation during slow-wave and REM sleep were new to me and changed how I think about when to study. diff --git a/test/e2e/corpus/logs/workout-log.md b/test/e2e/corpus/logs/workout-log.md new file mode 100644 index 0000000..75e4606 --- /dev/null +++ b/test/e2e/corpus/logs/workout-log.md @@ -0,0 +1,47 @@ +--- +title: Workout Log +created: 2024-01-01T08:00:00.000Z +modified: 2024-03-01T08:00:00.000Z +tags: ["fitness", "workout"] +type: log +--- + +## 2024-02-29T07:00:00.000Z {#e-w001000000000001} + +Long run: 18 miles at easy pace (9:30/mile average). Felt strong through mile 14, then the usual fatigue hit. Took two gels at miles 7 and 13. Hip flexors tight at the end — need to stretch more consistently. Weather was cold (38°F) and windy; wore a light jacket and gloves. Recovery drink immediately after. Legs feel heavy but not damaged. + +## 2024-02-26T06:30:00.000Z {#e-w001000000000002} + +Strength session — lower body focus. Deadlifts: 5×5 at 225 lbs. Hip hinge felt solid, kept the bar close throughout. Moved to Romanian deadlifts 3×10 at 155 lbs for hamstring isolation. Finished with Bulgarian split squats 3×8 each leg at bodyweight — balance is improving. No lower back discomfort during or after. PR on the conventional deadlift last week was 265 lbs, this week maintaining volume. + +## 2024-02-22T07:30:00.000Z {#e-w001000000000003} + +Tempo run: 8 miles with 5 miles at marathon goal pace (8:00/mile). Used a heart rate monitor — aerobic threshold is around 155 bpm for me. Held 152–158 bpm through the tempo section, which felt controlled but uncomfortable. Warm-up and cool-down 1.5 miles each at easy pace. Legs felt springy; good session overall. + +## 2024-02-18T11:00:00.000Z {#e-w001000000000004} + +Yoga session — 60 minutes of Vinyasa flow. Sun salutations to warm up, then held warrior sequences and pigeon pose for 2 minutes each side. Chaturanga has improved significantly; no longer collapsing in the shoulders. The instructor cued to engage the serratus anterior — I could feel the difference immediately. Hip flexors loosened considerably by the end. + +## 2024-02-14T07:00:00.000Z {#e-w001000000000005} + +Track workout: 6×800m at 5k pace (3:30 each), 90-second recovery between intervals. Splits: 3:28, 3:31, 3:29, 3:33, 3:32, 3:27 (negative split on the last one). Breathing felt labored from rep 3 onward. VO2 max training is uncomfortable but the adaptations are worth it. Total volume: 2.5 miles warm-up/cool-down plus 3 miles of intervals. + +## 2024-02-10T08:00:00.000Z {#e-w001000000000006} + +Climbing session at the gym — 2 hours. Warmed up on 5.10s, then worked a 5.12a project (roof section with sustained crimps). Managed to link the crux sequence twice but couldn't complete the full route. Ring finger felt slightly tender after — backed off immediately. Did 30 minutes of antagonist training: rubber band extensions, wrist curls, and reverse wrist curls. + +## 2024-02-06T07:00:00.000Z {#e-w001000000000007} + +Recovery run: 5 miles very easy (10:30/mile). Cadence drill — focused on keeping turnover above 170 steps per minute. Cadence work reduces ground contact time and overstriding injuries. Felt sluggish for the first two miles then loosened up. No aches; ready for the long run Saturday. + +## 2024-02-02T06:30:00.000Z {#e-w001000000000008} + +Upper body strength session. Overhead press: 4×6 at 95 lbs. Bench press: 4×8 at 155 lbs. Pull-ups: 4×8 (weighted, +20 lbs belt). Dumbbell rows: 3×12 each side at 65 lbs. Finished with face pulls and band pull-aparts for shoulder health. Pressing strength is slowly recovering since the focus shifted to marathon training. + +## 2024-01-28T08:00:00.000Z {#e-w001000000000009} + +Long run: 16 miles. First 10 miles with a running partner at 9:45/mile, last 6 alone at 9:15/mile (negative split). Practicing finishing strong. Consumed 3 gels and a pack of chews. Stomach handled it fine — no GI issues this time. Had moderate discomfort between miles 13–15 which is normal for this distance. + +## 2024-01-20T07:00:00.000Z {#e-w001000000000010} + +Stair climbing workout — 45 minutes on the StairMaster at moderate resistance. Supplements running without the impact. Heart rate held at 145–155 bpm throughout. Good aerobic stimulus on a recovery week. Followed by 20 minutes of stretching: hip flexors, hamstrings, IT band, calves. Foam rolled quads and glutes. diff --git a/test/e2e/corpus/notes/cooking/knife-skills-julienne.md b/test/e2e/corpus/notes/cooking/knife-skills-julienne.md new file mode 100644 index 0000000..604c453 --- /dev/null +++ b/test/e2e/corpus/notes/cooking/knife-skills-julienne.md @@ -0,0 +1,40 @@ +--- +title: Knife Skills — Julienne Cuts +created: 2024-01-18T09:00:00.000Z +modified: 2024-01-18T09:00:00.000Z +tags: ["cooking", "knife", "technique"] +type: note +--- + +Julienne is a knife cut that produces thin matchstick strips, classically 2–3mm × 2–3mm × 5–6cm. Mastering it requires correct knife grip, stable board technique, and a sharp blade. + +## Knife Grip + +Use the "pinch grip": thumb and index finger pinch the blade just above the bolster, with the other three fingers wrapped around the handle. This grip provides control and reduces fatigue compared to holding only the handle. + +The guiding hand uses the "claw grip": fingertips curled under, knuckles forward, touching the flat of the blade. The knuckles guide the blade and protect the fingertips. + +## Stabilizing the Vegetable + +Round vegetables must be flattened before julienning. Cut a thin slice off one side to create a stable flat surface. Work with the flat side down — the vegetable won't rock. + +## Julienne Sequence (carrots) + +1. Peel and trim the carrot into a rectangle (or cut into 5–6cm segments). +2. Slice lengthwise into planks 2–3mm thick, keeping them even. +3. Stack the planks and slice lengthwise again into 2–3mm strips. +4. The result is uniform matchsticks. + +## Board Technique + +A damp paper towel under the cutting board prevents it from sliding. Position the board close to the edge of the counter so your elbow can drop naturally. Choppy, vertical knife movements tire the arm; use a rocking or push-pull motion depending on what you're cutting. + +## Keeping Cuts Uniform + +Uniformity matters for even cooking, not just aesthetics. If half the strips are thick and half thin, some will be undercooked and others overcooked. Slow down before you speed up — consistent 2mm slices require attention to the guiding hand's spacing, not speed. + +## Brunoise + +A brunoise is julienne cut further: stack the julienne strips and cut crosswise into 2–3mm cubes. It is the finest dice in classical French knife work and is used for mirepoix, consommé garnishes, and fine brunoise sauces. + +A sharp knife makes every cut safer and more precise. A dull blade requires more force, increasing the chance of slipping. Hone before each session and sharpen regularly. diff --git a/test/e2e/corpus/notes/cooking/pasta-carbonara.md b/test/e2e/corpus/notes/cooking/pasta-carbonara.md new file mode 100644 index 0000000..2faa3a4 --- /dev/null +++ b/test/e2e/corpus/notes/cooking/pasta-carbonara.md @@ -0,0 +1,36 @@ +--- +title: Pasta Carbonara +created: 2024-01-15T09:00:00.000Z +modified: 2024-01-15T09:00:00.000Z +tags: ["cooking", "pasta", "italian"] +type: note +--- + +Carbonara is a Roman pasta dish built on four ingredients: guanciale, pecorino romano, eggs, and black pepper. The sauce is an emulsion of egg yolk, grated pecorino, and rendered guanciale fat — no cream, no garlic, no onion. + +## Ingredients (2 servings) + +- 200g spaghetti or rigatoni +- 150g guanciale (cured pork cheek), cut into lardons +- 3 egg yolks + 1 whole egg +- 60g pecorino romano, finely grated (plus more to serve) +- Freshly cracked black pepper, generous amount +- Kosher salt for pasta water + +## Technique + +**Render the guanciale**: Place guanciale in a cold pan, then bring to medium heat. The fat renders slowly, and the lardons become golden and crispy on the outside while remaining slightly soft inside. Remove the pan from heat when done; let it cool somewhat. + +**Make the egg mixture**: Beat the egg yolks and whole egg with the grated pecorino and a good amount of black pepper. The mixture should be thick — almost a paste. This is the heart of the sauce. + +**Cook the pasta**: Boil pasta in heavily salted water until 1-2 minutes before al dente. Reserve a large cup of starchy pasta water before draining. + +**Combine**: Add the pasta to the guanciale pan (off heat). Add a splash of pasta water. Add the egg-pecorino mixture and toss vigorously. The residual heat from the pasta and pan cooks the eggs gently; you are aiming for a creamy coating, not scrambled eggs. Add more pasta water if the sauce is too thick. + +## Common Mistakes + +Carbonara breaks when the pan is too hot — the eggs scramble instead of forming a silky sauce. The guanciale fat is the primary emulsifier; don't drain it. Pancetta can substitute for guanciale but lacks the same depth. Parmesan is a common substitution but purists insist on pecorino romano. + +## Serving + +Plate immediately, twist pasta into a mound, and finish with more grated pecorino and a final crack of black pepper. Carbonara does not keep; make and eat it right away. diff --git a/test/e2e/corpus/notes/cooking/sourdough-basics.md b/test/e2e/corpus/notes/cooking/sourdough-basics.md new file mode 100644 index 0000000..cc80d7e --- /dev/null +++ b/test/e2e/corpus/notes/cooking/sourdough-basics.md @@ -0,0 +1,35 @@ +--- +title: Sourdough Basics +created: 2024-01-16T09:00:00.000Z +modified: 2024-01-16T09:00:00.000Z +tags: ["cooking", "baking", "bread"] +type: note +--- + +Sourdough bread is leavened by a living culture of wild yeast and lactic acid bacteria rather than commercial yeast. The starter is the engine; understanding it is the foundation of all sourdough baking. + +## The Starter + +A sourdough starter is a fermented mixture of flour and water. The wild yeast (primarily Saccharomyces cerevisiae and Kazachstania humilis) produces CO₂ that leavens the bread. The bacteria (mainly Lactobacillus species) produce lactic and acetic acids that give sourdough its characteristic tang. + +Feed the starter daily or weekly depending on storage temperature. A healthy starter doubles in volume within 4–8 hours of feeding and smells pleasantly sour, not rotten. + +## Hydration + +Hydration is the ratio of water to flour in a dough, expressed as a percentage. A 75% hydration dough has 750g of water per 1000g of flour. Higher hydration produces a more open, irregular crumb but is harder to shape. Beginners should start at 65–70% hydration and increase as their technique improves. + +A stiff starter (50–60% hydration) produces more acetic acid (sharp, vinegary tang). A liquid starter (100–125% hydration) produces more lactic acid (milder, yogurt-like flavor). + +## Bulk Fermentation + +After mixing, the dough undergoes bulk fermentation: the yeast and bacteria colonize the entire mass. During bulk fermentation, perform stretch-and-fold sets every 30 minutes for the first 2 hours. This develops gluten structure without mechanical kneading. + +Bulk fermentation is complete when the dough has grown 50–75%, feels airy and jiggly, and shows small bubbles on the surface. Time depends heavily on ambient temperature: 4–5 hours at 78°F, 6–8 hours at 72°F. + +## Shaping and Proofing + +After bulk fermentation, pre-shape the dough, rest 20–30 minutes, then do a final shape. Place in a floured banneton (proofing basket) and cold-proof in the refrigerator for 8–16 hours. Cold proofing slows fermentation and makes the dough easier to score. + +## Baking + +Bake in a Dutch oven preheated to 500°F (260°C). Baking covered for the first 20 minutes traps steam, keeping the crust soft and allowing oven spring. Remove the lid and bake another 20–25 minutes until the crust is deep brown. diff --git a/test/e2e/corpus/notes/cooking/sous-vide-steak.md b/test/e2e/corpus/notes/cooking/sous-vide-steak.md new file mode 100644 index 0000000..37ce321 --- /dev/null +++ b/test/e2e/corpus/notes/cooking/sous-vide-steak.md @@ -0,0 +1,39 @@ +--- +title: Sous Vide Steak +created: 2024-01-17T09:00:00.000Z +modified: 2024-01-17T09:00:00.000Z +tags: ["cooking", "steak", "sous-vide"] +type: note +--- + +Sous vide (French: "under vacuum") is a cooking technique where food is sealed in a bag and cooked in a precisely temperature-controlled water bath. For steak, it guarantees edge-to-edge doneness that is impossible to achieve consistently on a grill or pan alone. + +## Temperature and Doneness + +The internal temperature determines doneness: +- 120–125°F (49–52°C): Rare — bright red center, very soft +- 130–135°F (54–57°C): Medium-rare — warm red center, juicy and tender +- 140–145°F (60–63°C): Medium — pink center, firmer +- 150°F+ (66°C+): Medium-well to well-done — little pink, can become dry + +Medium-rare at 130°F is the most popular target for most cuts. + +## Time Guidelines + +- Thin steaks (under 1 inch / 2.5cm): 1–2 hours +- Standard steaks (1–1.5 inches / 2.5–4cm): 1–4 hours +- Thick cuts (1.5–2.5 inches / 4–6cm): 2–4 hours + +Cooking longer than necessary (beyond about 4 hours at medium-rare) can cause the texture to become mushy as enzymes continue to break down the muscle fibers. + +## Setup + +Season the steak with salt, pepper, and optionally aromatics (thyme, rosemary, garlic). Seal in a vacuum bag or zip-lock using the water displacement method. Lower into the water bath at the target temperature. + +## Finishing Sear + +Sous vide steak is perfectly cooked internally but lacks crust. The Maillard reaction — the browning that develops flavor and texture — requires temperatures above 280°F (138°C) that the water bath never reaches. After the bath, pat the steak completely dry. Sear in a cast-iron skillet over maximum heat with a high-smoke-point oil (avocado, clarified butter) for 45–90 seconds per side, basting with butter. A hot grill works equally well. + +## Resting + +Sous vide steak needs minimal resting compared to traditionally cooked steak because the entire interior is already at the target temperature. A 2–3 minute rest while searing the second side is sufficient. diff --git a/test/e2e/corpus/notes/cooking/stock-from-bones.md b/test/e2e/corpus/notes/cooking/stock-from-bones.md new file mode 100644 index 0000000..c04e6fd --- /dev/null +++ b/test/e2e/corpus/notes/cooking/stock-from-bones.md @@ -0,0 +1,41 @@ +--- +title: Making Stock from Bones +created: 2024-01-19T09:00:00.000Z +modified: 2024-01-19T09:00:00.000Z +tags: ["cooking", "stock", "broth"] +type: note +--- + +Good stock is the foundation of classical cooking. A well-made bone stock is rich with gelatin, collagen, and mineral-laden marrow. Most home cooks under-roast and under-simmer their bones. + +## Roasting + +Roasting before simmering adds depth through the Maillard reaction on the bone surface and marrow. Spread bones in a single layer on a sheet pan — crowded bones steam instead of roast. Roast at 425°F (220°C) for 45–60 minutes, turning once, until deep mahogany brown. Pale or gray bones produce pale, flat stock. + +Tomato paste spread on the bones in the last 15 minutes of roasting adds umami and helps color. + +## Blanching + +For very clear stock, blanch bones first: cover with cold water, bring to a boil, boil 5 minutes, then drain and rinse. This purges blood and proteins that cause cloudiness. Skip blanching for brown stock (the roasting achieves similar cleansing). + +## Aromatics and Mirepoix + +Onion, carrot, and celery in a 2:1:1 ratio form the classic mirepoix. Add leek tops, parsley stems, bay leaves, thyme, and black peppercorns. Avoid starchy vegetables (potato) and strong aromatics (garlic in large quantities) that can overpower a neutral stock. + +## Simmering + +Cover bones with cold water. Bring slowly to a bare simmer — bubbles barely breaking the surface. A rolling boil emulsifies fat into the stock, making it cloudy and greasy. Skim grey foam frequently in the first 30 minutes. + +Simmer times by stock type: +- Chicken: 3–4 hours +- Beef/veal: 6–8 hours (up to 12 for demi-glace) +- Fish: 20–30 minutes (over-simmering makes fish stock bitter) +- Vegetable: 45–60 minutes + +## Straining and Cooling + +Strain through a fine-mesh sieve, then through cheesecloth for extra clarity. Cool quickly by setting the pot in an ice bath. Once cool, refrigerate overnight; skim the solidified fat cap off the top. + +## Gelatin + +A good beef or veal stock sets to a jelly when chilled. This gelatin (from collagen in connective tissue and cartilage) provides body and mouthfeel. If stock stays liquid when cold, simmer it down further to concentrate. diff --git a/test/e2e/corpus/notes/fitness/climbing-crimp-technique.md b/test/e2e/corpus/notes/fitness/climbing-crimp-technique.md new file mode 100644 index 0000000..7a69fb1 --- /dev/null +++ b/test/e2e/corpus/notes/fitness/climbing-crimp-technique.md @@ -0,0 +1,35 @@ +--- +title: Rock Climbing Crimp Technique +created: 2024-01-28T09:00:00.000Z +modified: 2024-01-28T09:00:00.000Z +tags: ["climbing", "fitness", "technique"] +type: note +--- + +Crimping is the most common hand position in rock climbing, particularly on technical sport and bouldering routes. It is also the most injury-prone grip, responsible for most finger pulley injuries. + +## Crimp Grips + +**Full crimp**: The fingers are highly flexed at the first knuckle (DIP joint) and the thumb wraps over the index finger. This provides maximum friction and is the strongest grip, but places extreme stress on the A2 flexor pulley in the ring and middle fingers. + +**Half crimp**: The DIP joint is slightly flexed rather than hyperextended. No thumb wrap. Slightly weaker than full crimp but significantly safer — less shear force on the pulleys. + +**Open hand**: The fingers remain relatively extended, contacting the hold with the pads rather than the tips. The weakest grip in the short term but the healthiest for tendons. Consistent open-hand training develops the grip over time without the injury risk of crimping. + +## Injury Prevention + +The A2 pulley (second annular pulley of the flexor tendon sheath) is the most commonly injured climbing structure. A full pulley rupture is an audible or felt "pop" in the finger, immediate pain, and loss of grip strength. Partial tears are more insidious — mild swelling and ache that worsen if ignored. + +**Prevention strategies**: +- Warm up thoroughly before projecting hard routes (30+ minutes of easier climbing) +- Avoid full crimping on very small holds in your first year +- Progress finger load gradually; don't jump straight to campus board training +- Stop if you feel acute pain in a finger during a session + +## Antagonist Training + +Crimping heavily loads the finger flexors. Extensor exercises (rubber band finger extensions, wrist roller, reverse wrist curls) balance the strength ratio and reduce injury risk. Many experienced climbers incorporate extensor work as a daily habit. + +## Footwork + +Poor footwork forces over-reliance on hand strength. Precise toe placement on footholds — smearing the rubber to maximize friction, trusting edging shoes on small features — reduces grip demands and conserves forearm strength. diff --git a/test/e2e/corpus/notes/fitness/deadlift-form.md b/test/e2e/corpus/notes/fitness/deadlift-form.md new file mode 100644 index 0000000..c6e0de4 --- /dev/null +++ b/test/e2e/corpus/notes/fitness/deadlift-form.md @@ -0,0 +1,40 @@ +--- +title: Deadlift Form +created: 2024-01-26T09:00:00.000Z +modified: 2024-01-26T09:00:00.000Z +tags: ["fitness", "weightlifting", "strength"] +type: note +--- + +The deadlift is a foundational hip-hinge movement. Done well, it builds the posterior chain — glutes, hamstrings, and spinal erectors. Done poorly, it loads the lumbar spine unsafely. + +## Setup + +**Foot position**: Bar over mid-foot, about 1 inch from the shins. Feet hip-width apart, toes slightly flared (15–30°). + +**Grip**: Hinge at the hips and push them back to reach the bar. Use a double overhand grip (both palms facing you) as long as possible; switch to a mixed grip (one palm each direction) for heavier sets to prevent bar roll. Hook grip is used by competitive lifters and powerlifters for maximum security. + +**Bar-body relationship**: Pull the bar close to the shins throughout the lift. Allowing the bar to drift forward increases the moment arm on the lower back dramatically. + +## Bracing + +Before initiating the pull, take a deep diaphragmatic breath into your belly (not your chest), brace your entire core — not just your abs but obliques and spinal erectors — as if bracing for a punch. This creates intra-abdominal pressure (IAP) that stabilizes the lumbar spine. A lifting belt does not replace proper bracing; it provides a wall for your core to brace against. + +## The Hip Hinge + +The deadlift is a hip hinge, not a squat. The hips should be above the knees but below the shoulders at the start. Drive through the heels, extending the hips and knees simultaneously. The bar travels in a straight vertical line — any lateral deviation wastes energy. + +## Lockout + +At the top, stand tall: hips fully extended, shoulders back, glutes squeezed. Do not hyperextend the lumbar spine at lockout. + +## Lowering the Bar + +Reverse the movement: push hips back first, then bend the knees once the bar passes them. Eccentric control is important for muscle development but don't grind it on the way down. + +## Common Errors + +- Rounding the lower back (loss of lumbar curve under load) +- Bar too far from the body on initiation +- Looking up excessively (cervical hyperextension) +- "Stripper deadlift" — hips rising faster than shoulders at the start, turning the lift into a stiff-leg deadlift diff --git a/test/e2e/corpus/notes/fitness/marathon-training-plan.md b/test/e2e/corpus/notes/fitness/marathon-training-plan.md new file mode 100644 index 0000000..d13d1fb --- /dev/null +++ b/test/e2e/corpus/notes/fitness/marathon-training-plan.md @@ -0,0 +1,37 @@ +--- +title: Marathon Training Plan +created: 2024-01-25T09:00:00.000Z +modified: 2024-01-25T09:00:00.000Z +tags: ["fitness", "running", "marathon"] +type: note +--- + +A marathon is 26.2 miles (42.2 km). Training for one requires progressive mileage buildup over 16–20 weeks, followed by a taper to arrive at the start line fresh and ready. + +## Weekly Mileage Progression + +Base training builds aerobic capacity and musculoskeletal resilience. Increase total weekly mileage by no more than 10% per week to avoid injury. A typical 18-week plan for a first-time marathoner might look like: + +- Weeks 1–4: 20–30 miles/week, establishing easy running habits +- Weeks 5–8: 30–40 miles/week, introducing one medium-long run midweek +- Weeks 9–12: 40–50 miles/week, peaking long runs at 18–22 miles +- Weeks 13–16: Back-to-back quality weeks, last long run week 15 or 16 +- Weeks 17–18: Taper — mileage drops 30–40%, then 50–60% final week + +## The Long Run + +The weekly long run is the cornerstone of marathon preparation. Run it at a conversational pace — 60–90 seconds per mile slower than goal marathon pace. It builds fat-burning capacity, trains the body to use glycogen efficiently, and conditions tendons and joints for high mileage. + +Peak long runs of 20–22 miles are standard. Runs longer than 22 miles provide diminishing returns and excessive recovery time. + +## The Taper + +The taper is the 2–3 week reduction in mileage before race day. It allows muscles to repair micro-damage accumulated over training, glycogen stores to top off, and fatigue to dissipate. Many runners experience "taper madness" — anxiety and phantom aches during the taper. This is normal. + +## Pacing Strategy + +Start 10–15 seconds per mile slower than goal pace for the first 10 miles. The marathon wall (when glycogen stores deplete) typically hits around mile 18–20 in under-fueled runners. A conservative early pace plus regular gel intake (every 45 minutes starting at mile 5) prevents the worst of it. + +## Recovery + +Two key principles: sleep (8+ hours nightly) and easy days genuinely easy (80% of runs at conversational pace). Overtraining causes more DNFs than under-training. diff --git a/test/e2e/corpus/notes/fitness/yoga-sun-salutation.md b/test/e2e/corpus/notes/fitness/yoga-sun-salutation.md new file mode 100644 index 0000000..52f7672 --- /dev/null +++ b/test/e2e/corpus/notes/fitness/yoga-sun-salutation.md @@ -0,0 +1,41 @@ +--- +title: Yoga — Sun Salutation (Surya Namaskar) +created: 2024-01-27T09:00:00.000Z +modified: 2024-01-27T09:00:00.000Z +tags: ["yoga", "fitness", "flexibility"] +type: note +--- + +Surya Namaskar (Sun Salutation) is a sequence of twelve asanas (postures) performed in a flowing chain synchronized with the breath. It is a complete practice in itself: it strengthens, stretches, and builds cardiovascular endurance. + +## The Sequence (Surya Namaskar A) + +1. **Tadasana** (Mountain Pose) — Stand tall, feet together, palms at heart center. +2. **Urdhva Hastasana** (Upward Salute) — Inhale, sweep arms overhead, slight backbend. +3. **Uttanasana** (Standing Forward Fold) — Exhale, fold forward, hands to floor. +4. **Ardha Uttanasana** (Half Lift) — Inhale, lengthen spine, flat back. +5. **Chaturanga Dandasana** (Four-Limbed Staff) — Exhale, step or jump back, lower to low push-up. +6. **Urdhva Mukha Svanasana** (Upward-Facing Dog) — Inhale, press chest forward, thighs off floor. +7. **Adho Mukha Svanasana** (Downward-Facing Dog) — Exhale, push back and up, hold 5 breaths. +8. **Ardha Uttanasana** — Inhale, step forward, half lift. +9. **Uttanasana** — Exhale, forward fold. +10. **Urdhva Hastasana** — Inhale, sweep arms up. +11. **Tadasana** — Exhale, palms to heart center. + +## Breath Synchronization + +Every movement in the flow links to either an inhale or exhale. Inhales accompany expansive, opening movements (backbends, lifts); exhales accompany contracting, folding movements. Breathing leads the movement — the body follows. + +## Alignment Notes + +**Chaturanga**: The elbows track back past the ribs, not flared out to the sides. Shoulders should not dip below elbow level. This is where most beginners compensate by collapsing. + +**Downward Dog**: Heels pressing toward (not necessarily touching) the floor, hips high, spine long. Pedaling the heels alternately loosens the calves. + +## Modifications + +Beginners can keep knees on the floor during Chaturanga or substitute a slow lowering to the ground. Upward Dog can be replaced with Bhujangasana (Cobra Pose) for students with wrist issues. + +## Practice Tips + +Five rounds warm up the body thoroughly. Twelve rounds constitute a moderate cardiovascular practice. The sequence is traditionally performed facing east at sunrise. diff --git a/test/e2e/corpus/notes/home/beeswax-wood-finish.md b/test/e2e/corpus/notes/home/beeswax-wood-finish.md new file mode 100644 index 0000000..83b3df8 --- /dev/null +++ b/test/e2e/corpus/notes/home/beeswax-wood-finish.md @@ -0,0 +1,40 @@ +--- +title: Beeswax Wood Finish +created: 2024-01-31T09:00:00.000Z +modified: 2024-01-31T09:00:00.000Z +tags: ["woodworking", "home", "finishing"] +type: note +--- + +Beeswax is one of the oldest wood finishing materials, prized for its low toxicity, pleasant smell, and warm satin sheen. It does not form a hard film like polyurethane; instead it penetrates the wood surface and provides a soft, renewable coating. + +## Preparation + +Sand the wood to 220 grit, removing any previous finish if needed. A clean, open-grain surface accepts beeswax better than one clogged with old finish. Wipe away sanding dust with a tack cloth or damp rag. + +## Applying Beeswax + +Beeswax can be applied as a solid block, paste, or dissolved in mineral spirits (liquid wax). For solid or paste wax: + +1. Work a small amount onto a lint-free cloth or steel wool (0000 grade for a very smooth surface). +2. Apply in circular motions, working into the grain. +3. Allow 5–10 minutes for the solvents to evaporate (if using paste wax) or for the wax to harden slightly. +4. Buff vigorously with a clean cloth in the direction of the grain. The friction generates heat that drives the wax deeper and polishes the surface. + +Apply thin coats; thick coats are harder to buff and leave a smeared appearance. + +## Building a Finish + +A single coat provides minimal protection. Two to three coats, each fully buffed before the next is applied, build a more durable surface. Allow 24 hours between coats. + +## Refreshing + +Unlike film finishes, beeswax is easy to refresh: lightly clean the surface, apply a thin new coat, and buff. There is no adhesion issue and no need to strip and reapply. High-traffic surfaces like tabletops may benefit from refreshing every 6–12 months. + +## Limitations + +Beeswax does not protect well against water rings, heat, or solvent spills. It is not suitable for outdoor use or surfaces exposed to water regularly. For outdoor furniture or countertops that see wet glasses, a hardening oil (tung, danish) or polyurethane provides better protection. + +## Food Safety + +Pure beeswax is food safe and is commonly used on cutting boards, wooden utensils, and salad bowls alongside mineral oil. diff --git a/test/e2e/corpus/notes/home/gfci-outlet-wiring.md b/test/e2e/corpus/notes/home/gfci-outlet-wiring.md new file mode 100644 index 0000000..464e5f0 --- /dev/null +++ b/test/e2e/corpus/notes/home/gfci-outlet-wiring.md @@ -0,0 +1,35 @@ +--- +title: GFCI Outlet Wiring +created: 2024-01-30T09:00:00.000Z +modified: 2024-01-30T09:00:00.000Z +tags: ["electrical", "home", "diy"] +type: note +--- + +A Ground Fault Circuit Interrupter (GFCI) outlet monitors the difference in current between the hot and neutral conductors. If it detects an imbalance of 5 milliamps or more — indicating current is taking an unintended path, possibly through a person — it trips within 1/40th of a second, potentially saving a life. + +## When GFCI Is Required + +The National Electrical Code (NEC) requires GFCI protection in bathrooms, kitchens (countertop outlets), garages, outdoors, crawl spaces, unfinished basements, and within 6 feet of a sink or water source. + +## Terminals on a GFCI Outlet + +A GFCI outlet has two pairs of screw terminals: + +- **LINE terminals** (labeled LINE): These connect to the incoming circuit wires from the breaker panel. The hot wire connects to the brass LINE screw; the neutral wire connects to the silver LINE screw. +- **LOAD terminals** (labeled LOAD): These are used to protect downstream standard outlets on the same circuit. Connecting additional outlets to LOAD provides GFCI protection for those outlets too. + +If you are only protecting the single GFCI outlet (no downstream outlets), cap the LOAD terminals with the included caps and leave them unused. + +## Wiring Steps + +1. Turn off the circuit breaker. Verify the circuit is dead with a non-contact voltage tester. +2. Remove the old outlet and identify the hot (black), neutral (white), and ground (bare copper or green) wires. +3. Connect hot to the brass LINE terminal, neutral to the silver LINE terminal, and ground to the green grounding screw. +4. If protecting downstream outlets, run new wires from the LOAD terminals to the next outlet. +5. Carefully fold the wires into the box and mount the GFCI outlet. Install the cover plate. +6. Restore power and press the TEST button — the RESET button should pop out. Press RESET to restore power. + +## Testing + +Monthly testing is recommended: press TEST (outlet should go dead), then RESET (outlet should restore). A GFCI that does not trip on TEST or that trips without resetting needs replacement. diff --git a/test/e2e/corpus/notes/home/lawn-mower-carburetor.md b/test/e2e/corpus/notes/home/lawn-mower-carburetor.md new file mode 100644 index 0000000..a142071 --- /dev/null +++ b/test/e2e/corpus/notes/home/lawn-mower-carburetor.md @@ -0,0 +1,29 @@ +--- +title: Lawn Mower Carburetor Cleaning +created: 2024-02-01T09:00:00.000Z +modified: 2024-02-01T09:00:00.000Z +tags: ["small-engine", "home", "maintenance"] +type: note +--- + +The carburetor mixes air and fuel in the correct ratio for combustion. Dirty or gummed carburetors are the most common reason a lawn mower runs rough, surges, or won't start. Ethanol in modern pump gasoline is the primary culprit — it attracts moisture and leaves a varnish residue when it evaporates. + +## Ethanol Damage + +Gasoline with 10% ethanol (E10) or higher absorbs water from the atmosphere. Over a storage season, this water and ethanol mix separates from the fuel and settles at the bottom of the tank and float bowl. Ethanol-laden fuel also degrades rubber components (gaskets, diaphragms) faster than pure gasoline. + +**Prevention**: Drain fuel completely before winter storage, or use a fuel stabilizer (Sta-Bil or similar) added to a full tank. Ethanol-free gasoline at premium pumps dramatically extends carburetor life. + +## Cleaning Procedure + +1. **Remove the air filter** and set aside. Spray starting fluid briefly into the carburetor throat to confirm whether the engine will fire — if it runs briefly, the carburetor is starving for fuel. +2. **Turn off the fuel valve**. Disconnect the fuel line from the carburetor and catch drips. +3. **Remove the carburetor**: typically two bolts and the throttle/choke linkage. Take a phone photo before disassembly. +4. **Disassemble**: Remove the float bowl (one bolt on most Briggs & Stratton and Tecumseh units). Note the float level, needle, and main jet. +5. **Clean with carburetor cleaner**: Spray all passages, the main jet hole, and the emulsion tube holes. Use a thin wire or drill bit to clear clogged jet orifices. The main jet hole should be perfectly round; a varnish deposit will narrow it. +6. **Replace the bowl gasket** if it is cracked or soft. +7. **Reassemble**, reinstall, and test. + +## Identifying a Dirty Carburetor + +Symptoms: engine starts cold and dies warm, runs rough at idle, surges at constant throttle, black smoke (rich), hard starting despite fresh fuel and new spark plug. diff --git a/test/e2e/corpus/notes/home/sweating-copper-pipe.md b/test/e2e/corpus/notes/home/sweating-copper-pipe.md new file mode 100644 index 0000000..821b728 --- /dev/null +++ b/test/e2e/corpus/notes/home/sweating-copper-pipe.md @@ -0,0 +1,38 @@ +--- +title: Sweating Copper Pipe +created: 2024-01-29T09:00:00.000Z +modified: 2024-01-29T09:00:00.000Z +tags: ["plumbing", "home", "diy"] +type: note +--- + +Sweating (soldering) copper pipe is the standard method for making leak-free plumbing joints. It requires flux, solder, a propane torch, and clean pipe surfaces. + +## Tools and Materials + +- Propane or MAPP gas torch (MAPP burns hotter and speeds up the job) +- Lead-free solder (95/5 tin-antimony or 97/3 tin-copper for potable water lines) +- Flux paste (water-soluble, non-corrosive type for drinking water) +- Pipe cutter (gives a clean, square cut) +- Emery cloth or fitting brush to clean pipe ends and fittings +- Pipe preparation tool (deburring end of cut pipe) + +## Preparation + +1. Cut the pipe squarely with a pipe cutter. Rotate the cutter two full turns, then tighten the wheel, repeat until cut through. Avoid hacksaw cuts — they leave burrs and uneven ends. +2. Deburr the inside of the cut end with the built-in reamer on the pipe cutter. +3. Polish the outside of the pipe end with emery cloth until it is bright and shiny (about 1 inch from the end). +4. Polish the inside of the fitting socket with a fitting brush. +5. Apply a thin coat of flux paste to the cleaned pipe end and inside the fitting. Flux prevents oxidation during heating and helps solder flow. + +## Soldering + +1. Assemble the joint and push the pipe fully into the fitting. +2. Heat the joint with the torch, applying heat to the fitting body (not directly to the solder). The fitting stores more mass and needs more heat. +3. Touch the solder to the junction between pipe and fitting — not to the flame. When the joint is hot enough, the solder will melt on contact and be drawn into the joint by capillary action. +4. Feed solder around the full circumference of the joint until a small bead appears all the way around. +5. Remove the torch and let the joint cool without moving it for 60 seconds. + +## Common Mistakes + +Applying solder to a cold joint (flux burns off, solder blobs rather than wicks), moisture in the pipe (steam prevents a good joint), and overheating (solder runs instead of staying in the joint). diff --git a/test/e2e/corpus/notes/languages/german-strong-verbs.md b/test/e2e/corpus/notes/languages/german-strong-verbs.md new file mode 100644 index 0000000..8bfce20 --- /dev/null +++ b/test/e2e/corpus/notes/languages/german-strong-verbs.md @@ -0,0 +1,45 @@ +--- +title: German Strong Verbs and Ablaut +created: 2024-02-04T09:00:00.000Z +modified: 2024-02-04T09:00:00.000Z +tags: ["german", "grammar", "language"] +type: note +--- + +Strong verbs in German form their past tense and past participle through a vowel change in the stem — a process called ablaut — rather than by adding a regular -te ending. They correspond roughly to English "irregular" verbs like *sing/sang/sung*. + +## Principal Parts + +Every strong verb has three principal parts: the infinitive, the simple past (Präteritum), and the past participle. Knowing these three forms allows you to construct all other tenses. + +*fahren / fuhr / gefahren* (to drive) +*schreiben / schrieb / geschrieben* (to write) +*helfen / half / geholfen* (to help) +*sprechen / sprach / gesprochen* (to speak) +*kommen / kam / gekommen* (to come) +*gehen / ging / gegangen* (to go — highly irregular) +*sein / war / gewesen* (to be — most irregular) + +## Ablaut Classes + +Proto-Germanic inherited a systematic vowel gradation system from Proto-Indo-European. Modern German strong verbs are remnants of this system, grouped into ablaut classes based on their vowel patterns: + +- **Class I**: ei → ie/i → ie (schreiben, bleiben) +- **Class II**: ie/au → o → o (biegen, fliegen) +- **Class III**: i + consonant cluster → a → u (singen, trinken, finden) +- **Class IV/V**: e → a → o/e (helfen, sprechen, geben) +- **Class VI**: a → u → a (fahren, tragen) +- **Class VII**: various → ie/ie (halten, laufen, heißen) + +## Umlaut in Second/Third Person Singular + +Many strong verbs with a/au in the infinitive take an umlaut (ä/äu) in the second and third person singular present: +*fahren → du fährst, er fährt* +*laufen → du läufst, er läuft* +*tragen → du trägst, er trägt* + +## Mixed and Irregular Verbs + +Some verbs combine weak endings with a stem vowel change: *bringen → brachte → gebracht*, *denken → dachte → gedacht*. These are called "mixed" or Präteritopräsentia in some traditions. + +Memorizing the principal parts is unavoidable; patterns only get you so far. Regular exposure to written German, where strong verbs appear frequently in their Präteritum forms, is the fastest route to internalization. diff --git a/test/e2e/corpus/notes/languages/japanese-particles-wa-ga.md b/test/e2e/corpus/notes/languages/japanese-particles-wa-ga.md new file mode 100644 index 0000000..4a37da2 --- /dev/null +++ b/test/e2e/corpus/notes/languages/japanese-particles-wa-ga.md @@ -0,0 +1,38 @@ +--- +title: Japanese Particles wa and ga +created: 2024-02-03T09:00:00.000Z +modified: 2024-02-03T09:00:00.000Z +tags: ["japanese", "grammar", "language"] +type: note +--- + +The particles は (wa) and が (ga) are among the most discussed and confusing aspects of Japanese grammar. Both can mark the grammatical subject of a sentence, but they carry different pragmatic information about what the speaker is presenting as new versus assumed information. + +## は (wa) — The Topic Marker + +は marks the topic of the sentence — the thing the sentence is about, which is often already known or assumed in context. + +*田中さんは学生です。* (Tanaka-san wa gakusei desu.) — "As for Tanaka, [he/she] is a student." The sentence is *about* Tanaka. + +The topic (wa) does not have to be the grammatical subject; it can be an object or time expression that the speaker wants to foreground: *魚は食べます。* (Sakana wa tabemasu.) — "As for fish, I eat [it]." Fish is topicalized even though it's the direct object. + +## が (ga) — The Subject Marker + +が marks the grammatical subject, typically introducing it as new information or placing emphasis on who/what performed the action. + +*田中さんが学生です。* — "It is Tanaka who is a student." The emphasis is on the identity of the person who is the student. + +In a question-answer pair, が marks the requested information: *誰が来ましたか?* (Who came?) *田中さんが来ました。* (Tanaka came.) — Tanaka is the new, focused information. + +## Contrast: wa vs. ga in Practice + +**Neutral description**: は is used when introducing the topic in context. +**Exhaustive listing / exclusive focus**: が implies "this and not something else." *私が行きます。* (I will go — not someone else.) +**New information**: が introduces a subject appearing for the first time in discourse. +**Stative predicates**: が is often preferred with adjectives and stative verbs: *猫が好きです。* (I like cats) — the object of liking takes が. + +## Fixed ga Constructions + +Some potential and desire expressions require が: *日本語が話せます。* (I can speak Japanese), *水が飲みたい。* (I want to drink water). + +The distinction continues to challenge learners well into advanced study, as native speaker intuitions about information structure often differ from explicit grammatical rules. diff --git a/test/e2e/corpus/notes/languages/spanish-subjunctive.md b/test/e2e/corpus/notes/languages/spanish-subjunctive.md new file mode 100644 index 0000000..e67cd8c --- /dev/null +++ b/test/e2e/corpus/notes/languages/spanish-subjunctive.md @@ -0,0 +1,39 @@ +--- +title: Spanish Subjunctive +created: 2024-02-02T09:00:00.000Z +modified: 2024-02-02T09:00:00.000Z +tags: ["spanish", "grammar", "language"] +type: note +--- + +The subjunctive mood expresses doubt, wish, emotion, desire, or hypothetical situations. It is one of the most challenging aspects of Spanish grammar for English speakers because English barely uses the subjunctive and provides few reliable intuitions. + +## Triggers for the Subjunctive + +The subjunctive appears in subordinate clauses introduced by "que" (that) when the main clause expresses: + +**Wish / Desire**: *Quiero que vengas* (I want you to come — not "I want you come"). +**Emotion**: *Me alegra que estés aquí* (I'm glad you are here). +**Doubt / Negation**: *Dudo que sea verdad* (I doubt it's true). *No creo que llegue* (I don't think he'll arrive). +**Recommendation / Order**: *Te recomiendo que tomes notas* (I recommend you take notes). +**Impersonal expressions**: *Es importante que estudies* (It's important that you study). + +The WEIRDO acronym (Wish, Emotion, Impersonal, Recommendations, Doubt/Denial, Ojalá) covers most triggers. + +## Conjugation Patterns + +To form the present subjunctive: take the first person singular (yo) indicative, drop the -o, and add the "opposite" endings: +- -AR verbs take -E endings: *hablar → hablo → habl- → hable, hables, hable, hablemos, habléis, hablen* +- -ER/-IR verbs take -A endings: *comer → como → com- → coma, comas, coma, comamos, comáis, coman* + +Irregular yo forms carry into the subjunctive: *tener → tengo → teng- → tenga*, *hacer → hago → hag- → haga*. + +Completely irregular subjunctives: *ser → sea*, *ir → vaya*, *haber → haya*, *estar → esté*, *dar → dé*, *saber → sepa*. + +## Adverbial Clauses + +Certain conjunctions always trigger the subjunctive: *para que* (so that), *a menos que* (unless), *antes de que* (before), *con tal de que* (provided that), *a fin de que* (in order that). + +Others use subjunctive only when referring to future or hypothetical events: *cuando* (when), *mientras* (while), *en cuanto* (as soon as). + +*Espero que llegues cuando puedas.* (I hope you arrive when you can.) — both clauses use subjunctive. diff --git a/test/e2e/corpus/notes/music/guitar-sweep-picking.md b/test/e2e/corpus/notes/music/guitar-sweep-picking.md new file mode 100644 index 0000000..58ab49f --- /dev/null +++ b/test/e2e/corpus/notes/music/guitar-sweep-picking.md @@ -0,0 +1,44 @@ +--- +title: Guitar Sweep Picking +created: 2024-02-06T09:00:00.000Z +modified: 2024-02-06T09:00:00.000Z +tags: ["music", "guitar", "technique"] +type: note +--- + +Sweep picking is a guitar technique for playing arpeggios rapidly and efficiently by dragging the pick across multiple strings in one continuous motion rather than picking each string individually. It is closely associated with neoclassical and shred metal styles but appears in jazz and fusion as well. + +## The Mechanics + +Instead of alternating pick strokes on each string, sweep picking uses a single downstroke across multiple strings when descending in pitch and a single upstroke when ascending. The pick "rakes" or sweeps through the strings. + +The left hand (fretting hand) must mute each note the instant it is played — if notes sustain they blur into a chord. This "rolling" technique across the frets, where each finger lifts as the next lands, is what makes sweep picking sound like a fast, clean arpeggio rather than a strummed chord. + +## Economy Picking + +Economy picking is a related technique that blends alternate picking and sweep picking: when moving from one string to the next in the same direction, use a sweep instead of reversing the pick direction. It is less strict than pure sweep picking and is often faster for scale runs. + +## Arpeggio Shapes + +Three-string and five-string arpeggio shapes are the starting point. A common five-string minor arpeggio in A: + +``` +e: -------5-- +B: -----5---- +G: ---6------ +D: -7-------- +A: 7--------- +``` + +Each note is picked individually but with a continuous downward sweep. The pinky on the high string must pull off before the pick arrives. + +## Common Mistakes + +- Not muting previously played notes (causes muddy, chordal sound) +- Rushing the technique before the left hand can keep up +- Tense picking hand (should float with minimal wrist tension) +- Using too much pick depth (barely graze the strings) + +## Practice Approach + +Slow practice with a metronome is essential. At very slow tempos, each note should ring clearly and stop cleanly before the next sounds. Only increase speed when the muting technique is clean. A typical progression: 40 bpm → 60 → 80 → 100 → 120, checking clarity at each step. diff --git a/test/e2e/corpus/notes/music/jazz-ii-v-i-voicings.md b/test/e2e/corpus/notes/music/jazz-ii-v-i-voicings.md new file mode 100644 index 0000000..3a68a29 --- /dev/null +++ b/test/e2e/corpus/notes/music/jazz-ii-v-i-voicings.md @@ -0,0 +1,48 @@ +--- +title: Jazz II-V-I Voicings +created: 2024-02-05T09:00:00.000Z +modified: 2024-02-05T09:00:00.000Z +tags: ["music", "jazz", "piano", "harmony"] +type: note +--- + +The II-V-I progression (two-five-one) is the most important harmonic movement in jazz. It appears in almost every standard and provides the scaffolding for improvisation. Mastering rootless voicings and guide tone movement transforms how you comp behind a soloist. + +## The Progression in C Major + +- **IIm7**: Dm7 (D-F-A-C) +- **V7**: G7 (G-B-D-F) +- **Imaj7**: Cmaj7 (C-E-G-B) + +The tritone between B and F in G7 defines the dominant chord's function. This tritone resolves inward (B → C, F → E) to the third and seventh of Cmaj7 — a satisfying voice-leading resolution. + +## Guide Tones + +Guide tones are the third and seventh of each chord — the most harmonically defining intervals. In a II-V-I in C: +- Dm7: F (7th) and A (3rd) — or C (7th) and F (3rd) +- G7: B (3rd) and F (7th) +- Cmaj7: E (3rd) and B (7th) + +Notice that F (the 7th of Dm7) becomes F (the 7th of G7), and B (the 3rd of G7) becomes B (the 7th of Cmaj7). Guide tones connect smoothly with minimal movement — this is the foundation of good jazz voice leading. + +## Rootless Voicings + +In a band context, the bass player covers the root. Pianists use rootless voicings that contain the 3rd, 7th, and at least one color tone (9th, 11th, or 13th). + +**A-form voicings** (start with the 7th on the bottom): +- Dm7: C-F-A (7-3-5 or 7-3-9) +- G7: F-B-E (7-3-13) +- Cmaj7: B-E-A (7-3-6/13) + +**B-form voicings** (start with the 3rd on the bottom): +- Dm7: F-A-C (3-5-7) +- G7: B-F-A (3-7-9 or 3-7-13) +- Cmaj7: E-B-D (3-7-9) + +## Tritone Substitution + +G7 can be substituted by D♭7 — a tritone away. Both chords share the same tritone (B = C♭, the 7th of D♭7; F, the 3rd of D♭7). The D♭7 resolves chromatically to Cmaj7 by half-step, a smoother resolution that jazz pianists and arrangers exploit for harmonic color. + +## Practice Strategy + +Cycle II-V-I through all twelve keys, playing A-form and B-form voicings alternately in each key. Sing the guide tones while playing to internalize the harmonic movement. diff --git a/test/e2e/corpus/notes/music/recording-vocals-home.md b/test/e2e/corpus/notes/music/recording-vocals-home.md new file mode 100644 index 0000000..284c61e --- /dev/null +++ b/test/e2e/corpus/notes/music/recording-vocals-home.md @@ -0,0 +1,35 @@ +--- +title: Recording Vocals at Home +created: 2024-02-07T09:00:00.000Z +modified: 2024-02-07T09:00:00.000Z +tags: ["music", "recording", "vocals"] +type: note +--- + +Getting a clean vocal recording at home requires addressing three problems: the room's acoustic reflections, microphone choice and placement, and plosive control. Each has a cost-effective solution. + +## Room Treatment + +Untreated rooms add reflections that wash out the recorded vocal and make it difficult to mix. The minimum effective treatment is recording in a corner with heavy blankets or acoustic panels on the two walls and ceiling above. A large wardrobe full of clothes is a surprisingly good vocal booth substitute. + +Hardwood floors and glass windows are the worst offenders — avoid facing them while recording. Thick carpet and upholstered furniture absorb high-frequency reflections. + +## Microphone Selection + +A large-diaphragm condenser microphone (LDC) is the standard for vocals. It captures transient detail and air that dynamic mics miss. Popular budget options include the Audio-Technica AT2020, AKG P120, and Rode NT1. These require phantom power (+48V) supplied by an audio interface. + +Dynamic microphones (SM7B, SM58) tolerate louder vocalists and reject more room noise but lack the high-frequency detail of condensers. They are a good choice for untreated rooms. + +## Mic Placement + +The "sweet spot" for most LDC mics is 6–10 inches from the mouth, slightly above the lips and aimed slightly downward. This angle avoids the plosive blast directly on-axis and reduces proximity effect (low-frequency boost from close proximity). + +Off-axis recording (45–90° from the capsule) can tame a harsh-sounding mic or bright vocalist. + +## Pop Filter + +A pop filter (screen placed 3–5 cm in front of the mic) breaks up the burst of air from plosive consonants (P, B, T) that cause low-frequency spikes in the recording. Nylon mesh pop filters are cheap and effective. Without one, even minor P pops can saturate the recording and require extensive post-processing. + +## Gain Staging + +Set interface gain so that peaks hit around -12 to -18 dBFS — this leaves headroom for compression and unexpected loud notes. Recording too hot and clipping is unrecoverable; recording too quiet adds noise floor but can be fixed. diff --git a/test/e2e/corpus/notes/programming/javascript-promises.md b/test/e2e/corpus/notes/programming/javascript-promises.md new file mode 100644 index 0000000..b85636b --- /dev/null +++ b/test/e2e/corpus/notes/programming/javascript-promises.md @@ -0,0 +1,59 @@ +--- +title: JavaScript Promises and Async/Await +created: 2024-01-12T09:00:00.000Z +modified: 2024-01-12T09:00:00.000Z +tags: ["javascript", "async", "promises"] +type: note +--- + +Promises are the foundation of asynchronous programming in JavaScript. A Promise represents a value that may not be available yet — it is either pending, fulfilled (resolved), or rejected. + +## Creating Promises + +```javascript +const p = new Promise((resolve, reject) => { + setTimeout(() => resolve("done"), 1000); +}); +``` + +The executor function runs immediately and calls `resolve` or `reject` to settle the promise. + +## Chaining with .then and .catch + +`.then(onFulfilled, onRejected)` registers callbacks and returns a new Promise. `.catch(onRejected)` is shorthand for `.then(undefined, onRejected)`. Chains propagate values through each `.then`, and any thrown error jumps to the nearest `.catch`. + +```javascript +fetch(url) + .then(res => res.json()) + .then(data => process(data)) + .catch(err => console.error(err)); +``` + +## Async/Await Syntax + +The `async` keyword marks a function as returning a Promise. Inside an async function, `await` suspends execution until the awaited Promise settles. + +```javascript +async function loadUser(id) { + const res = await fetch(`/api/users/${id}`); + if (!res.ok) throw new Error("Not found"); + return res.json(); +} +``` + +Errors from rejected promises become thrown exceptions, catchable with `try/catch`. + +## Microtask Queue + +Promise callbacks run as microtasks — they execute after the current synchronous code but before the next macrotask (like a `setTimeout` callback). This guarantees ordering: all `.then` callbacks for already-settled promises fire before the event loop returns to the task queue. + +## Promise Combinators + +- `Promise.all([...])` — resolves when all inputs resolve; rejects immediately if any rejects. +- `Promise.allSettled([...])` — resolves when all inputs settle, regardless of outcome. +- `Promise.race([...])` — settles with the first input to settle. +- `Promise.any([...])` — resolves with the first fulfilled input; rejects only if all reject. + +## Common Mistakes + +Forgetting to `return` inside a `.then` callback silently passes `undefined` to the next handler. Not catching rejections leads to unhandled rejection warnings. Nesting `.then` inside `.then` (the "Promise pyramid") defeats the point of chaining — flatten chains instead. diff --git a/test/e2e/corpus/notes/programming/python-numpy-broadcasting.md b/test/e2e/corpus/notes/programming/python-numpy-broadcasting.md new file mode 100644 index 0000000..f8322cd --- /dev/null +++ b/test/e2e/corpus/notes/programming/python-numpy-broadcasting.md @@ -0,0 +1,39 @@ +--- +title: NumPy Broadcasting Rules +created: 2024-01-11T09:00:00.000Z +modified: 2024-01-11T09:00:00.000Z +tags: ["python", "numpy", "arrays"] +type: note +--- + +NumPy broadcasting is the mechanism that allows arithmetic operations between arrays of different shapes. Instead of requiring arrays to have identical shapes, NumPy automatically "broadcasts" smaller arrays to match larger ones — without copying data. + +## The Broadcasting Rules + +NumPy compares shapes element-wise, starting from the trailing dimension. Two dimensions are compatible if they are equal, or if one of them is 1. If dimensions are incompatible and neither is 1, the operation raises a `ValueError`. + +**Example**: A `(3, 4)` array and a `(4,)` array are compatible. The `(4,)` array is treated as `(1, 4)`, then broadcast across the 3 rows. The result has shape `(3, 4)`. + +**Example**: A `(3, 1)` array and a `(1, 4)` array broadcast together to produce a `(3, 4)` result. Each scalar in the first array is added to every element in the corresponding row of the second. + +## Shape Alignment + +When arrays have different numbers of dimensions, NumPy prepends 1s to the smaller shape until both have the same number of axes. A scalar (shape `()`) broadcasts against any array. + +```python +a = np.ones((2, 3, 4)) +b = np.ones((3, 4)) # treated as (1, 3, 4) +c = np.ones((4,)) # treated as (1, 1, 4) +``` + +## Memory Efficiency + +Broadcasting avoids creating intermediate copies. A `(1, 4)` array broadcast with a `(1000, 4)` ndarray does not allocate a `(1000, 4)` copy of the row — NumPy's stride tricks let the same memory row be "reused" virtually across all 1000 positions. + +## Common Pitfalls + +The most frequent mistake is confusing a `(n,)` array (rank-1) with a `(1, n)` row vector or `(n, 1)` column vector. Subtracting a row mean from a matrix requires reshaping: `matrix - row_means[:, np.newaxis]` creates a `(n, 1)` column vector that broadcasts correctly along axis 1. + +## Practical Uses + +Broadcasting is essential in machine learning pipelines: subtracting per-feature means, scaling by standard deviations, computing outer products, and applying element-wise activation functions over batches all rely on it. Mastering broadcasting eliminates most explicit `for` loops over array dimensions. diff --git a/test/e2e/corpus/notes/programming/regex-lookaround.md b/test/e2e/corpus/notes/programming/regex-lookaround.md new file mode 100644 index 0000000..755c6ad --- /dev/null +++ b/test/e2e/corpus/notes/programming/regex-lookaround.md @@ -0,0 +1,67 @@ +--- +title: Regex Lookaround Assertions +created: 2024-01-14T09:00:00.000Z +modified: 2024-01-14T09:00:00.000Z +tags: ["regex", "lookahead", "lookbehind"] +type: note +--- + +Lookaround assertions are zero-width regex constructs that match a position in the string based on what precedes or follows, without consuming characters. They are useful when you need to assert context without including it in the match. + +## Positive Lookahead `(?=...)` + +Asserts that the pattern inside must match at this position, looking forward. The assertion succeeds if the engine can match the sub-pattern to the right, but the characters matched by the lookahead are not consumed. + +```regex +\d+(?= dollars) +``` + +Matches a sequence of digits followed by " dollars", but the match itself is only the digits. + +## Negative Lookahead `(?!...)` + +Asserts that the pattern inside must NOT match at this position. Useful for excluding specific continuations. + +```regex +foo(?!bar) +``` + +Matches "foo" not followed by "bar". "foobar" does not match; "foobaz" does. + +## Positive Lookbehind `(?<=...)` + +Asserts that the pattern inside must match immediately to the left of the current position. The characters matched by the lookbehind are not consumed. + +```regex +(?<=\$)\d+ +``` + +Matches digits preceded by a dollar sign. The `$` is not included in the match. + +## Negative Lookbehind `(?', '', '...', 10) FROM docs WHERE docs MATCH 'query'; +``` + +This is useful for displaying search results with context around the matched terms. diff --git a/test/e2e/corpus/notes/science/photosynthesis-c4.md b/test/e2e/corpus/notes/science/photosynthesis-c4.md new file mode 100644 index 0000000..99cfb33 --- /dev/null +++ b/test/e2e/corpus/notes/science/photosynthesis-c4.md @@ -0,0 +1,36 @@ +--- +title: Photosynthesis and C4 Adaptation +created: 2024-01-22T09:00:00.000Z +modified: 2024-01-22T09:00:00.000Z +tags: ["biology", "plants", "photosynthesis"] +type: note +--- + +Photosynthesis converts light energy into chemical energy stored as glucose. C4 plants evolved a biochemical CO₂-concentrating mechanism that makes photosynthesis more efficient in hot, dry, or high-light environments. + +## The Calvin Cycle (C3 Pathway) + +In C3 plants, CO₂ is fixed directly by RuBisCO (ribulose-1,5-bisphosphate carboxylase/oxygenase), producing a three-carbon compound (3-phosphoglycerate). RuBisCO is the most abundant enzyme on Earth and the primary carbon fixation enzyme. + +RuBisCO has a fatal flaw: it also reacts with O₂ instead of CO₂ — a wasteful process called photorespiration. In hot conditions, photorespiration can consume 25–50% of the carbon fixed by the Calvin cycle. + +## The C4 Pathway + +C4 plants separate initial CO₂ fixation from the Calvin cycle spatially, using two cell types: mesophyll cells and bundle sheath cells. + +1. CO₂ enters mesophyll cells and is captured by PEP carboxylase (which has much higher affinity for CO₂ and cannot react with O₂) to form a four-carbon compound (oxaloacetate → malate or aspartate). +2. The four-carbon compound is transported to bundle sheath cells surrounding the vascular bundles. +3. There it is decarboxylated, releasing concentrated CO₂ directly around RuBisCO. +4. The three-carbon remnant returns to mesophyll cells to be regenerated. + +This CO₂ pump keeps RuBisCO saturated and essentially eliminates photorespiration. + +## C4 Plants and Their Advantages + +Examples: maize (corn), sugarcane, sorghum, and many grass species. These plants thrive in high-temperature, high-light conditions. Maize is so efficient partly because of C4 photosynthesis. + +C4 plants use water and nitrogen more efficiently than C3 plants under hot, sunny conditions. However, the C4 pathway requires extra ATP per carbon fixed, so in cool, low-light conditions, C3 plants have the advantage. + +## CAM Pathway + +A related adaptation, CAM (Crassulacean Acid Metabolism), also uses four-carbon intermediates but separates fixation and the Calvin cycle temporally: CO₂ is stored at night (when stomata are open) and released during the day (when stomata close to reduce water loss). Used by cacti and succulents. diff --git a/test/e2e/corpus/notes/science/plate-tectonics.md b/test/e2e/corpus/notes/science/plate-tectonics.md new file mode 100644 index 0000000..8e91ffe --- /dev/null +++ b/test/e2e/corpus/notes/science/plate-tectonics.md @@ -0,0 +1,29 @@ +--- +title: Plate Tectonics +created: 2024-01-23T09:00:00.000Z +modified: 2024-01-23T09:00:00.000Z +tags: ["geology", "earth", "tectonics"] +type: note +--- + +Plate tectonics describes the large-scale motion of Earth's lithosphere — the rigid outer shell consisting of the crust and uppermost mantle. The lithosphere is divided into about a dozen major plates and several minor ones that move relative to each other at rates of 2–15 cm per year. + +## Plate Boundaries + +**Convergent boundaries**: Two plates move toward each other. When an oceanic plate meets a continental plate, the denser oceanic plate subducts beneath the continent, sinking into the mantle. This subduction generates volcanoes (the Pacific "Ring of Fire"), deep-focus earthquakes, and oceanic trenches (the Mariana Trench reaches 11km depth). When two continental plates collide, neither subducts easily; crust crumples into mountain belts like the Himalayas. + +**Divergent boundaries**: Plates move apart. The mantle rises to fill the gap; at mid-ocean ridges, basaltic magma erupts to form new oceanic crust. The Mid-Atlantic Ridge is a divergent boundary spreading at ~2.5 cm/year. In continents, rifting can eventually split landmasses — the East African Rift is a modern example. + +**Transform boundaries**: Plates slide horizontally past each other. No crust is created or destroyed; instead, elastic strain accumulates until released as earthquakes. The San Andreas Fault in California is a right-lateral transform boundary between the Pacific Plate and the North American Plate. + +## Hotspots + +Some volcanic activity occurs far from plate boundaries, over mantle plumes — columns of unusually hot material rising from deep in the mantle. Hawaii sits atop a hotspot; as the Pacific Plate moves over it, a chain of increasingly older volcanic islands extends to the northwest. Yellowstone is another hotspot beneath a continental plate. + +## Driving Mechanism + +The primary driver of plate motion is slab pull: subducting slabs are denser and cooler than the surrounding mantle, and their weight drags the attached plate toward the subduction zone. Ridge push (magma pushing plates apart at spreading centers) is a secondary driver. + +## Paleogeography + +Plate tectonics explains the distribution of continents. Pangaea, the supercontinent, began rifting ~175 million years ago. The Atlantic Ocean formed as the Americas separated from Europe and Africa. diff --git a/test/e2e/corpus/notes/science/prime-number-sieves.md b/test/e2e/corpus/notes/science/prime-number-sieves.md new file mode 100644 index 0000000..be0543b --- /dev/null +++ b/test/e2e/corpus/notes/science/prime-number-sieves.md @@ -0,0 +1,50 @@ +--- +title: Prime Number Sieves +created: 2024-01-24T09:00:00.000Z +modified: 2024-01-24T09:00:00.000Z +tags: ["mathematics", "algorithms", "primes"] +type: note +--- + +A prime sieve is an algorithm for finding all prime numbers up to a limit by iteratively eliminating composite numbers. The classical algorithm is the Sieve of Eratosthenes; more advanced variants include the Sieve of Atkin and segmented sieves. + +## Sieve of Eratosthenes + +The simplest and most widely used prime sieve, with O(n log log n) time complexity. + +**Algorithm**: +1. Create a boolean array of size `n+1`, all initialized to `true`. +2. Starting from `p = 2`, mark all multiples of `p` (starting from `p²`) as composite. +3. Move to the next unmarked number (the next prime) and repeat. +4. Stop when `p² > n`. All remaining `true` entries are prime. + +```python +def sieve_of_eratosthenes(n): + is_prime = [True] * (n + 1) + is_prime[0] = is_prime[1] = False + for p in range(2, int(n**0.5) + 1): + if is_prime[p]: + for i in range(p*p, n+1, p): + is_prime[i] = False + return [i for i, v in enumerate(is_prime) if v] +``` + +Memory usage is O(n) bits. For n = 10⁸, the boolean array fits in ~12 MB. + +## Segmented Sieve + +The standard sieve requires O(n) memory. A segmented sieve divides the range into blocks of size ~√n, finding primes in each segment using the small primes found in the first √n numbers. Memory usage drops to O(√n), making it practical for n up to 10¹². + +## Sieve of Atkin + +A more complex algorithm by Atkin and Bernstein (2004) that uses quadratic forms to mark composites. It has asymptotically better time complexity (O(n / log log n)) than Eratosthenes for large n, though in practice it is often slower due to constant factors and worse cache behavior. + +The Sieve of Atkin is mainly of theoretical interest; the segmented Sieve of Eratosthenes is typically faster in practice. + +## Linear Sieve + +A variant that processes each composite exactly once, achieving O(n) time. It builds a "smallest prime factor" table alongside the primes. Useful when you need the full prime factorization of all numbers up to n. + +## Applications + +Prime sieves are used in cryptography (RSA key generation), competitive programming, and number theory research. Generating all primes up to 10⁷ takes ~100 ms with a naive sieve; optimized segmented sieves can enumerate primes up to 10¹² in minutes. diff --git a/test/e2e/corpus/notes/science/quantum-entanglement.md b/test/e2e/corpus/notes/science/quantum-entanglement.md new file mode 100644 index 0000000..cb70bc7 --- /dev/null +++ b/test/e2e/corpus/notes/science/quantum-entanglement.md @@ -0,0 +1,37 @@ +--- +title: Quantum Entanglement +created: 2024-01-20T09:00:00.000Z +modified: 2024-01-20T09:00:00.000Z +tags: ["physics", "quantum", "entanglement"] +type: note +--- + +Quantum entanglement is a physical phenomenon where two or more particles become correlated in such a way that the quantum state of each cannot be described independently, even when separated by large distances. + +## Bell Pairs + +The simplest entangled system is a Bell pair: two qubits in the maximally entangled state. One such state is: + +|Φ⁺⟩ = (|00⟩ + |11⟩) / √2 + +When one qubit is measured in the computational basis and found to be |0⟩, the other qubit collapses to |0⟩ instantaneously — regardless of the spatial separation. The four Bell states (Φ⁺, Φ⁻, Ψ⁺, Ψ⁻) are the four maximally entangled two-qubit states and form an orthonormal basis for the two-qubit Hilbert space. + +## Non-Locality + +Bell's theorem (1964) proved that no local hidden variable theory can reproduce all predictions of quantum mechanics. The CHSH inequality and subsequent Bell tests (Aspect 1982, Hensen 2015, and others) have demonstrated statistically significant violations of the Bell inequality, confirming that quantum correlations are genuinely non-local. + +Non-locality does not permit faster-than-light communication. Measuring one entangled qubit yields a random result; only by comparing both measurements over a classical channel does the correlation become apparent. + +## Entanglement and Decoherence + +Entanglement is fragile. Interaction with the environment (thermal photons, vibrations, stray fields) entangles the quantum system with uncontrolled degrees of freedom, effectively destroying the coherent superposition — a process called decoherence. This is the central engineering challenge in quantum computing. + +## Applications + +- **Quantum key distribution (QKD)**: The BB84 and E91 protocols use entanglement properties to distribute cryptographic keys whose security is guaranteed by quantum mechanics. +- **Quantum teleportation**: Entanglement can be used to transmit an unknown quantum state from sender to receiver using only classical communication and a pre-shared Bell pair. +- **Quantum error correction**: Multi-qubit entangled states form the basis of stabilizer codes that detect and correct errors without measuring (and thereby collapsing) the encoded logical qubit. + +## EPR Paradox + +Einstein, Podolsky, and Rosen argued in 1935 that entanglement implied quantum mechanics was incomplete. Subsequent theoretical and experimental work — notably Bell's inequalities — demonstrated that the correlations cannot be explained by any locally realistic hidden variable theory. diff --git a/test/e2e/corpus/notes/science/stellar-evolution.md b/test/e2e/corpus/notes/science/stellar-evolution.md new file mode 100644 index 0000000..589a30e --- /dev/null +++ b/test/e2e/corpus/notes/science/stellar-evolution.md @@ -0,0 +1,35 @@ +--- +title: Stellar Evolution +created: 2024-01-21T09:00:00.000Z +modified: 2024-01-21T09:00:00.000Z +tags: ["astronomy", "stars", "physics"] +type: note +--- + +Stars are born, evolve, and die over timescales from millions to trillions of years depending on their initial mass. The life cycle of a star is driven by the balance between gravitational collapse and outward radiation pressure from nuclear fusion. + +## Formation + +Stars form inside molecular clouds — vast concentrations of hydrogen and dust. A region of the cloud collapses under its own gravity; as material falls inward, it heats up, forming a protostar. When the core temperature reaches about 10 million Kelvin, hydrogen fusion ignites and the star joins the main sequence. + +## Main Sequence + +A main sequence star fuses hydrogen into helium in its core. The outward pressure from this fusion balances gravity — a state called hydrostatic equilibrium. The sun is a main sequence G-type star about halfway through its ~10 billion year lifetime. More massive stars burn brighter and hotter but exhaust their fuel far faster; a 20 solar-mass star lives only ~10 million years on the main sequence. + +## Red Giants + +When hydrogen in the core is exhausted, fusion stops and the core contracts. The outer layers expand and cool, becoming a red giant. The sun will eventually expand to approximately the orbit of Mars. + +In the red giant phase, helium fusion ignites in the core (the helium flash), creating heavier elements like carbon and oxygen. + +## Stellar Death + +The fate depends on mass: + +- **Low/medium-mass stars** (like the sun): The outer layers are expelled as a planetary nebula, leaving behind a white dwarf — a dense, hot remnant supported by electron degeneracy pressure. + +- **High-mass stars**: The core collapses in milliseconds when iron accumulates; iron fusion absorbs rather than releases energy. The collapse triggers a core-collapse supernova. The remnant is either a neutron star (supported by neutron degeneracy pressure) or, for very massive progenitors, a stellar-mass black hole. + +## Supernovae and Element Synthesis + +Core-collapse supernovae scatter heavy elements forged by the massive star across the interstellar medium. Elements heavier than iron are largely produced by rapid neutron capture (r-process) during the explosion or in neutron star mergers. This stellar recycling seeds future generations of stars and planets. diff --git a/test/e2e/corpus/notes/travel/backpacking-stove-comparison.md b/test/e2e/corpus/notes/travel/backpacking-stove-comparison.md new file mode 100644 index 0000000..a2bff58 --- /dev/null +++ b/test/e2e/corpus/notes/travel/backpacking-stove-comparison.md @@ -0,0 +1,42 @@ +--- +title: Backpacking Stove Comparison +created: 2024-02-10T09:00:00.000Z +modified: 2024-02-10T09:00:00.000Z +tags: ["backpacking", "gear", "travel"] +type: note +--- + +Backpacking stoves fall into three main categories: canister stoves, liquid fuel stoves, and alcohol stoves. Each makes a different trade-off between weight, packability, reliability in cold weather, and fuel availability. + +## Canister Stoves + +Canister stoves screw onto a pressurized isobutane/propane mix canister. They are the simplest to use: turn the valve, ignite, and cook. + +**Pros**: Very light (50–100g for the stove alone), easy to regulate heat, integrated ignition on most models. +**Cons**: Canisters do not work well below freezing because isobutane's vapor pressure drops and the stove sputters. Fuel level is hard to gauge. Canisters are not refillable and create waste. + +**Best for**: Fair-weather backpacking, ultralight trips, weekend outings where fuel availability is predictable. + +Examples: MSR PocketRocket 2, Jetboil Flash, BRS-3000T (ultralight budget option). + +## Liquid Fuel Stoves + +Liquid fuel stoves (white gas, petrol, diesel) pressurize a refillable bottle. The MSR WhisperLite and XGK are the classics. + +**Pros**: Perform well in cold and at high altitude; fuel bottles are refillable. White gas burns hot and clean; multi-fuel stoves accept automotive petrol and kerosene when remote. +**Cons**: Heavier than canister stoves, require priming and maintenance (cleaning jets, replacing O-rings), more complex to operate. + +**Best for**: Winter camping, high-altitude expeditions, long trips in remote areas where canisters are unavailable. + +## Alcohol Stoves + +Alcohol stoves burn denatured alcohol (ethanol), methanol, or isopropyl. The simplest are homemade cat food can stoves. + +**Pros**: Extremely light (10–20g), no moving parts, fuel is widely available worldwide, cheap. +**Cons**: Very slow to boil water, hard to regulate heat, no simmer, perform poorly in cold and wind, carry-on restrictions on alcohol fuel. + +**Best for**: Ultralight thru-hiking in warm weather where cooking is simple (ramen, couscous) and boil time is not critical. + +## Integrated Systems + +Jetboil-style integrated canister systems pair a burner with a heat exchanger vessel. They boil water faster than a standard canister stove (2–3 minutes for 500ml) due to the heat exchanger fins, and are wind-resistant. The trade-off is that they are heavier and the pot is not usable separately. diff --git a/test/e2e/corpus/notes/travel/jet-lag-recovery.md b/test/e2e/corpus/notes/travel/jet-lag-recovery.md new file mode 100644 index 0000000..bd76264 --- /dev/null +++ b/test/e2e/corpus/notes/travel/jet-lag-recovery.md @@ -0,0 +1,40 @@ +--- +title: Jet Lag Recovery +created: 2024-02-09T09:00:00.000Z +modified: 2024-02-09T09:00:00.000Z +tags: ["travel", "sleep", "health"] +type: note +--- + +Jet lag occurs when the body's circadian rhythm is misaligned with the local day-night cycle after crossing multiple time zones. The suprachiasmatic nucleus (SCN) — the brain's master clock — adjusts slowly, roughly one hour per day, causing fatigue, insomnia, and cognitive fog. + +## Eastward vs. Westward Travel + +Eastward travel (advancing the clock) is harder than westward travel (delaying the clock) for most people. The human circadian period is slightly longer than 24 hours, so it is naturally easier to stay up later (delay) than to fall asleep earlier (advance). Flying from New York to London (5 hours east) typically causes more disruption than flying from London to New York. + +## Light Exposure + +Light is the most powerful synchronizer of the circadian clock. To advance the clock (eastward travel): seek bright light in the morning at the destination. To delay the clock (westward travel): seek bright light in the evening. Avoid bright light at the wrong time — it can push the clock in the wrong direction. + +A 10,000 lux lightbox used for 20–30 minutes in the morning is the most effective tool for rapid clock advancement. + +## Melatonin + +Melatonin is a chronobiotic — it shifts the phase of the circadian clock rather than just promoting sleep. Taken at the right time: + +- **Advancing** (eastward): take 0.5–1 mg melatonin at bedtime at the destination for 3–4 days. +- **Delaying** (westward): melatonin is less useful; avoid it at the destination's bedtime. + +Low doses (0.5 mg) are as effective as higher doses (5 mg) for phase shifting and cause less next-day grogginess. Avoid high-dose melatonin supplements marketed as sleep aids. + +## Strategic Fasting + +Some evidence supports a 12–16 hour fast beginning before arrival and breaking the fast at the destination's breakfast time. The idea is that the liver's metabolic clock responds to meal timing independently of the SCN, providing an additional anchor for circadian realignment. + +## Practical Protocol + +1. Adjust sleep schedule 2–3 days before a long eastward flight (sleep an hour earlier each night). +2. Avoid alcohol and minimize caffeine on the flight. +3. On arrival, stay awake until local bedtime — naps longer than 20 minutes delay adjustment. +4. Take 0.5 mg melatonin at local bedtime for 3 nights. +5. Seek morning sunlight daily. diff --git a/test/e2e/corpus/notes/travel/packing-carry-on-only.md b/test/e2e/corpus/notes/travel/packing-carry-on-only.md new file mode 100644 index 0000000..fc13ad5 --- /dev/null +++ b/test/e2e/corpus/notes/travel/packing-carry-on-only.md @@ -0,0 +1,39 @@ +--- +title: Packing Carry-On Only +created: 2024-02-08T09:00:00.000Z +modified: 2024-02-08T09:00:00.000Z +tags: ["travel", "packing", "minimalist"] +type: note +--- + +Traveling carry-on only eliminates checked bag fees, baggage claim waits, and lost luggage risk. The key is choosing the right bag, using compression, and layering a versatile wardrobe. + +## Bag Selection + +A bag that fits in the overhead bin (typically 22" × 14" × 9" for most US carriers, smaller for budget airlines) is the hard constraint. Many travelers also bring a personal item under the seat — a backpack or tote that handles what doesn't fit. + +Top-loading hiking-style bags maximize the usable volume. Front-loading panel bags offer easier access to the main compartment. + +## Clothing Strategy + +Plan around layers rather than outfits. Four base layers work for two weeks when paired with a two to three mid/outer layers: + +- **Base**: 3–4 merino wool or synthetic shirts (quick-dry, odor-resistant) +- **Bottom**: 2 pairs of pants (one packing and one wearing) +- **Mid**: 1 fleece or light down jacket +- **Outer**: 1 hardshell rain jacket (compresses small) +- **Shoes**: Wear the bulky shoes; pack 1 light pair (flip-flops or flats) + +Merino wool is the carry-on traveler's best material: it resists odor long enough to wear multiple days, dries overnight, and packs small. + +## Compression + +Packing cubes separate categories and compress soft items. Roll clothes before placing in cubes — rolling creates denser cylinders with fewer wrinkles than folding. Compression cubes (with a secondary zipper that collapses the cube further) can cut clothing volume by 30–40%. + +## Toiletries + +All liquids must be in containers ≤ 100 ml (3.4 oz) and fit in a single 1-quart clear bag (TSA rule). Solid alternatives eliminate the liquid restriction: shampoo bars, solid cologne, toothpaste tabs, and solid sunscreen sticks are all airport-friendly. + +## What Most People Over-Pack + +Shoes (two pairs maximum), "just in case" outfits, and full-size toiletry bottles. Anything purchased locally on a two-week trip costs less than a checked bag fee. diff --git a/test/e2e/crud.e2e.test.ts b/test/e2e/crud.e2e.test.ts new file mode 100644 index 0000000..3adaf58 --- /dev/null +++ b/test/e2e/crud.e2e.test.ts @@ -0,0 +1,154 @@ +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { Harness } from "./harness.ts"; + +const SETUP_TIMEOUT = 360_000; +const TEST_TIMEOUT = 30_000; + +for (const mode of ["serverless", "server"] as const) { + describe(`crud e2e [${mode}]`, () => { + const h = new Harness(); + + beforeAll(async () => { + await h.start(mode); + }, SETUP_TIMEOUT); + + afterAll(async () => { + await h.stop(); + }); + + // --- Note CRUD --- + + test("note: create → get → update → delete", async () => { + const path = "notes/_e2e/crud-test-note"; + + // Create + const created = await h.createNote(path, { + title: "E2E Test Note", + content: "Initial content for the e2e crud test.", + tags: ["e2e"], + }); + expect(created.path).toBe(path); + expect(created.title).toBe("E2E Test Note"); + expect(created.content).toContain("Initial content"); + + // Get + const fetched = await h.getNote(path); + expect(fetched.path).toBe(path); + expect(fetched.title).toBe("E2E Test Note"); + + // Update + const updated = await h.updateNote(path, { + title: "Updated E2E Note", + content: "Updated content for the test.", + }); + expect(updated.title).toBe("Updated E2E Note"); + expect(updated.content).toContain("Updated content"); + + // Verify update persisted + const afterUpdate = await h.getNote(path); + expect(afterUpdate.title).toBe("Updated E2E Note"); + + // Delete + await h.deleteNote(path); + + // Verify deleted + await expect(h.getNote(path)).rejects.toThrow(); + }, TEST_TIMEOUT); + + test("note: created note appears in listNotes", async () => { + const path = "notes/_e2e/list-test-note"; + await h.createNote(path, { title: "List Test Note" }); + + const entries = await h.listNotes("notes/_e2e"); + const paths = entries.map((e) => e.path); + expect(paths).toContain(path); + + await h.deleteNote(path); + }, TEST_TIMEOUT); + + // --- Folder CRUD --- + + test("folder: create → list → delete", async () => { + const folderPath = "notes/_e2e/test-folder"; + await h.createFolder(folderPath); + + const entries = await h.listNotes("notes/_e2e"); + const paths = entries.map((e) => e.path); + expect(paths).toContain(folderPath); + + await h.deleteFolder(folderPath); + }, TEST_TIMEOUT); + + // --- Log / entry CRUD --- + + test("log: create → addEntry → listEntries → updateEntry → deleteEntry → deleteLog", async () => { + const logPath = "logs/_e2e/crud-test-journal"; + + // Create log + await h.createLog(logPath, "E2E Test Journal"); + + // Add first entry + const entry1 = await h.addEntry(logPath, "First entry content."); + expect(entry1.id).toMatch(/^e-[0-9a-f]+$/); + expect(entry1.content).toBe("First entry content."); + + // Add second entry + const entry2 = await h.addEntry(logPath, "Second entry content."); + expect(entry2.id).not.toBe(entry1.id); + + // List entries + const entries = await h.listEntries(logPath); + expect(entries.length).toBeGreaterThanOrEqual(2); + // Newest first + expect(entries[0].id).toBe(entry2.id); + expect(entries[1].id).toBe(entry1.id); + + // Update entry + const updated = await h.updateEntry(logPath, entry1.id, "Updated first entry content."); + expect(updated.content).toBe("Updated first entry content."); + + // Delete entry + await h.deleteEntry(logPath, entry1.id); + const afterDelete = await h.listEntries(logPath); + const ids = afterDelete.map((e) => e.id); + expect(ids).not.toContain(entry1.id); + expect(ids).toContain(entry2.id); + + // Delete log + await h.deleteLog(logPath); + await expect(h.listEntries(logPath)).rejects.toThrow(); + }, TEST_TIMEOUT); + + test("log: appears in listJournals after creation", async () => { + const logPath = "logs/_e2e/list-test-journal"; + await h.createLog(logPath, "List Test Journal"); + + const journals = await h.listJournals("logs/_e2e"); + const paths = journals.map((j) => j.path); + expect(paths).toContain(logPath); + + await h.deleteLog(logPath); + }, TEST_TIMEOUT); + + // --- Context CRUD --- + + test("context: set → get → list → remove", async () => { + const ctxPath = "notes/_e2e/ctx-test"; + const contextText = "This is a test context for e2e testing."; + + await h.setContext(ctxPath, contextText); + + const fetched = await h.getContext(ctxPath); + expect(fetched).toBe(contextText); + + const all = await h.listContexts(); + const paths = all.map((c) => c.path); + expect(paths).toContain(ctxPath); + + await h.removeContext(ctxPath); + + const afterRemove = await h.getContext(ctxPath); + expect(afterRemove).toBeUndefined(); + }, TEST_TIMEOUT); + }); +} diff --git a/test/e2e/filters.e2e.test.ts b/test/e2e/filters.e2e.test.ts new file mode 100644 index 0000000..f079cf3 --- /dev/null +++ b/test/e2e/filters.e2e.test.ts @@ -0,0 +1,96 @@ +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { Harness } from "./harness.ts"; + +const SETUP_TIMEOUT = 360_000; +const TEST_TIMEOUT = 30_000; + +for (const mode of ["serverless", "server"] as const) { + describe(`filters e2e [${mode}]`, () => { + const h = new Harness(); + + beforeAll(async () => { + await h.start(mode); + }, SETUP_TIMEOUT); + + afterAll(async () => { + await h.stop(); + }); + + // --- Collection filters --- + + test("collections:notes excludes logs paths", async () => { + const results = await h.search("training running workout", { + mode: "hybrid", + limit: 10, + collections: ["notes"], + }); + expect(results.length).toBeGreaterThan(0); + for (const r of results) { + expect(r.path).not.toMatch(/^logs\//); + expect(r.path).toMatch(/^notes\//); + } + }, TEST_TIMEOUT); + + test("collections:logs excludes notes paths", async () => { + const results = await h.search("training running workout", { + mode: "hybrid", + limit: 10, + collections: ["logs"], + }); + expect(results.length).toBeGreaterThan(0); + for (const r of results) { + expect(r.path).not.toMatch(/^notes\//); + expect(r.path).toMatch(/^logs\//); + } + }, TEST_TIMEOUT); + + test("collections:[notes,logs] returns both types", async () => { + const results = await h.search("cooking", { + mode: "hybrid", + limit: 20, + collections: ["notes", "logs"], + }); + expect(results.length).toBeGreaterThan(0); + const hasNote = results.some((r) => r.path.startsWith("notes/")); + const hasLog = results.some((r) => r.path.startsWith("logs/")); + expect(hasNote).toBe(true); + expect(hasLog).toBe(true); + }, TEST_TIMEOUT); + + // --- minScore filter --- + + test("minScore:0 equivalent to no filter", async () => { + const noFilter = await h.search("sourdough", { mode: "bm25", limit: 5 }); + const withZero = await h.search("sourdough", { mode: "bm25", limit: 5, minScore: 0 }); + expect(withZero.map((r) => r.path)).toEqual(noFilter.map((r) => r.path)); + }, TEST_TIMEOUT); + + test("minScore:999 returns empty results", async () => { + for (const searchMode of ["bm25", "vector", "hybrid"] as const) { + const results = await h.search("sourdough", { mode: searchMode, limit: 5, minScore: 999 }); + expect(results).toHaveLength(0); + } + }, TEST_TIMEOUT); + + test("minScore mid-range drops some results from top-5", async () => { + const unfiltered = await h.search("cooking", { mode: "bm25", limit: 5 }); + if (unfiltered.length < 2) return; // skip if corpus doesn't give enough results + + // Find a mid-range threshold: use the score of the lowest result + const lowestScore = unfiltered[unfiltered.length - 1].score; + const highestScore = unfiltered[0].score; + // Only meaningful if there's a range + if (lowestScore >= highestScore) return; + + const midScore = lowestScore + (highestScore - lowestScore) * 0.5; + const filtered = await h.search("cooking", { mode: "bm25", limit: 5, minScore: midScore }); + + // Filtered count should be less than unfiltered + expect(filtered.length).toBeLessThan(unfiltered.length); + // Every filtered result should have score >= threshold + for (const r of filtered) { + expect(r.score).toBeGreaterThanOrEqual(midScore); + } + }, TEST_TIMEOUT); + }); +} diff --git a/test/e2e/fixtures/pinned-config.ts b/test/e2e/fixtures/pinned-config.ts new file mode 100644 index 0000000..2a0e1a7 --- /dev/null +++ b/test/e2e/fixtures/pinned-config.ts @@ -0,0 +1,6 @@ +export const PINNED_CONFIG = { + embedModel: "hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf", + rerank: false, + queryExpand: false, + embedInterval: 3600, +} as const; diff --git a/test/e2e/harness.ts b/test/e2e/harness.ts new file mode 100644 index 0000000..c86dff9 --- /dev/null +++ b/test/e2e/harness.ts @@ -0,0 +1,393 @@ +import { mkdtemp, rm, cp } from "node:fs/promises"; +import { join, dirname } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; +import { createServer } from "node:net"; +import type { ChildProcess } from "node:child_process"; +import type { SearchMode, SearchResult, NoteResult, ListEntry, LogEntry } from "../../src/core/types.ts"; +import { PINNED_CONFIG } from "./fixtures/pinned-config.ts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CORPUS_DIR = join(__dirname, "corpus"); +const PROJECT_ROOT = join(__dirname, "../.."); + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = createServer(); + srv.listen(0, "127.0.0.1", () => { + const addr = srv.address() as { port: number }; + srv.close(() => resolve(addr.port)); + }); + srv.on("error", reject); + }); +} + +async function fetchJson(url: string, init?: RequestInit): Promise { + const resp = await fetch(url, init); + const body = await resp.json() as any; + if (!resp.ok) throw new Error(body?.error ?? `HTTP ${resp.status}`); + return body as T; +} + +export class Harness { + mode!: "serverless" | "server"; + home!: string; + private baseUrl: string | null = null; + private serverProcess: ChildProcess | null = null; + + async start(mode: "serverless" | "server"): Promise { + this.mode = mode; + this.home = await mkdtemp(join(tmpdir(), `knotes-e2e-${mode}-`)); + + process.env["KNOTES_HOME"] = this.home; + + const { resetConfigCache, ensureHome, saveConfig } = await import("../../src/core/config.ts"); + const { resetDb } = await import("../../src/core/db.ts"); + const { resetStore } = await import("../../src/core/search.ts"); + + resetConfigCache(); + resetDb(); + resetStore(); + await ensureHome(); + + await saveConfig({ + serverless: mode === "serverless", + rerank: PINNED_CONFIG.rerank, + queryExpand: PINNED_CONFIG.queryExpand, + embedInterval: PINNED_CONFIG.embedInterval, + embedModel: PINNED_CONFIG.embedModel, + }); + + await cp(join(CORPUS_DIR, "notes"), join(this.home, "notes"), { recursive: true }); + await cp(join(CORPUS_DIR, "logs"), join(this.home, "logs"), { recursive: true }); + + if (mode === "serverless") { + const { updateIndex, embed } = await import("../../src/core/search.ts"); + await updateIndex(); + await embed({ trigger: "on-demand" }); + } else { + // Release the DB before spawning so subprocess can open it + resetDb(); + resetStore(); + delete process.env["KNOTES_HOME"]; + + const port = await getFreePort(); + this.baseUrl = `http://127.0.0.1:${port}`; + + this.serverProcess = spawn("npx", ["tsx", "src/main.ts", "server", "--port", String(port)], { + env: { ...process.env, KNOTES_HOME: this.home }, + cwd: PROJECT_ROOT, + stdio: ["ignore", "pipe", "pipe"], + }); + + this.serverProcess.stdout?.on("data", () => {}); + this.serverProcess.stderr?.on("data", () => {}); + + await this._waitForServer(); + await this._waitForEmbed(); + } + } + + private async _waitForServer(): Promise { + const deadline = Date.now() + 20_000; + while (Date.now() < deadline) { + try { + const resp = await fetch(`${this.baseUrl}/api/health`); + if (resp.ok) return; + } catch {} + await new Promise((r) => setTimeout(r, 200)); + } + throw new Error("E2E server did not start within 20 seconds"); + } + + private async _waitForEmbed(): Promise { + const deadline = Date.now() + 300_000; + while (Date.now() < deadline) { + try { + const resp = await fetch(`${this.baseUrl}/api/search/embed/status`); + const data = (await resp.json()) as { lastJob: { status: string; error?: string } | null }; + const s = data.lastJob?.status; + if (s === "completed") return; + if (s === "failed") throw new Error(`Embed failed: ${data.lastJob?.error ?? "unknown"}`); + } catch (err: any) { + if (err.message?.startsWith("Embed failed:")) throw err; + } + await new Promise((r) => setTimeout(r, 1000)); + } + throw new Error("E2E embed did not complete within 300 seconds"); + } + + async stop(): Promise { + if (this.serverProcess) { + this.serverProcess.kill("SIGTERM"); + // Wait for process to fully exit and release the port + await new Promise((resolve) => { + this.serverProcess!.once("exit", () => resolve()); + setTimeout(resolve, 3000); + }); + this.serverProcess = null; + } + + if (this.mode === "serverless" || process.env["KNOTES_HOME"] === this.home) { + const { resetDb } = await import("../../src/core/db.ts"); + const { resetStore } = await import("../../src/core/search.ts"); + resetDb(); + resetStore(); + delete process.env["KNOTES_HOME"]; + } + + await rm(this.home, { recursive: true, force: true }); + } + + // Ensure this harness's KNOTES_HOME is active (needed for serverless when two harnesses exist) + private async _ensureEnv(): Promise { + if (this.mode !== "serverless") return; + if (process.env["KNOTES_HOME"] !== this.home) { + process.env["KNOTES_HOME"] = this.home; + const { resetDb } = await import("../../src/core/db.ts"); + const { resetStore } = await import("../../src/core/search.ts"); + resetDb(); + resetStore(); + } + } + + async search( + query: string, + opts?: { + limit?: number; + mode?: SearchMode; + rerank?: boolean; + queryExpand?: boolean; + collections?: ("notes" | "logs")[]; + minScore?: number; + } + ): Promise { + if (this.mode === "serverless") { + await this._ensureEnv(); + const { search } = await import("../../src/core/router.ts"); + return search(query, opts) as Promise; + } + const url = new URL(`${this.baseUrl}/api/search`); + url.searchParams.set("q", query); + if (opts?.limit != null) url.searchParams.set("limit", String(opts.limit)); + if (opts?.mode) url.searchParams.set("mode", opts.mode); + if (opts?.collections?.length) url.searchParams.set("collections", opts.collections.join(",")); + if (opts?.minScore != null) url.searchParams.set("minScore", String(opts.minScore)); + return fetchJson(url.toString()); + } + + async getNote(path: string): Promise { + if (this.mode === "serverless") { + await this._ensureEnv(); + const { getNote } = await import("../../src/core/router.ts"); + return getNote(path) as Promise; + } + const url = new URL(`${this.baseUrl}/api/notes/get`); + url.searchParams.set("path", path); + return fetchJson(url.toString()); + } + + async createNote(path: string, opts?: { title?: string; content?: string; tags?: string[] }): Promise { + if (this.mode === "serverless") { + await this._ensureEnv(); + const { createNote } = await import("../../src/core/router.ts"); + return createNote(path, opts) as Promise; + } + return fetchJson(`${this.baseUrl}/api/notes`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path, ...opts }), + }); + } + + async updateNote(path: string, opts: { title?: string; content?: string; tags?: string[] }): Promise { + if (this.mode === "serverless") { + await this._ensureEnv(); + const { updateNote } = await import("../../src/core/router.ts"); + return updateNote(path, opts) as Promise; + } + return fetchJson(`${this.baseUrl}/api/notes`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path, ...opts }), + }); + } + + async deleteNote(path: string): Promise { + if (this.mode === "serverless") { + await this._ensureEnv(); + const { deleteNote } = await import("../../src/core/router.ts"); + return deleteNote(path); + } + const url = new URL(`${this.baseUrl}/api/notes`); + url.searchParams.set("path", path); + await fetchJson<{ ok: boolean }>(url.toString(), { method: "DELETE" }); + } + + async listNotes(prefix?: string): Promise { + if (this.mode === "serverless") { + await this._ensureEnv(); + const { listNotes } = await import("../../src/core/router.ts"); + return listNotes(prefix) as Promise; + } + const url = new URL(`${this.baseUrl}/api/notes`); + if (prefix) url.searchParams.set("prefix", prefix); + return fetchJson(url.toString()); + } + + async createFolder(path: string): Promise { + if (this.mode === "serverless") { + await this._ensureEnv(); + const { createFolder } = await import("../../src/core/router.ts"); + return createFolder(path); + } + await fetchJson<{ ok: boolean }>(`${this.baseUrl}/api/notes/folder`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path }), + }); + } + + async deleteFolder(path: string): Promise { + if (this.mode === "serverless") { + await this._ensureEnv(); + const { deleteFolder } = await import("../../src/core/router.ts"); + return deleteFolder(path); + } + const url = new URL(`${this.baseUrl}/api/notes/folder`); + url.searchParams.set("path", path); + await fetchJson<{ ok: boolean }>(url.toString(), { method: "DELETE" }); + } + + async listJournals(prefix?: string): Promise { + if (this.mode === "serverless") { + await this._ensureEnv(); + const { listJournals } = await import("../../src/core/router.ts"); + return listJournals(prefix) as Promise; + } + const url = new URL(`${this.baseUrl}/api/logs`); + if (prefix) url.searchParams.set("prefix", prefix); + return fetchJson(url.toString()); + } + + async createLog(path: string, title?: string): Promise { + if (this.mode === "serverless") { + await this._ensureEnv(); + const { createLog } = await import("../../src/core/router.ts"); + return createLog(path, title); + } + await fetchJson<{ ok: boolean; path: string }>(`${this.baseUrl}/api/logs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path, title }), + }); + } + + async deleteLog(path: string): Promise { + if (this.mode === "serverless") { + await this._ensureEnv(); + const { deleteLog } = await import("../../src/core/router.ts"); + return deleteLog(path); + } + const url = new URL(`${this.baseUrl}/api/logs`); + url.searchParams.set("path", path); + await fetchJson<{ ok: boolean }>(url.toString(), { method: "DELETE" }); + } + + async addEntry(path: string, content: string): Promise { + if (this.mode === "serverless") { + await this._ensureEnv(); + const { addEntry } = await import("../../src/core/router.ts"); + return addEntry(path, content) as Promise; + } + return fetchJson(`${this.baseUrl}/api/logs/entries`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path, content }), + }); + } + + async listEntries(path: string, opts?: { limit?: number }): Promise { + if (this.mode === "serverless") { + await this._ensureEnv(); + const { listEntries } = await import("../../src/core/router.ts"); + return listEntries(path, opts) as Promise; + } + const url = new URL(`${this.baseUrl}/api/logs/entries`); + url.searchParams.set("path", path); + if (opts?.limit != null) url.searchParams.set("limit", String(opts.limit)); + return fetchJson(url.toString()); + } + + async updateEntry(path: string, entryId: string, content: string): Promise { + if (this.mode === "serverless") { + await this._ensureEnv(); + const { updateEntry } = await import("../../src/core/router.ts"); + return updateEntry(path, entryId, content) as Promise; + } + return fetchJson(`${this.baseUrl}/api/logs/entries`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path, entryId, content }), + }); + } + + async deleteEntry(path: string, entryId: string): Promise { + if (this.mode === "serverless") { + await this._ensureEnv(); + const { deleteEntry } = await import("../../src/core/router.ts"); + return deleteEntry(path, entryId); + } + const url = new URL(`${this.baseUrl}/api/logs/entries`); + url.searchParams.set("path", path); + url.searchParams.set("entryId", entryId); + await fetchJson<{ ok: boolean }>(url.toString(), { method: "DELETE" }); + } + + async getContext(path: string): Promise { + if (this.mode === "serverless") { + await this._ensureEnv(); + const { getContext } = await import("../../src/core/router.ts"); + return getContext(path) as Promise; + } + const url = new URL(`${this.baseUrl}/api/context/get`); + url.searchParams.set("path", path); + const resp = await fetch(url.toString()); + const data = (await resp.json()) as { context: string | null }; + return data.context ?? undefined; + } + + async setContext(path: string, context: string): Promise { + if (this.mode === "serverless") { + await this._ensureEnv(); + const { setContext } = await import("../../src/core/router.ts"); + return setContext(path, context); + } + await fetchJson<{ ok: boolean }>(`${this.baseUrl}/api/context`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path, context }), + }); + } + + async listContexts(): Promise<{ path: string; context: string }[]> { + if (this.mode === "serverless") { + await this._ensureEnv(); + const { listContexts } = await import("../../src/core/router.ts"); + return listContexts() as Promise<{ path: string; context: string }[]>; + } + return fetchJson<{ path: string; context: string }[]>(`${this.baseUrl}/api/context`); + } + + async removeContext(path: string): Promise { + if (this.mode === "serverless") { + await this._ensureEnv(); + const { removeContext } = await import("../../src/core/router.ts"); + return removeContext(path); + } + const url = new URL(`${this.baseUrl}/api/context`); + url.searchParams.set("path", path); + await fetchJson<{ ok: boolean }>(url.toString(), { method: "DELETE" }); + } +} diff --git a/test/e2e/parity.e2e.test.ts b/test/e2e/parity.e2e.test.ts new file mode 100644 index 0000000..5256e9e --- /dev/null +++ b/test/e2e/parity.e2e.test.ts @@ -0,0 +1,51 @@ +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { Harness } from "./harness.ts"; + +const SETUP_TIMEOUT = 360_000; +const TEST_TIMEOUT = 30_000; + +describe("parity e2e [serverless vs server]", () => { + const serverless = new Harness(); + const server = new Harness(); + + beforeAll(async () => { + // Start both modes; do them sequentially to avoid env var conflicts + await serverless.start("serverless"); + await server.start("server"); + }, SETUP_TIMEOUT * 2); + + afterAll(async () => { + await serverless.stop(); + await server.stop(); + }); + + const searchQueries = [ + { q: "carbonara recipe guanciale pecorino", mode: "bm25" as const }, + { q: "semantic concept of cooperative concurrency async", mode: "vector" as const }, + { q: "quantum measurement Bell state nonlocality", mode: "hybrid" as const }, + ]; + + for (const { q, mode } of searchQueries) { + test(`search parity: "${q}" [${mode}]`, async () => { + const a = await serverless.search(q, { mode, limit: 10 }); + const b = await server.search(q, { mode, limit: 10 }); + + expect(a.length).toBeGreaterThan(0); + expect(b.map((r) => r.path)).toEqual(a.map((r) => r.path)); + expect(b.map((r) => r.title)).toEqual(a.map((r) => r.title)); + }, TEST_TIMEOUT); + } + + test("listNotes parity", async () => { + const a = await serverless.listNotes("notes/cooking"); + const b = await server.listNotes("notes/cooking"); + expect(b.map((e) => e.path).sort()).toEqual(a.map((e) => e.path).sort()); + expect(b.map((e) => e.title).sort()).toEqual(a.map((e) => e.title).sort()); + }, TEST_TIMEOUT); + + test("listJournals parity", async () => { + const a = await serverless.listJournals("logs"); + const b = await server.listJournals("logs"); + expect(b.map((e) => e.path).sort()).toEqual(a.map((e) => e.path).sort()); + }, TEST_TIMEOUT); +}); diff --git a/test/e2e/search.e2e.test.ts b/test/e2e/search.e2e.test.ts new file mode 100644 index 0000000..4a97057 --- /dev/null +++ b/test/e2e/search.e2e.test.ts @@ -0,0 +1,144 @@ +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { Harness } from "./harness.ts"; + +const SETUP_TIMEOUT = 360_000; +const TEST_TIMEOUT = 30_000; + +for (const mode of ["serverless", "server"] as const) { + describe(`search e2e [${mode}]`, () => { + const h = new Harness(); + + beforeAll(async () => { + await h.start(mode); + }, SETUP_TIMEOUT); + + afterAll(async () => { + await h.stop(); + }); + + // --- BM25: keyword-heavy queries expect rank-1 match --- + + test("bm25 top-1: carbonara keywords", async () => { + const results = await h.search("guanciale pecorino carbonara", { mode: "bm25", limit: 5 }); + expect(results.length).toBeGreaterThan(0); + expect(results[0].path).toBe("notes/cooking/pasta-carbonara"); + }, TEST_TIMEOUT); + + test("bm25 top-1: sourdough keywords", async () => { + const results = await h.search("sourdough starter hydration bulk fermentation", { mode: "bm25", limit: 5 }); + expect(results.length).toBeGreaterThan(0); + expect(results[0].path).toBe("notes/cooking/sourdough-basics"); + }, TEST_TIMEOUT); + + test("bm25 top-1: tokio async runtime keywords", async () => { + const results = await h.search("tokio futures poll executor runtime", { mode: "bm25", limit: 5 }); + expect(results.length).toBeGreaterThan(0); + expect(results[0].path).toBe("notes/programming/rust-async-runtime"); + }, TEST_TIMEOUT); + + test("bm25 top-1: plate tectonics keywords", async () => { + const results = await h.search("subduction rifting hotspots tectonic plates lithosphere", { mode: "bm25", limit: 5 }); + expect(results.length).toBeGreaterThan(0); + expect(results[0].path).toBe("notes/science/plate-tectonics"); + }, TEST_TIMEOUT); + + test("bm25 top-1: deadlift form keywords", async () => { + const results = await h.search("deadlift hip hinge bracing intra-abdominal", { mode: "bm25", limit: 5 }); + expect(results.length).toBeGreaterThan(0); + expect(results[0].path).toBe("notes/fitness/deadlift-form"); + }, TEST_TIMEOUT); + + // --- Vector: paraphrase queries expect top-3 match --- + + test("vector top-3: pasta with eggs and cured pork", async () => { + const results = await h.search("pasta with eggs and cured pork cheek cheese", { mode: "vector", limit: 5 }); + const paths = results.slice(0, 3).map((r) => r.path); + expect(paths).toContain("notes/cooking/pasta-carbonara"); + }, TEST_TIMEOUT); + + test("vector top-3: running a 26 mile race", async () => { + const results = await h.search("training plan for running a 26 mile race", { mode: "vector", limit: 5 }); + const paths = results.slice(0, 3).map((r) => r.path); + expect(paths).toContain("notes/fitness/marathon-training-plan"); + }, TEST_TIMEOUT); + + test("vector top-3: how stars live and die", async () => { + const results = await h.search("how stars are born on the main sequence and die as supernovae", { mode: "vector", limit: 5 }); + const paths = results.slice(0, 3).map((r) => r.path); + expect(paths).toContain("notes/science/stellar-evolution"); + }, TEST_TIMEOUT); + + test("vector top-3: jazz chord harmony", async () => { + const results = await h.search("jazz chord harmony two five one progression voicing", { mode: "vector", limit: 5 }); + const paths = results.slice(0, 3).map((r) => r.path); + expect(paths).toContain("notes/music/jazz-ii-v-i-voicings"); + }, TEST_TIMEOUT); + + test("vector top-3: ground fault outlet wiring", async () => { + const results = await h.search("wiring an outlet with ground fault protection for bathroom", { mode: "vector", limit: 5 }); + const paths = results.slice(0, 3).map((r) => r.path); + expect(paths).toContain("notes/home/gfci-outlet-wiring"); + }, TEST_TIMEOUT); + + // --- Hybrid: mixed keyword + semantic queries --- + + test("hybrid top-3: tokio green threads", async () => { + const results = await h.search("tokio green threads cooperative scheduling", { mode: "hybrid", limit: 5 }); + const paths = results.slice(0, 3).map((r) => r.path); + expect(paths).toContain("notes/programming/rust-async-runtime"); + }, TEST_TIMEOUT); + + test("hybrid top-3: quantum Bell pairs non-locality", async () => { + const results = await h.search("quantum Bell pairs nonlocality entanglement", { mode: "hybrid", limit: 5 }); + const paths = results.slice(0, 3).map((r) => r.path); + expect(paths).toContain("notes/science/quantum-entanglement"); + }, TEST_TIMEOUT); + + test("hybrid top-3: sweep picking arpeggios", async () => { + const results = await h.search("sweep picking arpeggios guitar", { mode: "hybrid", limit: 5 }); + const paths = results.slice(0, 3).map((r) => r.path); + expect(paths).toContain("notes/music/guitar-sweep-picking"); + }, TEST_TIMEOUT); + + // --- Negative queries: should return empty --- + + test("bm25 negative: gibberish returns empty", async () => { + const results = await h.search("xyzzy foobar zork quux bazinga florbulate", { mode: "bm25", limit: 5 }); + expect(results).toHaveLength(0); + }, TEST_TIMEOUT); + + test("vector negative: random characters returns empty or low-relevance", async () => { + const results = await h.search("asdfghjkl qwertyuiop zxcvbnm", { mode: "bm25", limit: 5 }); + expect(results).toHaveLength(0); + }, TEST_TIMEOUT); + + // --- Result quality checks --- + + test("result titles are non-empty and non-filename-like", async () => { + const results = await h.search("sourdough starter", { mode: "bm25", limit: 3 }); + expect(results.length).toBeGreaterThan(0); + for (const r of results) { + expect(r.title).toBeTruthy(); + expect(r.title).not.toMatch(/\.md$/); + expect(r.title.length).toBeGreaterThan(2); + } + }, TEST_TIMEOUT); + + test("result snippets are non-empty and do not start with ---", async () => { + const results = await h.search("tokio executor", { mode: "bm25", limit: 3 }); + expect(results.length).toBeGreaterThan(0); + for (const r of results) { + expect(r.snippet).toBeTruthy(); + expect(r.snippet).not.toMatch(/^---/); + } + }, TEST_TIMEOUT); + + test("result paths are retrievable via getNote", async () => { + const results = await h.search("carbonara", { mode: "bm25", limit: 3 }); + expect(results.length).toBeGreaterThan(0); + const note = await h.getNote(results[0].path); + expect(note.path).toBe(results[0].path); + expect(note.content).toBeTruthy(); + }, TEST_TIMEOUT); + }); +} diff --git a/vitest.config.ts b/vitest.config.ts index fa37244..128bcfd 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { include: ["test/**/*.test.ts"], + exclude: ["test/e2e/**"], globalSetup: ["./test/preload.ts"], testTimeout: 30000, }, diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts new file mode 100644 index 0000000..0bb2ebc --- /dev/null +++ b/vitest.e2e.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/e2e/**/*.test.ts"], + globalSetup: ["./test/preload.ts"], + testTimeout: 30000, + fileParallelism: false, + }, +});