Introduction
If you’ve read the previous Informedica post on F# Interactive (FSI) and why it’s a powerful tool in your development toolbox, then this post takes the next step. The first post introduced what FSI is and why it’s useful. This post shows how you can harness it practically on a brown-field project such as GenPRES.
Most of us don’t work in green-field environments where code is written fresh and clean. Instead, we struggle with brown-field codebases—systems that have grown over years, accreted complexity, and become mission-critical. Changing code in these environments is often intimidating: any modification risks unexpected consequences somewhere else in the system.
This is exactly where F# Interactive (FSI) becomes a superpower.
Building on that, here I show how you can:
- Extract and evolve existing production code, not just green-field prototypes
- Bring entire modules into a script, refactor them interactively
- Reuse your actual test suite in FSI
- Validate new features (like parallelism) without risking the main codebase
FSI lets us copy existing production code into a script file, make changes, and evaluate those changes instantly. Instead of performing risky refactors inside the main project, we isolate the code, try new ideas interactively, and only move changes back once we’re confident they work.
Tips & Tricks for Working with F# Interactive (FSI)
Here I list some tips and tricks I learned using the FSI for real production work.
1. Always Set the Current Directory
Environment.CurrentDirectory <- __SOURCE_DIRECTORY__
This ensures that relative paths work exactly as expected when loading or referencing other scripts or test assets.
2. Load Project Context Automatically
Use a bootstrap file like:
#load "load.fsx"
The bootstrap file loads all the dependent libraries, either local using source files or compiled libraries, or using nuget as shown below.
3. Reference NuGet Packages Inline
FSI handles this gracefully:
#r "nuget: Expecto, 9.0.4"
This lets you run tests, do property checks, and more without a full project file. It’s perfect for lightweight explorations.
4. Copy Only the Code You Need (Incrementally)
Instead of dragging entire modules into a script, start with just the functions you plan to modify or inspect — e.g., in the below example: the solve, solveAll, and helper functions from the Solver module.
5. Reuse Your Existing Tests
As shown above, you can #load your actual test files (e.g., from your tests directory) and run them directly in FSI. That’s an excellent way to verify behavior before touching the production code.
6. Modularize Your Script
Break your script into logical regions (helpers, solver refactor, tests) with comments. That makes it easier to jump around interactively.
7. Use Partial Evaluation
FSI lets you evaluate only parts of a script — use this to validate small functions or new approaches without reloading the whole file. Just select part of a script and send it to the FSI.
8. Keep FSI Sessions Alive
Rather than invoking FSI afresh each time, keep a session running so you build up state interactively. This is where tools like MCP integration become even more interesting.
9. Using FSI with MCP (Model Context Protocol) for Enhanced Interactive Workflows
You can go beyond the ordinary FSI experience by integrating it with an MCP-enabled server such as the open-source fsi-mcp-server. This tool is a drop-in replacement for the default fsi.exe that exposes F# Interactive sessions over a Model Context Protocol (MCP) interface.
This unlocks powerful workflows:
- AI-assisted interactive sessions Your AI assistant (e.g., Claude Code, Copilot, or another MCP-compatible agent) can programmatically send code to be evaluated in the same FSI session you’re using, inspect outputs, run tests, and more — without the classic friction of copy-paste or external execution.
- Seamless IDE Integration Replace your standard FSI executable in your editor (VS Code, Rider, etc.) with the MCP-enabled server, and the AI assistant becomes a first-class collaborative partner in your REPL workflow.
- Shared REPL State Across Interfaces Both you (at the console or editor send-to-REPL) and the AI agent share the same state — the definitions, modules, and script context are visible and modifiable by both sides.
This MCP integration keeps the tight interactive feedback loop of FSI alive even in more connected and collaborative workflows, especially useful when experimenting with large codebases or refactors like those in this blog.
A Real World Example: GenPRES
In the open-source project GenPRES, the Solver.fs module is a great example. It contains the core logic for evaluating product and sum equations—dense, interconnected, and essential. Before modifying it, we want a safe environment where we can experiment.
Below is the relevant part of the original solver.
Original solve Function (excerpt from Solver.fs)
let solve onlyMinIncrMax log sortQue var eqs =
let solveE n eqs eq =
try
Equation.solve onlyMinIncrMax log eq
with
| Exceptions.SolverException errs ->
(n, errs, eqs)
|> Exceptions.SolverErrored
|> Exceptions.raiseExc (Some log) errs
| e ->
writeErrorMessage $"didn't catch {e}"
failwith "unexpected"
let rec loop n que acc =
match acc with
| Error _ -> acc
| Ok acc ->
let n = n + 1
if n > (que @ acc |> List.length) * Constants.MAX_LOOP_COUNT then
(n, que @ acc)
|> Exceptions.SolverTooManyLoops
|> Exceptions.raiseExc (Some log) []
let que = que |> sortQue onlyMinIncrMax
match que with
| [] ->
match acc |> List.filter (Equation.check >> not) with
| [] -> Ok acc
| invalid ->
invalid
|> Exceptions.SolverInvalidEquations
|> Exceptions.raiseExc (Some log) []
| eq :: tail ->
let q, r =
if eq |> Equation.isSolvable |> not then
tail, Ok (eq :: acc)
else
match eq |> solveE n (acc @ que) with
| eq, Changed cs ->
let vars = cs |> List.map fst
acc |> replace vars |> fun (rpl, rst) ->
let que =
tail
|> replace vars
|> fun (es1, es2) -> es1 @ es2 @ rpl
que, Ok (eq :: rst)
| eq, Unchanged ->
tail, Ok (eq :: acc)
| eq, Errored m ->
[], Error (eq :: acc @ que, m)
loop n q r
match var with
| None -> eqs, []
| Some var -> eqs |> replace [var]
|> fun (rpl, rst) ->
match rpl with
| [] -> Ok eqs
| _ ->
rpl |> Events.SolverStartSolving |> Logger.logInfo log
loop 0 rpl (Ok rst)
This function works—but it’s intricate, highly recursive, and difficult to evolve safely.
Updated Solver in an .fsx Script (focused on differences)
To experiment safely, we moved a local copy of the solver into an F# script and add an alternative to the solving loop:
/// Solve equations in parallel.
/// Still an experimental feature.
/// Parallel distribution is cyclic
let parallelLoop onlyMinIncrMax log sortQue n rpl rst =
let solveE n eqs eq =
try
Equation.solve onlyMinIncrMax log eq
with
| Exceptions.SolverException errs ->
(n, errs, eqs)
|> Exceptions.SolverErrored
|> Exceptions.raiseExc (Some log) errs
| e ->
let msg = $"didn't catch {e}"
writeErrorMessage msg
msg |> failwith
let rec loop n que acc =
match acc with
| Error _ -> acc
| Ok acc ->
let n = n + 1
let c = que @ acc |> List.length
if c > 0 && n > c * Constants.MAX_LOOP_COUNT then
writeErrorMessage $"too many loops: {n}"
(n, que @ acc)
|> Exceptions.SolverTooManyLoops
|> Exceptions.raiseExc (Some log) []
match que with
| [] ->
match acc |> List.filter (Equation.check >> not) with
| [] -> acc |> Ok
| invalid ->
writeErrorMessage "invalid equations"
invalid
|> Exceptions.SolverInvalidEquations
|> Exceptions.raiseExc (Some log) []
| _ ->
let que, acc =
que
|> List.partition Equation.isSolvable
|> function
| que, unsolv -> que, unsolv |> List.append acc
// make sure that the equations with the lowest cost
// are prioritezed
let que = que |> sortQue onlyMinIncrMax
// apply parallel equation solving to the
// first number of optimal parallel workers
let rstQue, (rpl, rst) =
let queLen = que |> List.length
// calculate optimal number of workers
let workers =
if Parallel.totalWorders > queLen then queLen
else Parallel.totalWorders
// return remaining que and calculate
// in parallel the worker que
if workers >= queLen then []
else que |> List.skip workers
,
que
|> List.take workers
|> List.map (fun eq ->
async {
return eq |> solveE n (acc @ que)
}
)
|> Async.Parallel
|> Async.RunSynchronously
|> Array.toList
|> List.partition (snd >> function | Changed _ -> true | _ -> false)
let rst, err =
rst
|> List.partition (snd >> function | Errored _ -> true | _ -> false)
|> function
| err, rst ->
rst |> List.map fst,
err
|> List.choose (fun (_, sr) -> sr |> function | Errored m -> Some m | _ -> None)
|> List.collect id
if err |> List.isEmpty |> not then (que |> List.append acc, err) |> Error
else
let rpl, vars =
rpl
|> List.unzip
let vars =
vars
|> List.choose (function
| Changed vars -> Some vars
| _ -> None
)
|> List.collect id
|> List.fold (fun (vars : Variable list) (var, _) ->
match vars |> List.tryFind (Variable.eqName var) with
| None -> var::vars
| Some v ->
let vNew = v |> Variable.setValueRange var.Values
vars |> List.replace (Variable.eqName vNew) vNew
) []
// make sure that vars are updated with changed vars
// in the remaining que
let rstQue =
rstQue
|> replace vars
|> function
| es1, es2 -> es1 |> List.append es2
// calculate new accumulator and que
let acc, que =
acc
|> List.append rst
|> replace vars
|> function
| es1, es2 ->
es2 |> Ok,
es1
|> List.append rpl
|> List.append rstQue
loop n que acc
loop n rpl rst
By copying the solver module into a script:
- You can refactor or extend the code incrementally without touching production.
- You can run the solver interactively on sample equations.
- You can introduce features (like parallelism) without fear.
- You gain instant feedback from FSI.
- When satisfied, you copy the improved code back into the main project.
Running the Existing Tests from the Same Script
The really nice part is that you don’t just get to run ad-hoc experiments: you can also pull in your existing test suite and reuse it directly from the script.
In the same .fsx file, we load the existing tests from the main solution and define a small test module that compares the behavior of the sequential and parallel solvers:
// load the existing tests
#load "../../../tests/Informedica.GenSOLVER.Tests/Tests.fs"
open MathNet.Numerics
open Expecto
open Expecto.Flip
open Informedica.GenSolver.Tests
open Informedica.GenUnits.Lib
module Tests =
module ParallelTests =
open Informedica.Logging.Lib
open Informedica.GenSolver.Lib
let logger =
fun (_ : string) -> ()
|> SolverLogging.create
/// Solve equations sequentially
let solveSequential onlyMinMax eqs =
Informedica.GenSolver.Lib.Solver.solveAll
false // useParallel = false (sequential)
onlyMinMax
logger
eqs
/// Solve equations in parallel
let solveParallel onlyMinMax eqs =
Informedica.GenSolver.Lib.Solver.solveAll
true // useParallel = true
onlyMinMax
logger
eqs
/// Helper to compare equation results
let eqsAreEqual eqs1 eqs2 =
match eqs1, eqs2 with
| Ok eqs1, Ok eqs2 ->
let s1 =
eqs1
|> List.map (Equation.toString true)
|> List.sort
let s2 =
eqs2
|> List.map (Equation.toString true)
|> List.sort
s1 = s2
| Error _, Error _ -> true
| _ -> false
let mg = Units.Mass.milliGram
let day = Units.Time.day
let kg = Units.Weight.kiloGram
let mgPerDay = CombiUnit(mg, OpPer, day)
let mgPerKgPerDay = (CombiUnit (mg, OpPer, kg), OpPer, day) |> CombiUnit
let tests = testList "Parallel vs Sequential Solving" [
test "simple product equation gives same results" {
let eqs =
[ "a = b * c" ]
|> TestSolver.init
|> TestSolver.setMinIncl Units.Count.times "a" 1N
|> TestSolver.setMaxIncl Units.Count.times "a" 100N
|> TestSolver.setMinIncl Units.Count.times "b" 1N
|> TestSolver.setMaxIncl Units.Count.times "b" 10N
|> TestSolver.setMinIncl Units.Count.times "c" 1N
|> TestSolver.setMaxIncl Units.Count.times "c" 10N
let seqResult = eqs |> solveSequential true
let parResult = eqs |> solveParallel true
eqsAreEqual seqResult parResult
|> Expect.isTrue "sequential and parallel should give same results"
}
test "sum equation gives same results" {
let eqs =
[ "total = a + b + c" ]
|> TestSolver.init
|> TestSolver.setMinIncl Units.Count.times "total" 10N
|> TestSolver.setMaxIncl Units.Count.times "total" 100N
|> TestSolver.setMinIncl Units.Count.times "a" 1N
|> TestSolver.setMaxIncl Units.Count.times "a" 50N
|> TestSolver.setMinIncl Units.Count.times "b" 1N
|> TestSolver.setMaxIncl Units.Count.times "b" 50N
|> TestSolver.setMinIncl Units.Count.times "c" 1N
|> TestSolver.setMaxIncl Units.Count.times "c" 50N
let seqResult = eqs |> solveSequential true
let parResult = eqs |> solveParallel true
eqsAreEqual seqResult parResult
|> Expect.isTrue "sequential and parallel should give same results for sum"
}
test "multiple equations give same results" {
let eqs =
[ "ParacetamolDoseTotal = ParacetamolDoseTotalAdjust * Adjust" ]
|> TestSolver.init
|> TestSolver.setMinIncl mgPerDay "ParacetamolDoseTotal" 180N
|> TestSolver.setMaxIncl mgPerDay "ParacetamolDoseTotal" 3000N
|> TestSolver.setMinIncl mgPerKgPerDay "ParacetamolDoseTotalAdjust" 40N
|> TestSolver.setMaxIncl mgPerKgPerDay "ParacetamolDoseTotalAdjust" 90N
|> TestSolver.setMaxIncl kg "Adjust" 100N
let seqResult = eqs |> solveSequential true
let parResult = eqs |> solveParallel true
eqsAreEqual seqResult parResult
|> Expect.isTrue "sequential and parallel should give same results for complex equation"
}
test "chained equations give same results" {
let eqs =
[
"x = a * b"
"y = x * c"
"z = y * d"
]
|> TestSolver.init
|> TestSolver.setMinIncl Units.Count.times "a" 1N
|> TestSolver.setMaxIncl Units.Count.times "a" 10N
|> TestSolver.setMinIncl Units.Count.times "b" 1N
|> TestSolver.setMaxIncl Units.Count.times "b" 10N
|> TestSolver.setMinIncl Units.Count.times "c" 1N
|> TestSolver.setMaxIncl Units.Count.times "c" 10N
|> TestSolver.setMinIncl Units.Count.times "d" 1N
|> TestSolver.setMaxIncl Units.Count.times "d" 10N
|> TestSolver.setMinIncl Units.Count.times "z" 1N
|> TestSolver.setMaxIncl Units.Count.times "z" 10000N
let seqResult = eqs |> solveSequential true
let parResult = eqs |> solveParallel true
eqsAreEqual seqResult parResult
|> Expect.isTrue "sequential and parallel should give same results for chained equations"
}
test "with value sets gives same results" {
let eqs =
[ "dose = qty * freq" ]
|> TestSolver.init
|> TestSolver.setMinIncl Units.Count.times "dose" 10N
|> TestSolver.setMaxIncl Units.Count.times "dose" 100N
|> TestSolver.setValues Units.Count.times "freq" [1N; 2N; 3N; 4N]
|> TestSolver.setMinIncl Units.Count.times "qty" 1N
|> TestSolver.setMaxIncl Units.Count.times "qty" 50N
let seqResult = eqs |> solveSequential false // use full solving with value sets
let parResult = eqs |> solveParallel false
eqsAreEqual seqResult parResult
|> Expect.isTrue "sequential and parallel should give same results with value sets"
}
]
let run () =
tests
|> runTestsWithCLIArgs [] [| "--summary" |]
With this in place, you can now:
- Edit the solver implementation in the same script.
- Run Tests.ParallelTests.run() in FSI.
- Immediately verify that your new parallel implementation behaves the same as the existing sequential one across realistic scenarios taken from your domain.
This is the essence of taming a brown field with F#: you don’t just change code—you bring the code, the tests, and the runtime together in a single interactive environment, and let FSI give you fast, tight feedback loops while you evolve a mature codebase.
Conclusion: From Green Field Ideals to Brown Field Reality
Green-field projects are where we learn languages and frameworks. Brown-field projects are where we actually use them.
In a green-field setting, everything is possible: you control the architecture, the abstractions, and the direction of the code. In brown-field systems—like GenPRES—you inherit real constraints: production behavior that must not change, performance characteristics that matter, and code that has accumulated knowledge over time. The challenge is not writing new code, but changing existing code safely.
This is where F# Interactive truly distinguishes itself.
By lifting real production modules into an .fsx script, you create a safe experimental zone inside a mature system. You can refactor, optimize, and even introduce new execution models—such as parallel solving—while continuously validating behavior against the actual test suite. The feedback loop is immediate, the risk is contained, and confidence grows with every evaluated expression.
Seen in this light, FSI is not just a REPL or a learning tool. It is a brown-field engineering instrument.
If the earlier Informedica post showed why FSI is such a powerful tool for exploration and understanding, this follow-up shows how to apply it pragmatically to evolve a real, non-trivial codebase. And with newer approaches—such as running FSI through an MCP-enabled server—you can even extend that interactive loop to include AI-assisted workflows without sacrificing the shared state and immediacy that make FSI so effective.
Brown-field development doesn’t have to mean slow, risky, or opaque change. With F#, FSI, and a disciplined interactive workflow, it becomes iterative, observable, and surprisingly enjoyable.
That, ultimately, is how you tame the brown field.
![]()
