acquisitionuniverse for Elixir
Elixir functions for people who work through acquisition target lists. Rank companies, validate a thesis brief, print it as text and count which of the 15 common signals are actually visible. Plain maps in, plain maps out, no dependencies.
{:acquisitionuniverse, "~> 1.0"}
Rank
companies = [
%{id: "A", mandate_fit: 90, outreach_suitability: 80, transition_context: 50},
%{id: "B", mandate_fit: 96, outreach_suitability: 90, transition_context: 70, group_owned: true}
]
AcquisitionUniverse.rank(companies) |> Enum.map(&{&1.id, &1.composite})
# [{"A", 84.0}, {"B", 74.2}]
B is the stronger fit. It is owned by a group, so it carries no outreach points and A comes first. Provide your own weights as a map with :mandate_fit, :outreach_suitability and :transition_context.
Brief
brief = %{
name: "Precision machining, Carolinas",
vertical: "Precision machining",
regions: ["North Carolina", "South Carolina"],
employees_min: 10,
employees_max: 90,
exclusions: ["group-owned"]
}
[] = AcquisitionUniverse.validate_brief(brief)
IO.puts(AcquisitionUniverse.brief_text(brief))
Coverage
[s0, _, s2 | _] = AcquisitionUniverse.signals()
AcquisitionUniverse.signal_coverage(%{s0 => "family associated", s2 => nil})
# %{visible: 1, checked: 2, ratio: 0.5}
Functions
| Function | Result |
|---|---|
signals/0 |
list of 15 names |
default_weights/0 |
0.7, 0.2, 0.1 |
composite/2 |
float, 0 to 100 |
rank/2 |
maps with :composite, best first |
signal_coverage/1 |
map with visible, checked, ratio |
validate_brief/1 |
list of problems |
brief_text/1 |
string |
pilot_url/0 |
pilot request page |
LiveView idea
A LiveView with a form for the weights and a table for rank/2 lets a partner drag the balance between fit and suitability and watch the list reorder. The data is just a list of maps, so Phoenix.LiveView.stream/3 fits well.
Process a CSV
rows =
"screen.csv"
|> File.stream!()
|> Stream.drop(1)
|> Stream.map(&String.split(String.trim(&1), ","))
|> Enum.map(fn [id, fit, out, trans, owned] ->
%{id: id, mandate_fit: String.to_float(fit), outreach_suitability: String.to_float(out),
transition_context: String.to_float(trans), group_owned: owned == "yes"}
end)
AcquisitionUniverse.rank(rows) |> Enum.take(10)
Related reading
Add-on buyers can compare notes in the portfolio company BD guide. The same team publishes cookieless audience database material for curation platforms and a skill normalization reference for resume data.
Why Elixir for target list work
A target list is a living thing. New companies appear, others are acquired, owners change, and a note from a call changes how a record should be read. Elixir suits systems that must stay up and keep state while those changes arrive. This package supplies the arithmetic and the brief handling for such a system, in plain functions with no processes of their own.
Because the functions are pure, you can call them from a LiveView, a Broadway pipeline, a script in Livebook or a test, and they behave the same way every time.
Rank inside a LiveView
A LiveView that shows a ranked list and lets a partner change the weights is a satisfying thing to build, and it takes few lines. Keep the companies in assigns, keep the weights in assigns and re-rank on every change event.
def handle_event("weights", %{"fit" => fit, "suit" => suit, "ctx" => ctx}, socket) do
weights = %{
mandate_fit: String.to_float(fit),
outreach_suitability: String.to_float(suit),
transition_context: String.to_float(ctx)
}
ranked = AcquisitionUniverse.rank(socket.assigns.companies, weights)
{:noreply, assign(socket, weights: weights, ranked: ranked)}
end
Because ranking a few thousand maps is quick, you can do it on every slider move without a debounce. For a hundred thousand companies, rank in a task and stream the result in.
Persisting with Ecto
Store each company as a row with the three scores, the group ownership flag and a jsonb column for the signal values. Rank in SQL if your list is very large, or in Elixir if it is modest. Keep a weights_version column on any saved shortlist, so you can say which weights produced it.
schema "targets" do
field :name, :string
field :mandate_fit, :float
field :outreach_suitability, :float
field :transition_context, :float
field :group_owned, :boolean, default: false
field :signals, :map
timestamps()
end
Converting a row to the map the package expects is a one line function. Add a composite field to the schema as a virtual field if you want to show it without storing it.
Coverage as a background job
Compute coverage for each record when it is created or updated, and store the ratio. A column of coverage ratios makes filters easy: show me companies with a composite above 80 and coverage above 0.7. That filter finds the companies that are both strong and well evidenced, the ones worth a call this week.
%{ratio: ratio} = AcquisitionUniverse.signal_coverage(target.signals)
Repo.update!(Ecto.Changeset.change(target, coverage: ratio))
The brief as data
The brief is a map, so it fits naturally in a changeset. Validate it with validate_brief/1 inside the changeset and add each problem as an error. A form built this way refuses to save a brief without regions or exclusions, which is what you want. The text version, from brief_text/1, makes a good default for an email body.
Reproducibility
Deals are discussed months after a list is made. Keep inputs, weights and outputs together. Store the ranked list as a snapshot with the date, the weights and the version of the package. When a partner asks why a company was called in March, you can reproduce the ranking that was on screen.
Livebook for exploration
Livebook is a good home for exploratory analysis of a delivered file. Load the CSV with Explorer or NimbleCSV, convert rows to maps, call rank/2 and render a table with Kino. Share the notebook with a colleague, and they can change a weight and watch the order move. Keep real target names out of notebooks you share outside the team.
Related reading
The portfolio company business development guide shows how an operating company finds add on candidates and customers with the same screening idea. The same company publishes curation platform audience data for advertising teams and a skill normalization reference for hiring data.
Vertical signals as data
A screening adds extra signals for your vertical. For equipment dealers and material handling services they include OEM dealer authorizations, factory trained technicians, a rental fleet, planned maintenance programmes and full maintenance lease language. Store them in a separate map on each record, and keep the fifteen common signals in their own map. The package counts only the common fifteen, so the extras never distort the coverage ratio. A small function that counts the visible values in the extras map gives you a second ratio, and a LiveView can show both side by side.
That pairing is useful in review meetings. A company with high common coverage and low vertical coverage looks like a well described business that does not advertise its credentials, and a call can settle it.
A closing thought: the real value of a ranking is the conversation it starts. Put the weights and the version in the footer of every view, so that when someone screenshots the list into a chat, the context travels with it, and nobody argues about an order produced by settings they have never seen.
Further reading and practical notes
The Elixir language site links to the guides for the language and the tools, and the Stanford search fund research pages give useful background on the search model for anyone building tools for searchers.
Notes for Elixir teams. Because the functions are pure, you can run them inside a Task, a LiveView callback or a Broadway processor with the same confidence. Keep the weights in application configuration and read them once at start up. Put the group ownership flag on the record at import time, since an import is the one place where a human can confirm it. Emit a telemetry event for every ranking run with the number of companies, the weights version and the duration, and you will see in your metrics when a list grows beyond what a single process should handle.
If you build a LiveView for partners, keep the heavy work off the socket process. Rank in a task, send the result back and stream it into the page. Add a button that exports the current view as a CSV with the weights in a comment line at the top.
A good habit is to test with real shaped fixtures. Create a CSV with fifty invented companies that cover the awkward cases: a perfect fit that is group owned, a modest fit with an independent owner, a company with no scores and a company with all scores at 100. Run the ranking in tests and assert the order. The fixture file then doubles as documentation for new colleagues.
Remember that the ranking is a tool for conversation. It does not decide anything by itself. Use it to ask better questions in the weekly pipeline meeting, such as why a company ranked fifth was never called, and let people answer with evidence.
Notes
The package targets Elixir 1.14 or later, has no dependencies and starts no processes. The 15 signal names are returned by signals/0, and the default weights by default_weights/0.
Questions
Does it call a server? No.
Elixir version? 1.14 or later.
License? MIT. info@alpha-quantum.com