Taming the Brown Field with F#: Interactive Refactoring for Mature Codebases

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.

Loading

Leave a Reply

Your email address will not be published. Required fields are marked *